Vyper - 编译器 - 调查结果报告

  • vyperlang
  • 发布于 2024-02-28 14:38
  • 阅读 8

这份报告总结了Vyper编译器代码审计的结果,发现了2个高风险、7个中风险和22个低风险的问题。

Vyper - 编译器 - 发现报告

目录

<a id='contest-summary'></a>竞赛总结

赞助商:Vyper

日期:2023 年 9 月 14 日 - 2023 年 11 月 4 日

在此处查看更多竞赛详情

<a id='results-summary'></a>结果总结

发现数量:

  • 高危:2
  • 中危:7
  • 低危:22

高风险发现

<a id='H-01'></a>H-01. slice() 中的整数溢出

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` 和 `&lt;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。

影响

中风险

建议

在此处修复整数溢出

<a id='H-02'></a>H-02. concat 内置函数可能破坏内存

cyberthirst, KuroHashDit 提交。选定的提交者:cyberthirst

相关 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/builtins/functions.py#L534-L550

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/builtins/functions.py#L569-L572

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L270-L273

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L245-L247

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L301-L320

摘要

concat 内置函数可能会写入为其分配的内存缓冲区的边界之外,从而覆盖现有的有效数据。至少对于 v0.3.10rc3* 而言,根本原因是 concatbuild_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 定义为:

  • 数据指针 - 跳过保存长度的 1 个字
  • 偏移量 - 跳过已写入缓冲区的源参数
    • 偏移量通过以下方式增加:["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 将导致缓冲区溢出,因为它本质上会将 mstoredst_data 的源的 mload(mload 将加载整个字,并且 dst_data 到字边界的距离 < 32B)。对 copy_bytes 中的第二个路径的论证是类似的。

PoC

发现的主要攻击向量是当 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 循环的第二次迭代期间,将执行 mloaddst+1(因为 len('a') == 1),因此该函数会将 1B 写入缓冲区的边界之外。 字符串“b”的存储方式使得该字的右侧字节是一个零字节。 因此,零字节将被写入边界之外。 因此,当考虑 -1 时,它的最左侧字节将被覆盖为全 0。 因此,可以看出:452312848583266388373324160190187140051835877600158453279131187530910662655 == (2**248-1) 将输出 True

IR 分析

如果我们查看合约的 IR (vyper --no-optimize -f it),我们会看到:

## 第 30 行
                          /* a: int256 = -1 */ [mstore, 320, -1 &lt;-1>],

对于 concat 中循环的第二次迭代:

 len,
                        [mload, arg],
                        [seq,
                          [with,
                            src,
                            [add, arg, 32],
                            [with,
                              dst,
                              [add, [add, 256 &lt;concat destination>, 32], concat_ofst],
                              [mstore, dst, [mload, src]]]],
                          [set, concat_ofst, [add, concat_ofst, len]]]]],
                    [mstore, 256 &lt;concat destination>, concat_ofst],
                    256 &lt;concat destination>]],

因此 int 的地址是 320。

dst 定义为:[add, [add, 256 &lt;concat destination>, 32], concat_ofst],。 在第二次迭代中,concat_ofst 将为 1,因为 len('a)==1,因此 256+32+1 = 289。 现在,这个地址将被 mstored 到 - 因此,最后 mstored 的 B 的地址将为 289+32=321,这显然与 int a 的地址重叠。

第 2 条路径和 __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),也不会导致缓冲区溢出。

中风险发现

<a id='M-01'></a>M-01. 由于 vyper_compile.py 的 compile_files 函数定义发生更改,vyper-serve 无法编译字节码

cryptonoob 提交。

相关 GitHub 链接

https://github.com/vyperlang/vyper/blob/0b740280c1e3c5528a20d47b29831948ddcc6d83/vyper/cli/vyper_compile.py#L279-L287

https://github.com/vyperlang/vyper/blob/0b740280c1e3c5528a20d47b29831948ddcc6d83/vyper/cli/vyper_serve.py#L85-L97

摘要

在 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),   # &lt;&lt;== 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 版本中)

<a id='M-02'></a>M-02. compile_ir.py 中的 SHA3_64 漏洞

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 不为 0is_delegate_callis_static_call 为真,则在 builtins/functions.py 中的 RawCall.build_ir 中抛出一个异常。

<a id='M-04'></a>M-04. 合约接口允许 payable 函数的 nonpayable 实现

obront 提交。

相关 GitHub 链接

https://github.com/vyperlang/vyper/blob/3ba14124602b673d45b86bae7ff90a01d782acb5/vyper/semantics/types/user.py#L316-L331

概要

当实现一个合约接口时,编译器会检查接口中的每个函数在合约中是否都有一个对应的公开函数。然而,它不检查这些函数是否具有相同的可见性,这可能会导致危险的情况。

漏洞详情

当对实现接口的合约执行语义分析时,编译器会调用 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。

<a id='M-05'></a>M-05. Slice 的边界检查可能会溢出以访问不相关的数据

obront 提交。

相关 GitHub 链接

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L404-L457

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L319-L331

概要

slice 的边界检查没有考虑到当 start 值不是字面量时,start + length 可能会溢出。这导致攻击者可以溢出边界检查,从而使用 slice() 内置函数访问(a)不相关的存储槽或(b)内存中的前一个字。

漏洞详情

当调用 slice() 时,如果 startlength 值是字面量,则会有编译时边界检查,但如果它们是传递的值,则当然不会发生这种情况:

if not is_adhoc_slice:
    if length_literal is not None:
        if length_literal &lt; 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 &lt; length,这意味着为了使溢出成为可能,start 必须大于 max uint256 - 31。因此,返回的 slice 可以是任何从被切片变量之前的最多 32 字节开始的 slice。

PoC

为简单起见,采用以下 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,以确保不会发生溢出。

<a id='M-06'></a>M-06. 外部调用可能会将返回数据溢出到返回输入缓冲区

obront 提交。

相关 GitHub 链接

https://github.com/vyperlang/vyper/blob/9ce56e7d8b0196a5d51d706a8d2376b98d3e8ad7/vyper/codegen/external_call.py#L33-L142

概要

当对外部合约进行调用时,我们从字节 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 读取。

PoC

此合约使用两个参数调用外部合约。进行调用时,缓冲区包括:

  • 字节 28:method_id
  • 字节 32:第一个参数 (0)
  • 字节 64:第二个参数 (hash)

返回数据缓冲区从字节 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

或者,在内存中单独分配返回数据缓冲区。

<a id='M-07'></a>M-07. 数组有符号整数访问

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 &lt;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 &lt;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 &lt;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..0000x800..000 (-0) 表示。这两个索引将是不同的,因此允许通过有符号整数来索引数组似乎不是我们想要限制的内容。

影响

这可能会导致对禁止的存储槽进行偷偷摸摸的访问。

使用工具

手动审查

建议

Subscriptable 节点添加类型检查,并确保类型匹配。

低风险发现

<a id='L-01'></a>L-01. [M-01] 如果将负整数作为 uint 数据类型传递,编译器将无法 revert。

DarkTower 提交。

相关 GitHub 链接

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 语言编译器上添加检查应该可以解决此问题。

<a id='L-02'></a>L-02. 访问字面量列表的内置函数无法编译

_由 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` 匹配,而不是要求它是它的一个实例。

### &lt;a id='L-03'>&lt;/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 &lt;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():

content of tests/sample_code_from_abi.vy

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 引入对这两个内置方法的缺失项的优雅处理

### &lt;a id='L-04'>&lt;/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 字节,并且永远不会被使用。所以应该纠正这一点。

### 影响

低风险

### 使用的工具

### 建议
### &lt;a id='L-05'>&lt;/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) 无法获取表达式的类型,然后整个编译器崩溃。

### 影响
低风险

### 建议
### &lt;a id='L-06'>&lt;/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) 无法获取表达式的类型,然后整个编译器崩溃。

### 影响
低风险

### 建议
### &lt;a id='L-07'>&lt;/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 调试器。

建议

使这些选项互斥,如果提供了这两个选项,则停止编译过程。

<a id='L-08'></a>L-08. 由于 shadowing 迭代器变量导致崩溃

提交者: cyberthirst.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/ir/compile_ir.py#L434-L436

概要

编译器崩溃,出现包含 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

PoC

这是一个触发该错误的简单合约:

##@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 实现为其他内置函数。

<a id='L-09'></a>L-09. 由于结构属性中缺少 var_info 导致的崩溃

提交者: cyberthirst.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/semantics/analysis/base.py#L249-L253

概要

当编译器验证不可变变量的修改时,由于结构属性中缺少 var_info 而崩溃。

漏洞详情

对于不可变变量,会跟踪修改次数。如果超过 1,则会引发异常: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/semantics/analysis/base.py#L249-L253

跟踪是使用属性 var_info 完成的。在某些情况下,此属性缺失,编译器崩溃。

PoC

假设以下合约:

##@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 注释所有相关的节点(或者太晚注释它们)。确保节点具有执行所有语义过程所需的必要信息。

<a id='L-10'></a>L-10. for 循环的单点退出检查

提交者: cyberthirst.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L1049-L1070

概要

编译器强制 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 &lt; 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 &lt; 20:
  -----------^
       5         return 0

影响

单点退出是为 for 循环破坏的不变量。如果在编译的后期阶段依赖此不变量,这可能会有问题。但是,没有发现这种情况。因此,我们认为这是一个令人困惑的不一致。

使用的工具

人工复核。

建议

扩展单个出口的验证以包含 for 循环。

<a id='L-11'></a>L-11. 由于 ASTTokens 实例化导致的编译器崩溃

提交者: cyberthirst.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/ast/annotation.py#L272

概要

即使对于有效的合约,实例化 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

影响

有效的程序无法编译。

使用的工具

手动测试。

建议

我们不知道崩溃的真正原因,因此无法提供建议。

<a id='L-12'></a>L-12. 元组常量在折叠期间被删除,破坏了编译

提交者: obront.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/master/vyper/ast/folding.py

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/ast/expansion.py#L97-L113

概要

在常量折叠期间,对常量变量的引用被替换为其底层值。完成此操作后,常量变量本身将被删除。对于元组常量,第一步失败。这导致对不存在的变量的引用,这会在后面的 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() 函数以正确处理元组,或者明确禁止将它们用作常量,以在语义分析中捕获这种情况。

<a id='L-13'></a>L-13. 由于 calc_mem_gas() 中的舍入导致 Gas 成本估算不正确

提交者: obront.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/utils.py#L191-L193

概要

当内存扩展时,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

<a id='L-14'></a>L-14. BALANCE 操作码的 Gas 估算不正确

提交者: obront.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/evm/opcodes.py#L55

概要

当估计 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)),

<a id='L-15'></a>L-15. SHA256 内置函数将在没有 SHA256 预编译的链上返回输入值

提交者: obront.

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L674-L689

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L629-L641

概要

当使用 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`。

### &lt;a id='L-16'>&lt;/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

<a id='L-17'></a>L-17. _bytes_to_num() 跳过 ensure_in_memory() 检查,这可能导致编译失败

提交者: obront

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/_convert.py#L76-L85

概要

转换中使用的 _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() 函数可以正确处理这种情况。但是,如果参数没有指定 locationbytes_data_ptr() 函数会崩溃:

if ptr.location is None:
    raise CompilerPanic("tried to modify non-pointer type")

PoC

以下 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 移动到内存中,然后再进行转换;或者为返回适当值的空字符串创建手动覆盖。

<a id='L-18'></a>L-18. 如果在编译时传递负整数,则内置的 shift() 函数将失败

提交者: obront

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L1451-L1466

概要

内置的 shift() 函数接受 INT256 作为输入,这在运行时可以正常运行。但是,如果将负字面量传递给该函数,则会进行编译时检查,从而导致回退。

漏洞详情

evaluate() 方法中(在编译时计算 shift() 时使用),有以下检查:

if value &lt; 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).min0 之间的值 raise UnfoldableNote,这将跳过评估并保留该函数在运行时进行评估。

<a id='L-19'></a>L-19. 由于不正确的填充,编译后的操作码将为 PUSH 指令返回错误的值

提交者: obront

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/52dc413c684532d5c4d6cdd91e3b058957cfcba0/vyper/compiler/output.py#L294-L312

概要

当编译器在 -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,以达到预期的长度。

PoC

考虑以下 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 之前将其填充为两位数。

<a id='L-20'></a>L-20. 错误的单位包含在保留关键词中

提交者: obront

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/semantics/namespace.py#L207-L220

概要

保留关键词列表中包含的 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

影响

一些应保留的单位未被保留,而另一些不应保留的单位被保留了。

使用工具

人工审查

建议

对齐这两个列表,使保留关键词反映用于转换的单位。

<a id='L-21'></a>L-21. Pure 函数可以发出日志

提交者: Franfran

相关的 GitHub 链接

https://github.com/vyperlang/vyper/issues/3141

概要

Pure 函数允许发出日志。

漏洞详情

虽然 Pure 函数在任何时候都应完全等效,但这是一个错误的假设,ChainSecurity 审查已发现,因为可以使用 blockhash。 一个被遗忘的内置函数是 raw_log,它通过 LOG&lt;N> 操作码发出日志。 例如,此代码可以正常编译:

@external
@pure
def loggg(_topic: bytes32, _data: Bytes[100]):
    raw_log([_topic], _data)

这是一个写入操作,而 Pure 函数应仅允许读取访问,因此破坏了对Pure 函数的假设。

影响

例如,这可能会被 Pure 函数的实施者恶意使用。 应使用 STATICCALL 操作码调用它们,对于任何执行的操作(包括 CREATECREATE2LOG0LOG1LOG2LOG3LOG4SSTORESELFDESTRUCT 和值为非零值的 CALL)都应引发异常,如 EIP-214 中所述(他们是否遗漏了 delegatecall?)。 在这种情况下,将使用 STATICCALL,当要发出日志时,调用将回退,这可能会冻结合约。

使用工具

人工审核

建议

禁止 Pure 函数使用 raw_log

<a id='L-22'></a>L-22. Signed Integer 边缘情况的编译时除法

提交者: Franfran

相关的 GitHub 链接

https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/ir/optimizer.py#L54-L55

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) &lt; 0 else 1
    return sign * (abs(x) // abs(y))  # adapted from py-evm

但是,根据以太坊黄皮书,应该存在一个边缘情况: image

如你所见,DIVSDIV 并非完全等效。当 $\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) &lt; 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) &lt; 0 else 1
    return sign * abs(x // y)

(最好创建一个 evm_sdiv 以确保它将来不会引起任何问题)

  • 原文链接: github.com/vyperlang/aud...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
vyperlang
vyperlang
江湖只有他的大名,没有他的介绍。