这份报告总结了Vyper编译器代码审计的结果,发现了2个高风险、7个中风险和22个低风险的问题。
__default__
和/或 __init__
函数的有效 JSON ABI 接口calc_mem_gas()
中的舍入导致 gas 成本估算不正确_bytes_to_num()
跳过 ensure_in_memory()
检查,这可能导致编译失败shift()
函数将失败由 KuroHashDit, obront 提交。选定的提交者:KuroHashDit。
slice() 代码中存在整数溢出,这将导致内存损坏。
POC:
d: public(Bytes[256])
@external
def test():
x : uint256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 # 2**256-1
self.d = b"\x01\x02\x03\x04\x05\x06"
# s : Bytes[256] = slice(self.d, 1, x)
assert len(slice(self.d, 1, x))==115792089237316195423570985008687907853269984665640564039457584007913129639935
由于 x 是一个变量,slice(self.d, 1, x) 将返回一个 Bytes[256] 对象。但是,由于整数溢出,此 Bytes[256] 对象的长度将被写入 2**256-1,并且访问此对象可能会导致内存损坏。
根源:
vyper/builtins/functions.py 中的第 348 行
@process_inputs
def build_IR(self, expr, args, kwargs, context):
src, start, length = args
# 处理 `msg.data`、`self.code` 和 `<address>.code`
if src.value in ADHOC_SLICE_NODE_MACROS:
return _build_adhoc_slice_node(src, start, length, context)
is_bytes32 = src.typ == BYTES32_T
if src.location is None:
# 它不是指针;强制它成为一个,因为
# copy_bytes 适用于指针。
assert is_bytes32, src
src = ensure_in_memory(src, context)
with src.cache_when_complex("src") as (b1, src), start.cache_when_complex("start") as (
b2,
start,
), length.cache_when_complex("length") as (b3, length):
if is_bytes32:
src_maxlen = 32
else:
src_maxlen = src.typ.maxlen
dst_maxlen = length.value if length.is_literal else src_maxlen
buflen = dst_maxlen
# 将 32 字节添加到缓冲区大小,因为字访问可能
# 未对齐(见下文)
if src.location == STORAGE:
buflen += 32
# 获取 returntype string 或 bytes
assert isinstance(src.typ, _BytestringT) or is_bytes32
# TODO: 尝试从语义分析中获取 dst_typ
if isinstance(src.typ, StringT):
dst_typ = StringT(dst_maxlen)
else:
dst_typ = BytesT(dst_maxlen)
# 为返回值分配一个缓冲区
buf = context.new_internal_variable(BytesT(buflen))
# 为其分配正确的返回类型。
# (注意 dst_maxlen 和 buflen 之间的不匹配)
dst = IRnode.from_list(buf, typ=dst_typ, location=MEMORY)
dst_data = bytes_data_ptr(dst)
if is_bytes32:
src_len = 32
src_data = src
else:
src_len = get_bytearray_length(src)
src_data = bytes_data_ptr(src)
# 一般情况。逐字节复制
if src.location == STORAGE:
# 因为 slice 使用字节寻址,但 storage
# 是字对齐的,所以该算法从某个数字开始
# 在数据段开始之前的字节数,可能复制
# 一个额外的字。伪代码是:
# dst_data = dst + 32
# copy_dst = dst_data - start % 32
# src_data = src + 32
# copy_src = src_data + (start - start % 32) / 32
# = src_data + (start // 32)
# copy_bytes(copy_dst, copy_src, length)
# //在复制后设置长度,因为长度字已被覆盖!
# mstore(src, length)
# 从 `start` 之前的第一个字对齐地址开始
# 例如 start == 字节 7 -> 我们从字节 0 开始复制
# start == 字节 32 -> 我们从字节 32 开始复制
copy_src = IRnode.from_list(
["add", src_data, ["div", start, 32]], location=src.location
)
# 例如 start == 字节 0 -> 我们复制到 dst_data + 0
# start == 字节 7 -> 我们复制到 dst_data - 7
# start == 字节 33 -> 我们复制到 dst_data - 1
copy_dst = IRnode.from_list(
["sub", dst_data, ["mod", start, 32]], location=dst.location
)
# len + (32 if start % 32 > 0 else 0)
copy_len = ["add", length, ["mul", 32, ["iszero", ["iszero", ["mod", start, 32]]]]]
copy_maxlen = buflen
else:
# 所有其他地址空间(mem、calldata、code)我们都有
# 字节对齐访问,所以我们可以做简单的事情,
# memcopy(dst_data, src_data + dst_data)
copy_src = add_ofst(src_data, start)
copy_dst = dst_data
copy_len = length
copy_maxlen = buflen
do_copy = copy_bytes(copy_dst, copy_src, copy_len, copy_maxlen)
ret = [
"seq",
# 确保我们不会超出源缓冲区
["assert", ["le", ["add", start, length], src_len]], # 边界检查 #BUG 代码在这里 start + length 可能会溢出
do_copy,
["mstore", dst, length], # 设置长度
dst, # 返回指向 dst 的指针
]
ret = IRnode.from_list(ret, typ=dst_typ, location=MEMORY)
return b1.resolve(b2.resolve(b3.resolve(ret)))
["assert", ["le", ["add", start, length], src_len]] 可能会有整数溢出,绕过此处的断言,并最终将错误的长度写入 dst。
中风险
在此处修复整数溢出
由 cyberthirst, KuroHashDit 提交。选定的提交者:cyberthirst。
concat
内置函数可能会写入为其分配的内存缓冲区的边界之外,从而覆盖现有的有效数据。至少对于 v0.3.10rc3*
而言,根本原因是 concat
的 build_IR
没有正确遵守 copy_bytes
的 API。
build_IR
为连接分配一个新的内部变量:https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/builtins/functions.py#L534-L550
请注意,缓冲区分配给 maxlen
+ 1 个字,以实际保存数组的长度。
稍后,copy_bytes
函数用于将实际的源参数复制到目标:https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/builtins/functions.py#L569-L572
dst_data
定义为:
["set", ofst, ["add", ofst, arglen]]
,即它按源参数的长度增加现在,copy_bytes
函数有多个控制流路径,以下是比较有趣的路径:
第 1 个:https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L270-L273
第 2 个:https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L301-L320
可以看出,在这两个路径中,都可以将源中的一个字复制到目标。
请注意,该函数本身包含以下说明: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L245-L247
也就是说,我们可以要求复制 1B
,但会复制整个字。
现在,如果 dst_data
到 concat 数据缓冲区末尾的距离 < 32B,copy_op = STORE(dst, LOAD(src))
来自 copy_bytes
将导致缓冲区溢出,因为它本质上会将 mstore
到 dst_data
的源的 mload
(mload 将加载整个字,并且 dst_data
到字边界的距离 < 32B)。对 copy_bytes
中的第二个路径的论证是类似的。
发现的主要攻击向量是当 concat
位于 internal
函数或 __init__()
中时。 假设我们有一个调用 internal
函数的 external
函数。 在这种情况下,地址空间被划分,使得内部函数的内存位于地址空间的较低部分。 因此,缓冲区溢出可以覆盖调用方的有效数据。
这是一个简单的例子:
##@version ^0.3.9
@internal
def bar() -> uint256:
sss: String[2] = concat("a", "b")
return 1
@external
def foo() -> int256:
a: int256 = -1
b: uint256 = self.bar()
return a
foo
显然应该返回 -1
,但它返回 452312848583266388373324160190187140051835877600158453279131187530910662655
-1
是故意使用的,因为它的位结构,但这里的值是相当不相关的。 在此示例中,在 build_IR
中 for 循环的第二次迭代期间,将执行 mload
到 dst+1
(因为 len('a') == 1),因此该函数会将 1B
写入缓冲区的边界之外。 字符串“b”的存储方式使得该字的右侧字节是一个零字节。 因此,零字节将被写入边界之外。 因此,当考虑 -1
时,它的最左侧字节将被覆盖为全 0。 因此,可以看出:452312848583266388373324160190187140051835877600158453279131187530910662655 == (2**248-1)
将输出 True
。
如果我们查看合约的 IR (vyper --no-optimize -f it),我们会看到:
## 第 30 行
/* a: int256 = -1 */ [mstore, 320, -1 <-1>],
对于 concat 中循环的第二次迭代:
len,
[mload, arg],
[seq,
[with,
src,
[add, arg, 32],
[with,
dst,
[add, [add, 256 <concat destination>, 32], concat_ofst],
[mstore, dst, [mload, src]]]],
[set, concat_ofst, [add, concat_ofst, len]]]]],
[mstore, 256 <concat destination>, concat_ofst],
256 <concat destination>]],
因此 int
的地址是 320。
dst
定义为:[add, [add, 256 <concat destination>, 32], concat_ofst],
。
在第二次迭代中,concat_ofst
将为 1,因为 len('a)==1
,因此 256+32+1 = 289
。 现在,这个地址将被 mstored
到 - 因此,最后 mstored
的 B 的地址将为 289+32=321
,这显然与 int a
的地址重叠。
__init__()
为了演示第二条提到的路径(更长的 length_bound
- 一般情况)中的漏洞:
##@version ^0.3.9
s: String[1]
s2: String[33]
s3: String[34]
@external
def __init__():
self.s = "a"
self.s2 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # 33*'a'
@internal
def bar() -> uint256:
self.s3 = concat(self.s, self.s2)
return 1
@external
def foo() -> int256:
i: int256 = -1
b: uint256 = self.bar()
return i
调用 foo()
的输出是 452312848583266388373324160190187140051835877600158453279131187530910662655
。
最后,对于 __init__()
函数的 PoC,在这种情况下,immutables
可以被覆盖:
##@version ^0.3.9
i: immutable(int256)
@external
def __init__():
i = -1
s: String[2] = concat("a", "b")
@external
def foo() -> int256:
return i
调用 foo()
的输出是 452312848583266388373324160190187140051835877600158453279131187530910662655
。
缓冲区溢出可能导致合约语义的完全更改,如果攻击者控制函数的输入,情况会更糟。 因为溢出不一定每次都发生,因此在合约测试期间可能会被忽略,并且脆弱的代码可以部署在链上。
但是,并非 concat
的所有用法都会导致覆盖有效数据,因为我们需要它位于 internal
函数中并且靠近 return
语句,在这种情况下不会发生其他内存分配。 因此,可能性被认为是中等的。
该漏洞似乎在以下位置被引入:548d35d720fb6fd8efbdc0ce525bed259a73f0b9
。 在 v0.3.1
(看起来不错)和 v0.3.2
(已经很糟糕)之间使用了 git bisect
,并且运行了 forge test
,并且测试断言该函数确实返回 -1。 因此,在此提交之后使用 vyper
部署的合约可能会受到影响。
手动审查以查找错误。 boa + forge + git bisect 用于测试。
一种可能的解决方案是过度分配用于连接的缓冲区。 必须确保即使源参数被复制到目标,当目标接近缓冲区末尾时(即距离小于 32B),也不会导致缓冲区溢出。
由 cryptonoob 提交。
在 vyper 的 0.3.10 版本中,由于 cli/vyper_compile.py 的 compile_files 函数参数定义发生更改,vyper-serve 无法编译字节码 HTTP 请求,如下所示。 这使得无法使用 vyper-serve 通过 HTTP 编译合约
在 vyper 的 0.3.9 版本中,cli/vyper_compile.py 的 compiles_files 函数声明如下:
## cli/vyper_compile.py 版本 0.3.9
def compile_files(
input_files: Iterable[str],
output_formats: OutputFormats,
root_folder: str = ".",
show_gas_estimates: bool = False,
evm_version: str = DEFAULT_EVM_VERSION,
no_optimize: bool = False,
storage_layout: Iterable[str] = None,
no_bytecode_metadata: bool = False,
) -> OrderedDict:
# ...
但是,vyper 的 cli/vyper_compile.py 的 compile_files 0.3.10rc3 版本删除了 EVM_VERSION_FIELD 参数:
## cli/vyper_compile.py 版本 0.3.10rc3
def compile_files(
input_files: Iterable[str],
output_formats: OutputFormats,
root_folder: str = ".",
show_gas_estimates: bool = False,
settings: Optional[Settings] = None,
storage_layout: Optional[Iterable[str]] = None,
no_bytecode_metadata: bool = False,
) -> OrderedDict:
# ...
compile_files 在 vyper_serve.py 中被调用,evm_version 作为参数:
## vyper_serve.py
def _compile(self, data):
code = data.get("code")
# ...
try:
code = data["code"]
out_dict = vyper.compile_codes(
{"": code},
list(vyper.compiler.OUTPUT_FORMATS.keys()),
evm_version=data.get("evm_version", DEFAULT_EVM_VERSION), # <<== EVM_VERSION
)[""]
所以,现在传递给 cli/vyper_compile.py 的参数无效,并且 vyper_serve.py 无法再生成字节码。
验证此漏洞的一个简单方法是安装两个版本(0.3.9 和 0.3.10rc3),如下所示:
步骤 1 安装 0.3.9 并启动 vyper-serve: 在一个终端中安装 0.3.9 版本:
cd /tmp/
virtualenv vyper_venv9
source vyper_venv9/bin/activate
pip install vyper==0.3.9
vyper --version
启动 vyper-serve:
vyper-serve
使用 http 请求编译合约:
curl -X POST localhost:8000/compile -H "Content-Type: application/json" -d '{"code": "\n\n# @version ^0.3.7\n\n@external\ndef foo():\n pass\n"}'
观察成功的响应:
{
"ast_dict": {
"contract_name": "",
"ast": {
"ast_type": "Module",
"src": "0:50:0",
"end_col_offset": 8,
"doc_string": null,
"node_id": 0,
"lineno": 1,
"body": [
{
"args": {
"args": ...
}
...
}
使用 Ctrl-C
停止 vyper-serve
步骤 2 观察 vyper-serve 0.3.10 无法编译字节码 安装 0.3.10 版本:
cd /tmp/
virtualenv vyper_venv10
source vyper_venv10/bin/activate
pip install vyper==0.3.10rc3
vyper --version
启动 vyper-serve:
vyper-serve
使用 http 请求尝试编译相同的合约:
curl -X POST localhost:8000/compile -H "Content-Type: application/json" -d '{"code": "\n\n# @version ^0.3.7\n\n@external\ndef foo():\n pass\n"}'
观察响应:
curl: (52) Empty reply from server
以及来自 vyper-serve 控制台的堆栈跟踪:
----------------------------------------
Exception occurred during processing of request from ('127.0.0.1', 44642)
Traceback (most recent call last):
File "/usr/lib/python3.10/socketserver.py", line 683, in process_request_thread
self.finish_request(request, client_address)
File "/usr/lib/python3.10/socketserver.py", line 360, in finish_request
self.RequestHandlerClass(request, client_address, self)
File "/usr/lib/python3.10/socketserver.py", line 747, in __init__
self.handle()
File "/usr/lib/python3.10/http/server.py", line 433, in handle
self.handle_one_request()
File "/usr/lib/python3.10/http/server.py", line 421, in handle_one_request
method()
File "/tmp/vyper_venv10/lib/python3.10/site-packages/vyper/cli/vyper_serve.py", line 72, in do_POST
response, status_code = self._compile(data)
File "/tmp/vyper_venv10/lib/python3.10/site-packages/vyper/cli/vyper_serve.py", line 94, in _compile
out_dict = vyper.compile_codes(
TypeError: compile_codes() got an unexpected keyword argument 'evm_version'
----------------------------------------
这是由于上面解释的 compile_files 中所做的更改。
用户无法使用 vyper-serve 编译字节码,导致功能丧失/拒绝服务
影响:低 可能性:高
CVSS 中 - 4.3 AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
手动分析
更改 cli/vyper_compile.py compile_files 函数定义以考虑来自 vyper_serve.py@_compile
函数的 evm_version 参数(例如在 0.3.9 版本中)
由 KuroHashDit 提交。
SHA3_64 的计算中存在错误,这将产生错误哈希结果,并可能影响 HashMap 对象的访问。
compile_ir.py 中的第 583 行
# SHA3 一个 64 字节值
elif code.value == "sha3_64":
o = _compile_to_assembly(code.args[0], withargs, existing_labels, break_dest, height)
o.extend(_compile_to_assembly(code.args[1], withargs, existing_labels, break_dest, height))
o.extend(
[
*PUSH(MemoryPositions.FREE_VAR_SPACE2),
"MSTORE",
*PUSH(MemoryPositions.FREE_VAR_SPACE),
"MSTORE",
*PUSH(64),
*PUSH(MemoryPositions.FREE_VAR_SPACE),
"SHA3",
]
)
return o
o.extend(_compile_to_assembly(code.args[1], withargs, existing_labels, break_dest, height)) 应该在 height+1 上。此代码将影响 withargs 变量的正确访问。
由于 SHA3_64 与 HashMap 对象的读取和写入有关,因此它对合约链上的数据具有重要影响。总体影响应该是高水平。
POC 代码:
(with _loc
(with val 1
(with key 2
(sha3_64 val key)))
(seq
(s`RawCall` 内置函数的 `RawCall` 处理程序没有检查 `value` 是否传递给内置函数,以及 `is_delegate_call` 或 `is_static_call` 是否为真:
https://github.com/Cyfrin/2023-09-vyper-compiler/blob/main/vyper/builtins/functions.py#L1100
```python
class RawCall(BuiltinFunction):
---------
def build_IR(self, expr, args, kwargs, context):
---------
gas, value, outsize, delegate_call, static_call, revert_on_failure = (
kwargs["gas"],
kwargs["value"],
kwargs["max_outsize"],
kwargs["is_delegate_call"],
kwargs["is_static_call"],
kwargs["revert_on_failure"],
)
---------
if delegate_call:
call_op = ["delegatecall", gas, to, *common_call_args] # @audit 应该检查如果 is_delegate_call 为真,那么 value == 0
elif static_call:
call_op = ["staticcall", gas, to, *common_call_args] # @audit 应该检查如果 is_static_call 为真,那么 value == 0
call_ir += [call_op]
---------
return IRnode.from_list(call_ir, typ=typ)
这是一个 Vyper 中的示例实现,可以成功编译和部署:
event logUint256:
logged_uint256: indexed(uint256)
@external
@payable
def delegatedTo1():
log logUint256(msg.value)
@external
@payable
def delegatedTo2():
log logUint256(msg.value)
@external
@payable
def delegateToSelf():
return_data: Bytes[300] = b""
call_data1: Bytes[100] = _abi_encode(b"",method_id=method_id("delegatedTo1()"))
call_data2: Bytes[100] = _abi_encode(b"",method_id=method_id("delegatedTo2()"))
return_data = raw_call(self, call_data1, max_outsize=255, is_delegate_call=True, value=msg.value/2)
return_data = raw_call(self, call_data2, max_outsize=255, is_delegate_call=True, value=msg.value/2)
在上面的例子中,开发者希望在 delegatedTo1/2 中收到传递的 msg.value/2
,但实际上他们收到的却是完整的 msg.value
。
当向 delegateToSelf
发送 100
时,交易追踪显示两个 delegatecall 都输出 100(0x64)
,而不是 50
:
function test_incorrectMsgValueDelegatecall() external {
address vyper = address(0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0);
vyper.call{value: 100}(abi.encodeWithSignature("delegateToSelf()"));
}
--------
Running 1 test for test/Counter.t.sol:CounterTest
[PASS] test_incorrectMsgValueDelegatecall() (gas: 13858)
Traces:
[13858] CounterTest::test_incorrectMsgValueDelegatecall()
├─ [3956] 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0::delegateToSelf{value: 100}()
│ ├─ [27] PRECOMPILE::identity(0x) [staticcall]
│ │ └─ ← 0x
│ ├─ [27] PRECOMPILE::identity(0x) [staticcall]
│ │ └─ ← 0x
│ ├─ [1221] 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0::541c930c(00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000) [delegatecall]
│ │ ├─ emit topic 0: 0xd74736c81b9d709d9d3cc16b682a1075c6b99b57b848fefb07ba5368ff27827d
│ │ │ topic 1: 0x0000000000000000000000000000000000000000000000000000000000000064
│ │ │ data: 0x
│ │ └─ ← ()
│ ├─ [18] PRECOMPILE::identity(0x) [staticcall]
│ │ └─ ← 0x
│ ├─ [1221] 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0::f0b781bd(00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000) [delegatecall]
│ │ ├─ emit topic 0: 0xd74736c81b9d709d9d3cc16b682a1075c6b99b57b848fefb07ba5368ff27827d
│ │ │ topic 1: 0x0000000000000000000000000000000000000000000000000000000000000064
│ │ │ data: 0x
│ │ └─ ← ()
│ ├─ [18] PRECOMPILE::identity(0x) [staticcall]
│ │ └─ ← 0x
│ └─ ← ()
└─ ← ()
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 186.29ms
然而,在 Solidity 中,这是不可能的。编译器会抛出一个错误:
pragma solidity 0.8.17;
contract SolidityDelegatecallValue {
function tryMe() external {
(bool succeess, bytes memory retVal) = address(this).delegatecall{value: 100}("");
}
receive() external payable {}
}
编译时:
Error:
Compiler run failed:
Error (6189): Cannot set option "value" for delegatecall.
--> src/solidity_delegatecall_value.sol:5:48:
|
5 | (bool succeess, bytes memory retVal) = address(this).delegatecall{value: 100}("");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
这允许开发者使用一个 value
来执行 delegatecall 或 staticcall,而由于 delegatecall 和 staticcall 的性质,这个 value
不会被使用。这可能会扰乱会计,并且容易被开发者忽略,从而导致资金损失。
这在 multicall 实现中将是非常有问题的。
一个真实的例子是流行的 snekmate
库的实现:
https://github.com/pcaversaccio/snekmate/blob/5fe40ea7376b0405244d6c3f4f4f6c7b047c146b/src/utils/Multicall.vy#L169
@external
@payable
def multicall_value_self(data: DynArray[BatchValueSelf, max_value(uint8)]) -> DynArray[Result, max_value(uint8)]:
------
value_accumulator: uint256 = empty(uint256)
results: DynArray[Result, max_value(uint8)] = []
return_data: Bytes[max_value(uint8)] = b""
success: bool = empty(bool)
for batch in data:
msg_value: uint256 = batch.value
value_accumulator = unsafe_add(value_accumulator, msg_value)
if (batch.allow_failure == False):
return_data = raw_call(self, batch.call_data, max_outsize=255, value=msg_value, is_delegate_call=True)
success = True
results.append(Result({success: success, return_data: return_data}))
else:
success, return_data = \
raw_call(self, batch.call_data, max_outsize=255, value=msg_value, is_delegate_call=True, revert_on_failure=False)
results.append(Result({success: success, return_data: return_data}))
assert msg.value == value_accumulator, "Multicall: value mismatch"
return results
Foundry, Vyper
如果 value
不为 0
且 is_delegate_call
或 is_static_call
为真,则在 builtins/functions.py
中的 RawCall.build_ir
中抛出一个异常。
由 obront 提交。
当实现一个合约接口时,编译器会检查接口中的每个函数在合约中是否都有一个对应的公开函数。然而,它不检查这些函数是否具有相同的可见性,这可能会导致危险的情况。
当对实现接口的合约执行语义分析时,编译器会调用 type_.validate_implements(node)
来确认接口是否被正确实现。
这个函数会遍历接口中的所有公共函数,检查我们是否实现了一个具有相同名称的函数,然后验证所有的参数和返回类型是否为相同的类型。最后,它检查我们函数的状态可变性是否不大于接口。
def implements(self, other: "ContractFunctionT") -> bool:
"""
检查此函数是否实现了另一个函数的签名。
当确定一个接口是否被实现时使用。这个方法不应该被任何继承的类直接实现。
"""
if not self.is_external:
return False
arguments, return_type = self._iface_sig
other_arguments, other_return_type = other._iface_sig
if len(arguments) != len(other_arguments):
return False
for atyp, btyp in zip(arguments, other_arguments):
if not atyp.compare_type(btyp):
return False
if return_type and not return_type.compare_type(other_return_type): # type: ignore
return False
if self.mutability > other.mutability:
return False
return True
如果我们看一下 mutability 枚举,我们可以看到“大于”表示一个限制较少的可变性:
class StateMutability(_StringEnum):
PURE = _StringEnum.auto()
VIEW = _StringEnum.auto()
NONPAYABLE = _StringEnum.auto()
PAYABLE = _StringEnum.auto()
这意味着,虽然我们不能获取接口上的 view 函数并将其实现为 nonpayable 函数,但我们可以反过来,将任何函数实现为更严格的类型。
虽然对于某些类型,这可能是有意义的,但它可能会导致 payable 函数出现问题。
接口旨在定义合约执行所需的行为。如果一个接口将一个函数定义为 payable,那么交互合约可以安全地将 ETH 发送到该函数。然而,如果一个实现该接口的合约将该函数更改为 nonpayable(或 view),则可能会导致交互合约 revert。
Vyper 认为正确实现接口的合约可能无法反映接口的预期,并且交互合约最终可能会被锁定,因为它们希望能够将 ETH 发送到一个 non-payable 函数。
请注意,Solidity 有一个类似的检查,即在实现接口时,“较低”的可变性是可以接受的,但是对于 payable 函数有一个特定的例外,以避免这种风险。请参阅下表,了解异同的细分。
------------------------- Solidity ------------ Vyper view => nonpayable NO NO ✓ view => payable NO NO ✓ nonpayable => view/getter YES YES ✓ nonpayable => payable NO NO ✓ payable => view/getter NO YES <== 这是问题所在 payable => nonpayable NO YES <== 这是问题所在
人工审查
在 implements()
函数中,检查接口上的函数的可变性是否为 payable。如果是,则要求实现合约也使该函数为 payable。
由 obront 提交。
slice 的边界检查没有考虑到当 start 值不是字面量时,start + length
可能会溢出。这导致攻击者可以溢出边界检查,从而使用 slice()
内置函数访问(a)不相关的存储槽或(b)内存中的前一个字。
当调用 slice()
时,如果 start
和 length
值是字面量,则会有编译时边界检查,但如果它们是传递的值,则当然不会发生这种情况:
if not is_adhoc_slice:
if length_literal is not None:
if length_literal < 1:
raise ArgumentException("Length cannot be less than 1", length_expr)
if length_literal > arg_type.length:
raise ArgumentException(f"slice out of bounds for {arg_type}", length_expr)
if start_literal is not None:
if start_literal > arg_type.length:
raise ArgumentException(f"slice out of bounds for {arg_type}", start_expr)
if length_literal is not None and start_literal + length_literal > arg_type.length:
raise ArgumentException(f"slice out of bounds for {arg_type}", node)
在运行时,我们执行以下等效的检查,但运行时检查没有考虑到溢出:
["assert", ["le", ["add", start, length], src_len]], # 边界检查
如果被切片的 bytestring 位于内存或存储中,则存在相同的问题:
存储 slice()
函数直接从存储中将字节复制到内存中,并返回结果 slice 的内存 value。这意味着,如果用户能够输入 start
value,他们可以强制溢出并访问不相关的存储槽。在大多数情况下,这意味着他们有能力强制为 slice 返回 0
,即使这不应该发生。在极端情况下,这意味着他们可以从存储中返回另一个不相关的 value。
内存 slice()
函数返回结果 slice 的内存 value。作为过程的一部分,有一个检查 start + 32 < length
,这意味着为了使溢出成为可能,start
必须大于 max uint256 - 31
。因此,返回的 slice 可以是任何从被切片变量之前的最多 32 字节开始的 slice。
为简单起见,采用以下 Vyper 合约,它接受一个参数来确定在 Bytes[64]
bytestring 中应该在哪里切片。它应该只接受 value 0,并且应该在所有其他情况下 revert。
## @version ^0.3.9
x: public(Bytes[64])
secret: uint256
@external
def __init__():
self.x = empty(Bytes[64])
self.secret = 42
@external
def slice_it(start: uint256) -> Bytes[64]:
return slice(self.x, start, 64)
我们可以使用以下手动存储来演示该漏洞:
{"x": {"type": "bytes32", "slot": 0}, "secret": {"type": "uint256", "slot": 3618502788666131106986593281521497120414687020801267626233049500247285301248}}
如果我们运行以下测试,将 max - 63
作为 start
value 传递,我们将溢出边界检查,但访问 1 + (2**256 - 63) / 32
处的存储槽,这是在上面的存储布局中设置的:
function test__slice_error() public {
c = SuperContract(deployer.deploy_with_custom_storage("src/loose/", "slice_error", "slice_error_storage"));
bytes memory result = c.slice_it(115792089237316195423570985008687907853269984665640564039457584007913129639872); // max - 63
console.logBytes(result);
}
结果是我们从存储中返回 secret value:
Logs:
0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a
对于内存 slice,请参见以下合约:
## @version ^0.3.9
@external
def slice_it_mem(start: uint256) -> Bytes[32]:
x: uint256 = 2345908340958
y: Bytes[32] = b"\x05\x05"
return slice(y, start, 32)
如果我们将 max uint256 - 31
作为 start 传递,我们将返回 2
(bytestring 的长度)。如果我们将 max uint256 - 30
传递,我们将返回 205
(长度加上左对齐 bytestring 的第一个元素)。如果我们将 max uint256 - 29
传递,我们将返回 20505
,依此类推。
通过滥用边界检查溢出,内置的 slice()
方法可用于读取不相关的存储slot或内存位置。
手动审查,Foundry
更新边界检查,以包括检查 start + length > start
,以确保不会发生溢出。
由 obront 提交。
当对外部合约进行调用时,我们从字节 28 开始写入 calldata,并将返回缓冲区分配为从字节 0 开始(与 calldata 重叠)。在检查动态类型的 RETURNDATASIZE
时,该大小仅与该类型的最小允许大小进行比较,而不与返回值的 length
进行比较。因此,格式错误的返回数据可能导致合约将其自身的 calldata 误认为返回数据。
当为外部调用打包参数时,我们创建一个大小为 max(args, return_data) + 32
的缓冲区。calldata 放置在此缓冲区中(从字节 28 开始),并且返回缓冲区分配为从字节 0 开始。假设我们可以重复利用该内存,因为我们将无法读取超过 RETURNDATASIZE
的内容。
if fn_type.return_type is not None:
return_abi_t = calculate_type_for_external_return(fn_type.return_type).abi_type
# we use the same buffer for args and returndata,
# so allocate enough space here for the returndata too.
buflen = max(args_abi_t.size_bound(), return_abi_t.size_bound())
else:
buflen = args_abi_t.size_bound()
buflen += 32 # padding for the method id
当返回数据时,我们通过从字节 0 开始来解包返回数据。我们检查 RETURNDATASIZE
是否大于返回类型的最小允许值:
if not call_kwargs.skip_contract_check:
assertion = IRnode.from_list(
["assert", ["ge", "returndatasize", min_return_size]],
error_msg="returndatasize too small",
)
unpacker.append(assertion)
此检查确保返回的任何动态类型的大小至少为 64。但是,它不会验证 RETURNDATASIZE
是否与动态类型的 length
字一样大。
因此,如果合约期望返回动态类型,并且作为 length
读取的返回数据的部分包括大于实际 RETURNDATASIZE
的大小,则从缓冲区读取的返回数据将超出实际返回数据大小并从 calldata 读取。
此合约使用两个参数调用外部合约。进行调用时,缓冲区包括:
返回数据缓冲区从字节 0 开始,并将返回返回的 bytestring,最大长度为 96 字节。
interface Zero:
def sneaky(a: uint256, b: bytes32) -> Bytes[96]: view
@external
def test_sneaky(z: address) -> Bytes[96]:
return Zero(z).sneaky(0, keccak256("oops"))
另一方面,想象一个简单的合约,它实际上没有返回一个 bytestring,而是返回两个 uint256。为了方便与 Foundry 一起使用,我已在 Solidity 中实现了它:
function sneaky(uint a, bytes32 b) external pure returns (uint, uint) {
return (32, 32);
}
返回数据将被解析为一个 bytestring。前 32 个将指向字节 32 来读取length。第二个 32 将被视为 length。然后它将从返回数据缓冲区中读取接下来的 32 个字节,即使这些字节不是返回数据的一部分。
由于这些字节将来自字节 64,我们可以在上面看到 calldata 中将 hash 放置在此处。
如果我们运行以下 Foundry 测试,我们可以看到这实际上确实发生了:
function test__sneakyZeroReturn() public {
ZeroReturn z = new ZeroReturn();
c = SuperContract(deployer.deploy("src/loose/", "ret_overflow", ""));
console.logBytes(c.test_sneaky(address(z)));
}
Logs:
0xd54c03ccbc84dd6002c98c6df5a828e42272fc54b512ca20694392ca89c4d2c6
返回格式错误数据的恶意或错误的合约可能会导致超出返回的数据并从 calldata 缓冲区读取返回数据。
手动审查,Foundry
如果我们想继续使用同一个缓冲区作为 calldata 和返回数据,请为动态返回类型添加一个额外的安全检查,即根据将作为长度解包的字节检查 RETURNDATASIZE
。
或者,在内存中单独分配返回数据缓冲区。
由 Franfran 提交。
数组可以通过有符号整数进行索引,而它们仅为无符号整数定义。
让我们举一个简单的例子:
arr: public(uint256[MAX_UINT256])
@external
def set(idx: int256, num: uint256):
self.arr[idx] = num
这会编译,并且可以工作!
如果我们使用 vyper src/array.vy -f ir
生成 ir
,我们会得到这个:
[iszero, [xor, _calldata_method_id, 2720814400 <0xa22c5540: set(int256,uint256)>]],
[seq,
[assert, [iszero, [or, callvalue, [lt, calldatasize, 68]]]],
[seq,
[goto, external_set__int256_uint256__common],
# Line 4
[seq,
[label,
external_set__int256_uint256__common,
var_list,
[seq,
[seq,
[unique_symbol, sstore_2],
/* store the value at index */
[sstore,
[with,
clamp_arg,
/* load the array index */
[calldataload, 4 <idx (4+0)>],
/* make sure that the int is not 2**255, max is 2**255 - 1 */
[seq,
[assert,
[ne,
clamp_arg,
115792089237316195423570985008687907853269984665640564039457584007913129639935]],
clamp_arg]],
/* load the array value */
[calldataload, 36 <num (4+32)>]]],
[exit_to, external_set__int256_uint256__cleanup],
pass]],
[label, external_set__int256_uint256__cleanup, var_list, stop]]]]
编译时有一个警告 UserWarning: Use of large arrays can be unsafe!
,但请注意,对于任何长度小于 64 bits
的数组,都会触发此警告。出现此警告的原因是,任意长度的数组可能会提供写入已使用存储槽的机会。
我们可以编写一段不会触发此警告的代码,例如
arr: public(uint256[max_value(uint32)])
@external
def set(idx: int16, num: uint256):
self.arr[idx] = num
人们可能会在更定制的智能合约中假设,任何超出边界或至少小于 0 的数组访问都会 revert,但有符号整数也可以具有可能导致存储中某些冲突的无符号按位等效项。
例如,有符号整数表示法中的 0
可以用 0x000..000
或 0x800..000
(-0
) 表示。这两个索引将是不同的,因此允许通过有符号整数来索引数组似乎不是我们想要限制的内容。
这可能会导致对禁止的存储槽进行偷偷摸摸的访问。
手动审查
为 Subscriptable
节点添加类型检查,并确保类型匹配。
由 DarkTower 提交。
https://github.com/vyperlang/vyper/tree/v0.3.10rc3/vyper
编译器不正确的内置类型检查器导致负整数作为 uint2str 中的 value 传递。这可能会对 vyper 开发者造成严重的未被注意的问题。
正如 vyper 编译器文档所述:
uint2str(value: unsigned integer)→ String 返回无符号整数的字符串表示形式。
- value:要转换的无符号整数。
- 返回 value 的字符串表示形式。
下面提供了编译器无法 revert 的代码段示例:
@external
def testFoobar():
a: String[78] = uint2str(-12)
pass
编译后,返回:
0x61007761000f6000396100776000f36003361161000c57610062565b5f3560e01c346100665763f8a8fd6d811861006057600360c0527f2d3130000000000000000000000000000000000000000000000000000000000060e05260c0805160208201805160605250806040525050005b505b5f5ffd5b5f80fda165767970657283000309000b
误导开发者并产生意外的下溢。
手动审查
当将负整数传递给 uint2str
参数时,在 Vyper 语言编译器上添加检查应该可以解决此问题。
_由 obront, Bauchibred, DarkTower 提交。选择提交者:[obront](https://github.com/vyperlang/audits/blob/master/profile/clnxz4xd```markdown number: public(uint256) exampleList: constant(DynArray[uint256, 3]) = [1, 2, 3]
@external def init(): self.number = len(exampleList)
### 影响
包含字面量列表作为内置函数参数的合约将无法编译。
### 使用的工具
人工复核
### 建议
在 `validate_expected_type()` 中,调整检查以确保期望的类型与 `DArrayT` 或 `SArrayT` 匹配,而不是要求它是它的一个实例。
### <a id='L-03'></a>L-03. ContractFunctionT.from_abi 无法优雅地处理表示 `__default__` 和/或 `__init__` 函数的有效的 JSON ABI 接口
**提交者:** [0xZRA](https://github.com/vyperlang/audits/blob/master/profile/cllln8wzi000amj08ewcv68en).
#### 相关的 GitHub 链接
https://github.com/vyperlang/vyper/blob/v0.3.10rc3/vyper/semantics/types/function.py#L128
### 概要
ContractFunctionT.from_abi 无法处理通过有效的 JSON ABI 接口提供的,带有表示函数的对象中的 `__default__` 和/或 `__init__` 方法的代码。
### 漏洞详情
`__init__` 和 `__default__` 方法在它们的 ABI 中分别缺少 `name` 和 `inputs` 项(尽管有正当理由),这导致 `ContractFunctionT.from_abi` 方法无法生成 `ContractFunctionT` 对象。
复现步骤:
1 - 将示例 `.vy` 代码添加到独立文件中
2 - 通过运行 `vyper -f abi <path-to-the-file>` 生成 abi:
root@06f545b1d4b9:/workspaces/vyper# vyper -f abi tests/sample_code_from_abi.vy
[{"stateMutability": "nonpayable", "type": "constructor", "inputs": [], "outputs": []}, {"stateMutability": "nonpayable", "type": "fallback"}]
3 - 将 ABI 有效负载传递给 `ContractFunctionT.from_abi` 方法
4 - 确认断言在两种情况下都会因 KeyError 而失败
添加一个新的测试 `test_init_and_default_fail_to_create_from_abi.py`:
import pytest from vyper.semantics.types.function import ContractFunctionT
@pytest.mark.xfail(raises=KeyError) def test_init_and_default_fail_to_create_from_abi():
code = """
owner: address last_sender: address
@external def init(): self.owner = msg.sender
@external def default(): self.last_sender = msg.sender """ abi_payload = [{"stateMutability": "nonpayable", "type": "constructor", "inputs": [], "outputs": []}, {"stateMutability": "nonpayable", "type": "fallback"}]
init_fn_from_abi=abi_payload[0]
#Fails with KeyError: 'name'
init_fn_t = ContractFunctionT.from_abi(abi=init_fn_from_abi)
default_fn_from_abi=abi_payload[1]
#Fails with KeyError: 'inputs'
default_fn_t = ContractFunctionT.from_abi(abi=default_fn_from_abi)
### 影响
未处理的异常通常会导致调试挑战、不明确的行为和损坏的客户端代码。
### 使用的工具
pytest, 人工复核
### 建议
向 ContractFunctionT.from_abi 引入对这两个内置方法的缺失项的优雅处理
### <a id='L-04'></a>L-04. RawCall 中的无用内存分配错误
**提交者:** [KuroHashDit](https://github.com/vyperlang/audits/blob/master/profile/cln6wuqc6000ol808dd8imjox).
### 概要
RawCall 有一个分配无用内存的错误。
### 漏洞详情
raw_call 的原型:
raw_call(to: address, data: Bytes, max_outsize: uint256 = 0, gas: uint256 = gasLeft, value: uint256 = 0, is_delegate_call: bool = False, is_static_call: bool = False, revert_on_failure: bool = True)→ Bytes[max_outsize]
vyper/vyper/builtins/functions.py
def build_IR(self, expr, args, kwargs, context):
to, data = args
# TODO: must compile in source code order, left-to-right
gas, value, outsize, delegate_call, static_call, revert_on_failure = (
kwargs["gas"],
kwargs["value"],
kwargs["max_outsize"],
kwargs["is_delegate_call"],
kwargs["is_static_call"],
kwargs["revert_on_failure"],
)
........
output_node = IRnode.from_list(
context.new_internal_variable(BytesT(outsize)), typ=BytesT(outsize), location=MEMORY
)
在第 1143 行,当 out_size 为 0 时,将在此处分配类型为 BytesT(0) 的内存,大小为 32 字节,并且永远不会被使用。所以应该纠正这一点。
### 影响
低风险
### 使用的工具
### 建议
### <a id='L-05'></a>L-05. 断言代码生成期间编译器崩溃
**提交者:** [KuroHashDit](https://github.com/vyperlang/audits/blob/master/profile/cln6wuqc6000ol808dd8imjox).
### 概要
当 vyper 生成断言代码时,会出现崩溃错误。
### 漏洞详情
好的代码:
@external
def __init__():
pass
@external
def test():
x: uint256 = 1
s: String[100] = "error"
assert x == 1, s
这段代码运行良好。
错误的代码:
s: public(String[100])
@external
def __init__():
self.s = "error"
@external
def test():
x: uint256 = 1
assert x == 1, self.s
这段代码将导致编译器崩溃。
根本原因:
vyper/vyper/semantics/analysis/annotation.py
class StatementAnnotationVisitor(_AnnotationVisitorBase):
ignored_types = (vy_ast.Break, vy_ast.Continue, vy_ast.Pass, vy_ast.Raise)
def __init__(self, fn_node: vy_ast.FunctionDef, namespace: dict) -> None:
self.func = fn_node._metadata["type"]
self.namespace = namespace
self.expr_visitor = ExpressionAnnotationVisitor(self.func)
assert self.func.n_keyword_args == len(fn_node.args.defaults)
for kwarg in self.func.keyword_args:
self.expr_visitor.visit(kwarg.default_value, kwarg.typ)
def visit(self, node):
super().visit(node)
def visit_AnnAssign(self, node):
type_ = get_exact_type_from_node(node.target)
self.expr_visitor.visit(node.target, type_)
self.expr_visitor.visit(node.value, type_)
def visit_Assert(self, node):
self.expr_visitor.visit(node.test)
在 visit_Assert() 中,它没有访问 node.msg。然后在 /vyper/codegen/expr.py 中,Expr::parse_Attribute(self) 无法获取表达式的类型,然后整个编译器崩溃。
### 影响
低风险
### 建议
### <a id='L-06'></a>L-06. raise 代码生成期间编译器崩溃
**提交者:** [KuroHashDit](https://github.com/vyperlang/audits/blob/master/profile/cln6wuqc6000ol808dd8imjox).
### 概要
当 vyper 生成 raise 代码时,会出现崩溃错误。
### 漏洞详情
好的代码:
@external
def __init__():
pass
@external
def test():
x: uint256 = 1
s: String[100] = "error"
raise s
这段代码运行良好。
错误的代码:
s: public(String[100])
@external
def __init__():
self.s = "error"
@external
def test():
x: uint256 = 1
raise self.s
这段代码将导致编译器崩溃。
根本原因:
vyper/vyper/semantics/analysis/annotation.py
class StatementAnnotationVisitor(_AnnotationVisitorBase):
ignored_types = (vy_ast.Break, vy_ast.Continue, vy_ast.Pass, vy_ast.Raise)
def __init__(self, fn_node: vy_ast.FunctionDef, namespace: dict) -> None:
self.func = fn_node._metadata["type"]
self.namespace = namespace
self.expr_visitor = ExpressionAnnotationVisitor(self.func)
assert self.func.n_keyword_args == len(fn_node.args.defaults)
for kwarg in self.func.keyword_args:
self.expr_visitor.visit(kwarg.default_value, kwarg.typ)
def visit(self, node):
super().visit(node)
def visit_AnnAssign(self, node):
type_ = get_exact_type_from_node(node.target)
self.expr_visitor.visit(node.target, type_)
self.expr_visitor.visit(node.value, type_)
def visit_Assert(self, node):
self.expr_visitor.visit(node.test)
在 StatementAnnotationVisitor 类中,它没有 visit_Raise 方法。然后在 /vyper/codegen/expr.py 中,Expr::parse_Attribute(self) 无法获取表达式的类型,然后整个编译器崩溃。
### 影响
低风险
### 建议
### <a id='L-07'></a>L-07. vyper 可以接受来自 cli 的冲突优化选项
**提交者:** [cyberthirst](https://github.com/vyperlang/audits/blob/master/profile/cln69xxib000gjt08n37hic1g).
#### 相关的 GitHub 链接
https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/cli/vyper_compile.py#L174-L178
### 概要
编译器允许设置不同的优化级别:codesize 和 gas。这些选项相互排斥。但是,可以在提供这两个选项的情况下运行编译器。
### 漏洞详情
编译器可以这样运行:
vyper --optimize gas --optimize codesize test.vy
这些是冲突的选项,编译器不应该接受这样的配置 - 就像在以下情况下一样:
```python
if args.no_optimize and args.optimize:
raise ValueError("Cannot use `--no-optimize` and `--optimize` at the same time!")
最后,使用后一个选项(codesize),通过在调试器中停止编译器在以下行上可以很容易地验证: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/cli/vyper_compile.py#L174-L178
编译器允许互斥的选项,其中只使用 1 个。因此,编译器的执行不是完全可预测的。
一个没有意识到这些选项是互斥的用户启用了这两个选项。同时,他更喜欢他的合约是 gas
优化而不是 codesize
优化。由于不透明的配置,他的偏好没有得到满足。
人工复核,PyCharm 调试器。
使这些选项互斥,如果提供了这两个选项,则停止编译过程。
提交者: cyberthirst.
编译器崩溃,出现包含 sqrt
的有效输入程序,因为 vyper.exceptions.CompilerPanic: shadowed loop variable range_ix0
。
sqrt
函数的 IR 是通过 generate_inline_function
生成的,该函数使用新的命名空间和上下文。此外,该函数的实现包含一个 for loop
。
for
循环在主体中生成一个新的迭代器变量:range_ix0
,独立于先前的上下文。因此,如果在 for loop
中调用 sqrt
,则迭代器变量将发生名称冲突。
以下断言将不会通过,编译器将崩溃: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/ir/compile_ir.py#L434-L436
这是一个触发该错误的简单合约:
##@version ^0.3.9
@external
def my_little_test() -> decimal:
j: decimal = 0.0
for i in range(666):
j = sqrt(2.0)
return j
像这样的合约也无法编译:
@external
def my_little_test() -> decimal:
j: decimal = sqrt(sqrt(666.0))
return j
某些有效的程序无法编译。因此,开发人员被迫编写不同的(可能是不透明的)合约以避免该错误。
人工复核。
通过手动 IR
构造将 function
实现为其他内置函数。
提交者: cyberthirst.
当编译器验证不可变变量的修改时,由于结构属性中缺少 var_info 而崩溃。
对于不可变变量,会跟踪修改次数。如果超过 1,则会引发异常: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/semantics/analysis/base.py#L249-L253
跟踪是使用属性 var_info
完成的。在某些情况下,此属性缺失,编译器崩溃。
假设以下合约:
##@version ^0.3.9
struct B:
v1: int128
v2: decimal
struct A:
v: B
val: public(immutable(A))
@external
def __init__():
val = A({v: B({v1: 0, v2: 0.0})})
val.v.v1 += 666
编译时,编译器崩溃,出现:
AttributeError: 'NoneType' object has no attribute '_modification_count'
编译器没有正确处理所有合约的修改检查(以及可能的 var_info
赋值),这可能导致未定义的行为。但是,我们没有发现这样的情况。因此,影响主要是开发人员的困惑错误,这会减慢开发过程。
手动测试。
语义分析器很可能没有正确地用 var_info
注释所有相关的节点(或者太晚注释它们)。确保节点具有执行所有语义过程所需的必要信息。
提交者: cyberthirst.
编译器强制 block 有 1 个出口点。此不变量未在 for loop
中检查。
编译器检查函数主体和 if 语句是否具有 1 个出口点: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L1049-L1070
但是,正如我们在代码中看到的那样,For
节点未经过验证。因此,像下面这样的合约可以正常编译:
@external
def returning_all_nigt_long() -> uint256:
a: uint256 = 10
for i in range(10):
return 11
a = 20
return 12
return a
但是,像下面这样的合约无法编译:
@external
def i_have_so_many_exit_point_omg() -> uint256:
a: uint256 = 10
if a < 20:
return 0
a = 20
return 11111111111111
return 101019291
编译失败,出现:
vyper.exceptions.StructureException: Too too many exit statements (return, raise or selfdestruct).
contract "vyper_contracts/Test.vy:4", function "i_have_so_many_exit_point_omg", line 4:4
3 a: uint256 = 10
---> 4 if a < 20:
-----------^
5 return 0
单点退出是为 for 循环破坏的不变量。如果在编译的后期阶段依赖此不变量,这可能会有问题。但是,没有发现这种情况。因此,我们认为这是一个令人困惑的不一致。
人工复核。
扩展单个出口的验证以包含 for
循环。
提交者: cyberthirst.
即使对于有效的合约,实例化 asttokens.ASTTokens
类也会导致编译器崩溃。
假设以下程序:
##@version ^0.3.9
import test5 as T
b: public(uint256)
event Transfer:
random: indexed(uint256)
shi: uint256
@external
def transfer():
log Transfer(T(self).b(), 10)
return
编译它会导致以下错误:
IndexError: list index out of range
崩溃发生在执行以下行之后: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/ast/annotation.py#L272
有效的程序无法编译。
手动测试。
我们不知道崩溃的真正原因,因此无法提供建议。
提交者: obront.
https://github.com/vyperlang/vyper/blob/master/vyper/ast/folding.py
在常量折叠期间,对常量变量的引用被替换为其底层值。完成此操作后,常量变量本身将被删除。对于元组常量,第一步失败。这导致对不存在的变量的引用,这会在后面的 codegen 模块中破坏编译过程。
在折叠过程中的 replace_user_defined_constants()
函数中,我们遍历所有常量变量并迭代源代码中对该值的所有引用。
for node in vyper_module.get_descendants(vy_ast.Name, {"id": id_}, reverse=True):
...
对于每个实例,我们调用 _replace()
,它尝试创建一个具有旧节点值的新节点,但类型更改为常量的类型,并且值设置为常量的值。此调用包装在 try catch 块中,以便 UnfoldableNode
错误不会破坏编译,而是简单地保持在运行时返回。
try:
new_node = _replace(node, replacement_node, type_=type_)
except UnfoldableNode:
if raise_on_error:
raise
continue
如果我们查看 _replace()
函数,我们可以看到它处理值是 Constant、List 或 Call 的情况,但在所有其他情况下都返回 UnfoldableNote
。
将此与语义分析中的检查进行比较,我们可以看到在语义上我们允许元组作为常量,而在折叠过程中,由于错误,这将跳过元组的折叠:
def check_constant(node: vy_ast.VyperNode) -> bool:
"""
检查给定节点是否为字面量或常量值。
"""
if _check_literal(node):
return True
if isinstance(node, (vy_ast.Tuple, vy_ast.List)):
return all(check_constant(item) for item in node.elements)
if isinstance(node, vy_ast.Call):
args = node.args
if len(args) == 1 and isinstance(args[0], vy_ast.Dict):
return all(check_constant(v) for v in args[0].values)
call_type = get_exact_type_from_node(node.func)
if getattr(call_type, "_kwargable", False):
return True
return False
折叠完成后,remove_unused_statements()
函数会删除所有表示变量声明为常量的节点。这假设这些节点已在使用它们的地方就地替换,但不考虑已跳过元组的情况。
def remove_unused_statements(vyper_module: vy_ast.Module) -> None:
"""
删除类型检查后未使用的语句节点。
类型检查完成后,我们可以删除现在没有意义的语句以在 IR 生成之前简化 AST。
参数
---------
vyper_module : Module
顶层 Vyper AST 节点。
"""
for node in vyper_module.get_children(vy_ast.VariableDecl, {"is_constant": True}):
vyper_module.remove_from_body(node)
## `implements: interface` 语句 - 在类型检查期间验证
for node in vyper_module.get_children(vy_ast.ImplementsDecl):
vyper_module.remove_from_body(node)
结果是任何元组常量都不会在折叠期间就地替换,而是在折叠完成后删除其节点。这导致管道中进一步的错误,codegen 模块尝试 parse_Name
并发现相应的变量名称不存在。
## @version ^0.3.9
e: constant(uint256) = 24
f: constant((uint256, uint256)) = (e, e)
@external
def foo(x: uint256) -> uint256:
return f[0]
这会导致以下错误:
vyper.exceptions.TypeCheckFailure: Name node did not produce IR.
声明的元组常量将无法正确处理,而是会导致编译失败。
请注意,虽然我无法确定任何尽管错过了检查但仍能编译代码的方法,但是如果在不恢复编译的情况下可以在合约中使用这些元组值的任何极端情况下,问题可能会蔓延到已编译的代码中,这可能会产生更严重的影响。
手动审查
调整 _replace()
函数以正确处理元组,或者明确禁止将它们用作常量,以在语义分析中捕获这种情况。
calc_mem_gas()
中的舍入导致 Gas 成本估算不正确提交者: obront.
当内存扩展时,Vyper 使用 calc_mem_gas()
util 函数来估计扩展的成本。但是,此计算应向上舍入到最接近的字,而实现向下舍入到最接近的字。由于内存扩展的 gas 成本呈指数增长,因此随着内存大小变大,这会产生很大的偏差。
在生成 Vyper IR 时,我们估计所有外部函数的 gas 成本,其中包括对内存扩展成本的特定调整:
## adjust gas estimate to include cost of mem expansion
## frame_size of external function includes all private functions called
## (note: internal functions do not need to adjust gas estimate since
mem_expansion_cost = calc_mem_gas(func_t._ir_info.frame_info.mem_used) # type: ignore
ret.common_ir.add_gas_estimate += mem_expansion_cost # type: ignore
此 calc_mem_gas()
函数的实现如下:
def calc_mem_gas(memsize):
return (memsize // 32) * 3 + (memsize // 32) ** 2 // 512
正如我们在 EVM.codes 上看到的那样,计算应该是:
memory_size_word = (memory_byte_size + 31) / 32
memory_cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word)
虽然两个实现都使用相同的公式,但正确的实现使用 memory_size_word
作为已触摸的内存字的总数(即 memsize 向上舍入到最接近的字),而 Vyper 实现向下舍入到最接近的字。
Gas 估算将始终低估外部函数的内存扩展成本。
手动审查,EVM.codes
更改 calc_mem_gas()
函数以向上舍入,从而正确地反映 EVM 的行为:
def calc_mem_gas(memsize):
- return (memsize // 32) * 3 + (memsize // 32) ** 2 // 512
+ return (memsize + 31 // 32) * 3 + (memsize + 31 // 32) ** 2 // 512
提交者: obront.
当估计 gas 成本时,BALANCE 假定花费 700 gas。但是,BALANCE 的正确 gas 成本是 2600。
当估计 gas 成本时,我们对任何 BALANCE 调用使用 700 的成本:
"BALANCE": (0x31, 1, 1, 700),
但是,自从 EIP 2929 以来,BALANCE 读取的成本已增加到 2600。
查看 操作码 gas 成本,我们可以看到 BALANCE 定义如下:
gas_cost = 100 if target_addr in touched_addresses (warm access)
gas_cost = 2600 if target_addr not in touched_addresses (cold access)
由于 Vyper 默认为温暖地址或存储插槽的折扣情况采用更高的成本(参见:SSTORE、EXTCODESIZE),因此此操作的 gas 成本应默认为 2600。
由于 BALANCE 操作码的价格不正确,gas 价格将被低估。
手动审查,EVM.codes
调整 BALANCE 以反映 EIP 2929,正如你已经对 EXTCODESIZE 和 EXTCODEHASH 所做的那样:
- "BALANCE": (0x31, 1, 1, 700),
+ "BALANCE": (0x31, 1, 1, (700, 2600)),
提交者: obront.
当使用 bytes32 输入调用 SHA256 内置函数时,我们使用相同的暂存空间来保存输入并返回输出。如果链没有实现 SHA256 预编译(这是许多 ZK rollup 的要求),则此地址将是一个 EOA,因此调用将以静默方式失败,我们将从内存中返回输入值。
SHA256 内置函数是地址 (0x02) 处的预编译合约的包装器。如果使用 bytes32 参数调用它,我们将执行以下逻辑:
1) 将输入参数放置在 0 内存插槽中。 2) 使用 0-31 的内存插槽的输入调用预编译。 3) 断言调用成功。 4) 要求预编译将哈希值返回到 0-31 的内存插槽。 5) 从 0-31 的内存插槽中加载值以返回哈希值。
我们可以看到此处实现的此逻辑:
sub = args[0请注意,不实现 SHA256 预编译是 ZK rollup 的常见要求。ZKsync 和 Scroll 目前都没有实现预编译。幸运的是,两者目前都有错误会阻止此漏洞被利用,但未来仅仅跳过实现预编译的 rollup 将会受到攻击。
### 影响
对于所有 bytes32 输入,未实现 SHA256 预编译的 Rollup 将导致 SHA256 内置函数返回输入(而不是无数据)。
### 使用工具
人工审查
### 建议
由于存在调用成功但没有返回值的情况,请将数据返回到 `FREE_VAR_SPACE2`,以确保在没有返回数据的情况下返回 `0`。
### <a id='L-16'></a>L-16. Fang 优化选项已损坏
**提交者:** [obront](https://github.com/vyperlang/audits/blob/master/profile/clnxz4xdc000cl908cj3yirf0)。
#### 相关的 GitHub 链接
https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/cli/vyper_ir.py#L47-L51
https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/cli/vyper_ir.py#L35-L38
### 概要
Fang 允许用户指定他们希望将程序输出为 `ir`、`opt_ir`、`asm` 或 `bytecode`。然而,实际行为是 `ir` 将返回优化的 IR,而 `opt_ir` 不会返回任何内容。
### 漏洞详情
当用户调用 `fang ...` 时,该调用由 `cli/vyper_ir.py` 处理。传递的参数之一是要输出的格式列表。从帮助文档中:
```md
"格式以 csv 列表形式打印 ir,opt_ir,asm,bytecode"
但是,如果我们查看 compile_to_ir()
函数,我们可以看到,如果传递了 ir
,它会自动优化 IR 并将其保存为 compiler_data["ir"]
,而不是 compiler_data["opt_ir"]
。
compiler_data = {}
ir = IRnode.from_list(s_expressions[0])
ir = optimizer.optimize(ir)
if "ir" in output_formats:
compiler_data["ir"] = ir
此外,我们可以看到,如果 opt_ir
包含在格式列表中,则不会对其进行处理,也不会发生任何事情。没有任何方法可以在 compiler_data["opt_ir"]
中保存任何值。
稍后,当我们处理输出时,我们会迭代可能的输出类型:
for key in ("ir", "opt_ir", "asm", "bytecode"):
if key in compiler_data:
print(compiler_data[key])
由于 compiler_data
中永远不会有任何名为 opt_ir
的键,因此将跳过此选项。
当我们请求未优化的 IR 时,Fang 将生成优化的 IR。这可能会给使用 Fang 的底层开发人员带来问题,特别是当他们指定他们的 IR 应保持与他们编写的完全一致时。这可能会导致意外的行为,例如 gas 价格和 codesize 与预测的不完全相同。
当被要求时,它会跳过生成优化的 IR,这个问题不太严重。
人工审查
通过 Fang 将 IR 的生成拆分为两个选项:一个用于跳过优化步骤的 ir
,另一个用于使用当前实现的 opt_ir
。
_bytes_to_num()
跳过 ensure_in_memory()
检查,这可能导致编译失败提交者: obront。
转换中使用的 _bytes_to_num()
函数假定任何 bytestring 类型都在内存中。如果它们是从表达式内部声明的,它会尝试从内存中加载它们并崩溃。
将 bytestring 转换为数字时,我们执行以下操作:
if isinstance(arg.typ, _BytestringT):
_len = get_bytearray_length(arg)
arg = LOAD(bytes_data_ptr(arg))
num_zero_bits = ["mul", 8, ["sub", 32, _len]]
当空 bytestring 直接传递给转换时,get_bytearray_length()
函数可以正确处理这种情况。但是,如果参数没有指定 location
,bytes_data_ptr()
函数会崩溃:
if ptr.location is None:
raise CompilerPanic("tried to modify non-pointer type")
以下 Vyper 合约应编译:
@external
def get_empty_bytestring_as_uint() -> uint256:
return convert(empty(Bytes[32]), uint256)
但是,它返回以下内容:
Error compiling: examples/minimal.vy
vyper.exceptions.CompilerPanic: tried to modify non-pointer type
包含在表达式中声明空 bytestring 的转换的合约将无法编译。
人工审查
使用类似于编译器中其他地方使用的 ensure_in_memory()
函数将空的 bytestring 移动到内存中,然后再进行转换;或者为返回适当值的空字符串创建手动覆盖。
shift()
函数将失败提交者: obront。
内置的 shift()
函数接受 INT256
作为输入,这在运行时可以正常运行。但是,如果将负字面量传递给该函数,则会进行编译时检查,从而导致回退。
在 evaluate()
方法中(在编译时计算 shift()
时使用),有以下检查:
if value < 0 or value >= 2**256:
raise InvalidLiteral("Value out of range for uint256", node.args[0])
但是,该函数旨在接受 INT256
作为参数:
_inputs = [("x", (UINT256_T, INT256_T)), ("_shift_bits", IntegerT.any())]
这在 build_IR()
方法中可以正确处理,但在编译时调用 evaluate()
时会失败。
移位负字面量并尝试在编译时计算表达式的合约将无法编译。
人工审查
理想的选择是更新 evaluate()
方法以处理负整数。
或者,鉴于 shift()
函数已弃用,并且可能不需要额外的工作,最简单的解决方案是简单地为 type(int256).min
和 0
之间的值 raise UnfoldableNote
,这将跳过评估并保留该函数在运行时进行评估。
提交者: obront。
当编译器在 -f opcodes
或 -f opcodes_runtime
模式下运行时,它会将最终的字节码转换为操作码。但是,由于放置在 PUSH
值上的填充不正确,对于任何带有前导零的字节,返回值都将不正确。
当编译器以操作码为目标输出运行时,我们通过以下函数运行最终的字节码:
def _build_opcodes(bytecode: bytes) -> str:
bytecode_sequence = deque(bytecode)
opcode_map = dict((v[0], k) for k, v in opcodes.get_opcodes().items())
opcode_output = []
while bytecode_sequence:
op = bytecode_sequence.popleft()
opcode_output.append(opcode_map.get(op, f"VERBATIM_{hex(op)}"))
if "PUSH" in opcode_output[-1] and opcode_output[-1] != "PUSH0":
push_len = int(opcode_map[op][4:])
# we can have push_len > len(bytecode_sequence) when there is data
# (instead of code) at end of contract
# CMC 2023-07-13 maybe just strip known data segments?
push_len = min(push_len, len(bytecode_sequence))
push_values = [hex(bytecode_sequence.popleft())[2:] for i in range(push_len)]
opcode_output.append(f"0x{''.join(push_values).upper()}")
print(opcode_output)
return " ".join(opcode_output)
此函数迭代字节码中的每个指令,并将其转换为相应的操作码。对于 PUSH
指令,它会解析要包含的字节数(我们称之为 x
),然后假定以下 x
指令是传递给 PUSH
的值。
对于这两个字节块中的每一个,它都会使用 hex(bytecode_sequence.popleft())[2:]
解析字节,并将它们连接在一起。
问题在于,对于以 0
开头的两个字节(例如 0x05
),这只会将非零数字附加到序列中。结果是一个序列,其长度没有 PUSH 指令预期的长度长,因此会在前面(或后面,具体取决于类型)添加 0,以达到预期的长度。
考虑以下 Vyper 合约,其中有一个返回 0x350f872d
的 bytes4 值的函数:
@external
def f1() -> bytes4:
return 0x350f872d
由于返回值的第二个字节以 0 开头,因此转换将返回 0xf
而不是 0x0f
。
结果是编译器返回了以下不正确的操作码(请参见中间的 PUSH32
指令):
PUSH0 CALLDATALOAD PUSH1 0xE0 SHR PUSH4 0xC27FC35 DUP2 XOR PUSH2 0x03E JUMPI CALLVALUE PUSH2 0x042 JUMPI PUSH32 0x35F872D0000000000000000000000000000 PUSH1 0x40 MSTORE PUSH1 0x20 PUSH1 0x40 RETURN JUMPDEST PUSH0 PUSH0 REVERT JUMPDEST PUSH0 DUP1 REVERT
当以操作码模式运行,并且有任何包含前导零的字节的 PUSH 指令时,编译器将返回不正确的值。
人工审查
确保在将 push_values
值连接到 bytestring 之前将其填充为两位数。
提交者: obront。
保留关键词列表中包含的 ETH 单位的 denominations
列表与在单位之间进行转换时接受的单位列表不同。这导致一些不应保留的关键词被保留,而一些应保留的非保留关键词没有被保留。
单位的保留关键词列表如下:
"ether",
"wei",
"finney",
"szabo",
"shannon",
"lovelace",
"ada",
"babbage",
"gwei",
"kwei",
"mwei",
"twei",
"pwei",
在值之间进行转换时接受的单位列表为:
wei_denoms = {
("wei",): 1,
("femtoether", "kwei", "babbage"): 10**3,
("picoether", "mwei", "lovelace"): 10**6,
("nanoether", "gwei", "shannon"): 10**9,
("microether", "szabo"): 10**12,
("milliether", "finney"): 10**15,
("ether",): 10**18,
("kether", "grand"): 10**21,
}
比较两个列表:
ada, twei, pwei
milliether, microether, nanoether, picoether, femtoether, grand, kether
一些应保留的单位未被保留,而另一些不应保留的单位被保留了。
人工审查
对齐这两个列表,使保留关键词反映用于转换的单位。
提交者: Franfran。
https://github.com/vyperlang/vyper/issues/3141
Pure 函数允许发出日志。
虽然 Pure 函数在任何时候都应完全等效,但这是一个错误的假设,ChainSecurity 审查已发现,因为可以使用 blockhash
。
一个被遗忘的内置函数是 raw_log
,它通过 LOG<N>
操作码发出日志。
例如,此代码可以正常编译:
@external
@pure
def loggg(_topic: bytes32, _data: Bytes[100]):
raw_log([_topic], _data)
这是一个写入操作,而 Pure 函数应仅允许读取访问,因此破坏了对Pure 函数的假设。
例如,这可能会被 Pure 函数的实施者恶意使用。
应使用 STATICCALL
操作码调用它们,对于任何执行的操作(包括 CREATE
、CREATE2
、LOG0
、LOG1
、LOG2
、LOG3
、LOG4
、SSTORE
、SELFDESTRUCT
和值为非零值的 CALL
)都应引发异常,如 EIP-214 中所述(他们是否遗漏了 delegatecall?)。
在这种情况下,将使用 STATICCALL
,当要发出日志时,调用将回退,这可能会冻结合约。
人工审核
禁止 Pure 函数使用 raw_log
。
提交者: Franfran。
https://github.com/vyperlang/vyper/assets/51274081/3f619c79-88e0-4d15-9ace-7d9ba02d16bc
在编译时,除法对有符号和无符号整数使用相同的逻辑。这会导致一些正确性问题。
有符号和无符号数的编译时除法运算由 evm_div
定义
def evm_div(x, y):
if y == 0:
return 0
# NOTE: should be same as: round_towards_zero(Decimal(x)/Decimal(y))
sign = -1 if (x * y) < 0 else 1
return sign * (abs(x) // abs(y)) # adapted from py-evm
但是,根据以太坊黄皮书,应该存在一个边缘情况:
如你所见,DIV
和 SDIV
并非完全等效。当 $\mu[0] = -2^{255}$ 且 $\mu[1] = -1$ 时,存在一种特殊情况。
如果我们使用 Python 引擎评估表达式,这就是我们为此函数得到的结果:
>>> def evm_div(x, y):
... if y == 0:
... return 0
... # NOTE: should be same as: round_towards_zero(Decimal(x)/Decimal(y))
... sign = -1 if (x * y) < 0 else 1
... return sign * (abs(x) // abs(y)) # adapted from py-evm
...
>>> evm_div(-2**255, -1)
57896044618658097711785492504343953926634992332820282019728792003956564819968
>>> assert evm_div(-2**255, -1) == 2**255
结果是 2**255
,而应该为 -2**255
。
以下是一些示例,说明如何利用此漏洞:
@external
def div_bug() -> int256:
return -2**255 / -1
无法运行,被 Type Checker 捕获:
vyper.exceptions.InvalidType: Expected int256 but literal can only be cast as uint256.
contract "src/div.vy:3", function "div_bug", line 3:11
2 def div_bug() -> int256:
---> 3 return -2**255 / -1
------------------^
4
虽然应该编译。
但是,我们可以通过这种方式使其编译,虽然应该回退,因为 as_wei_value
不支持负值。
@external
def div_bug() -> uint256:
return as_wei_value(-2**255 / -1, "wei")
这会编译,而值应该评估为负值,并返回 0x8000000000000000000000000000000000000000000000000000000000000000
。
另一个例子:
@external
def div_bug() -> uint256:
return max(-2**255 / -1, 0)
返回值是0x8000000000000000000000000000000000000000000000000000000000000000
因为 max
在编译时使用 -2**255 / -1
的错误计算进行评估。预期结果应为 0
。
@external
def div_bug() -> int256:
return min(-2**255 / -1, 0)
返回 0
其他可以编译的东西:
@external
def div_bug() -> String[100]:
return uint2str(-2**255 / -1)
@external
def div_bug() -> uint256:
return uint256_addmod(-2**255 / -1, -2**255 / -1, -2**255 / -1)
@external
def div_bug() -> uint256:
return uint256_mulmod(-2**255 / -1, -2**255 / -1, -2**255 / -1)
@external
def div_bug() -> uint256:
return pow_mod256(-2**255 / -1, -2**255 / -1)
人工审核
def evm_div(x, y):
if y == 0:
return 0
elif x == -2**255 and y == -1:
return -2**255
sign = -1 if (x / y) < 0 else 1
return sign * abs(x // y)
(最好创建一个 evm_sdiv
以确保它将来不会引起任何问题)
- 原文链接: github.com/vyperlang/aud...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!