微妙的 Vyper:理解 Vyper 的特性与陷阱

本文由 Vyper 的首席安全工程师编写,总结了 Vyper 语言中 21 个可能导致安全问题或行为不直观的特性,提醒开发者注意这些“陷阱”,以编写更安全、可读性更强的智能合约。文章涵盖了求值顺序、模块初始化、动态数组内存分配、传值调用、Flags语义等多个方面,并给出了具体的代码示例和解释。

Vyper 的主要目标之一是尽可能地安全和可读。然而,由于历史原因或实现细节,该语言中存在一些边缘情况(“陷阱”),其行为可能对用户来说并不直观。本文将介绍这些特性,作为一种预防措施,以帮助用户实现编写安全和可读合约的目标。此外,有些项目仅强调 Vyper 与 Solidity 或 Python 的差异,并且仅在从这些语言切换时才可能令人惊讶。

我是 Vyper 的首席安全工程师,此列表是根据我认为可能存在问题的而编制的。该列表基于我对 Vyper 编译器进行长期审查,以及我们的审计师和用户经常提出的问题。

本文是为 0.4.1 版本编写的,但通常也适用于其他版本。列表中的大多数项目都与链接相关联,其中包含更详细的信息和源代码示例,以帮助理解。

1. 求值顺序

求值顺序是指语句中表达式的求值顺序。Vyper 应该从左到右求值表达式。然而,在某些情况下并非如此。首先,从 0.4.0 版本开始,内置函数(raw_callslice 等)的求值顺序是未定义的。此外,这个问题甚至扩展到运算符(完整列表,请参阅 此安全公告)。

如果一个参数产生另一个参数使用的副作用,则意外的求值顺序可能会成为问题。例如,在这里,foo 返回 13 而不是 1,如果求值顺序是从左到右,则会是这种情况。

state: uint256

def side_effect() -> uint256:
    self.state = 12
    return 1

@external
def foo() -> uint256:
    return unsafe_add(self.state, self.side_effect()) # 返回 13 而不是 1

注意

这些求值顺序问题是 Vyper 的已知缺点,如果没有引入性能回归,则无法轻松修复。我们正在积极努力解决这个问题。

2. init 函数的线性化

0.4.0 版本以来,Vyper 提供了模块。有状态模块必须初始化。但是,编译器不强制初始化函数按依赖顺序调用。

考虑以下示例并假设 lib1 依赖于 lib2

import lib1
import lib2

initializes: lib1[lib2 := lib2]
initializes: lib2

@deploy
def __init__():
    lib1.__init__()  # lib2.__init__() 尚未被调用!
    lib2.__init__()

通过使用 initializes: lib1[lib2 := lib2],我们声明 lib2lib1 的状态依赖项。但是,在此示例中,我们首先调用 lib1__init__ 函数,因此,如果 lib1__init__ 依赖于 lib2,这可能会导致意外行为。

注意

这是一个经过深思熟虑的设计决策,但我们正在讨论引入线性化顺序检查。

3. 由于 DynArrays 的内存分配导致的 DoS

Vyper 静态分配内存(有关 Vyper 中内存分配的深入解释,请参见 本文);这包括 DynArray 类型。它为 DynArray 分配内存,以适应其最大可能大小(也称为边界)。

在以下示例中,将分配 352 字节(一个长度插槽 + 10 个值插槽):

def foo():
    array: DynArray[uint256, 10] = [1, 2]
    a: uint256 = 1

当我们接触变量 a(分配在比 array 更高的地址上)时,内存将被扩展,就像 array 已满一样,并将触发内存扩展成本。如果 DynArray 是通过 calldata 提供的,这尤其令人惊讶,因为在这种情况下,Vyper 会立即将其复制到内存中。

因此,如果为 DynArray 指定了较高的边界,则很容易导致 out-of-gas 异常。

警告

相同的规则适用于 StringsBytes。我们重点介绍了 DynArrays 上的行为,因为我们预计分配策略可能在那里是最有问题的。

4. 调用约定是按值传递

在内部函数调用期间,所有参数都按 1:1 复制并传递给被调用者。对被调用者中参数的任何修改都不会传播到调用者,即不使用引用,尽管 Vyper 文档有一个关于 引用类型 的章节。

在调用 foo 期间,断言通过:

def bar(array: DynArray[uint256, 10]):
    array = []

@external
def foo():
    array: DynArray[uint256, 10] = [1, 2]
    self.bar(array)
    assert len(array) == 2

5. 赋值是复制

即使对于文档称为 引用类型 的类型,Vyper 中的赋值运算符始终执行复制。该原则与按值传递的调用约定相同。

6. Immutables 和 init 函数可能未初始化/调用

Vyper 仅强制对 immutable 变量进行语法赋值[1] 以及对 __init__ 函数进行语法调用。如果此赋值/调用出现在 if 语句中,则可能不会发生该赋值/调用。在以下示例中,immutable 未赋值,并且模块未初始化:

i: immutable(uint256)

import lib1
initializes: lib1

@deploy
def __init__():
    if False:
        lib1.__init__()
        i = 42

注意

这是一个经过深思熟虑的设计决策,强制赋值/调用会限制构造函数的灵活性。

7. // 的舍入行为

整数除法对于负数的舍入语义与 Python 不同:Vyper 向零舍入,而 Python 向负无穷大舍入。例如,-1 // 2 在 Python 中将返回 -1,但在 Vyper 中将返回 0(在 Solidity 中也是 0)。

8. default_return_value 在调用完成后进行评估

如果被调用合约不返回任何内容,Vyper 允许为外部调用指定 default_return_value。此默认值是函数调用的一个参数,但令人惊讶的是,它是在调用完成后延迟评估的(如果不需要,则不会进行评估)。

在以下示例中,对 foo 的调用通过断言,因为默认值永远不会被评估:

interface Foo:
    def test() -> uint256: nonpayable
external_call_counter: uint256

@external
def test() -> uint256:
    return 0

@external
def foo():
    res: uint256 = extcall Foo(self).test(
        default_return_value = self.get_default_id()
    )
    assert self.external_call_counter == 0

def get_default_id() -> uint256:
    counter: uint256 = self.external_call_counter
    self.external_call_counter += 1
    return counter

注意

有关此行为的基本原理,请参见 此链接

9. 内部函数和 @payable / @nonpayable 装饰器

外部函数上的 @payable 装饰器表示,如果以它为目标的 message call 具有附加值,则该函数不会 revert 。如果附加了非零值,则 Nonpayable 函数将 revert。

内部函数也可以用这些装饰器标记,但语义不同。如果一个内部函数是 @payable,则该函数可以读取 msg.value,但 @nonpayable 不能。一个 @nonpayable 内部函数可以从 @payable 外部函数调用,但它不能读取 msg.value。重要的是,内部函数在存在非零值的情况下不会 revert (即使是 nonpayable)。

10. 非重入内部函数

即使内部函数也可以用 @nonreenrant 装饰器标记。然而,从已经持有锁的另一个函数调用内部 @nonreentrant 函数将导致 revert 。发生这种情况是因为第二个函数尝试获取当前执行路径中已持有的锁,即使没有发生外部重入调用。

以下示例表明,如果我们调用 foo,则调用将 revert,因为 bar 使用与 foo 已经锁定的相同的锁:

@nonreentrant
def bar():
    pass

@external
@nonreentrant
def foo():
    self.bar()

注意

我们正在讨论删除 internal 函数的装饰器。删除已在 pull request 中实现。

11. 无 private 可见性

任何导入 Vyper 模块的模块都可以访问和修改 Vyper 模块中的所有状态变量。

如果导入者修改了导入模块的 invariant 所依赖的某些重要状态变量,这可能尤其危险。

在这里,导入者 main 修改了 lib1 的存储。

lib1.vy

i: uint256

@deploy
def __init__():
    self.i = 42

main.vy

import lib1

initializes: lib1

@deploy
def __init__():
    lib1.__init__()
    lib1.i = 666

注意

这是一个经过深思熟虑的设计决策,旨在提高库之间的互操作性。然而,目前正在 讨论 添加某种形式的封装。

12. 默认参数由被调用者插入,而不是由调用者插入

Vyper 中的函数支持默认参数。如果一个外部函数具有默认参数,则编译器会为每个默认参数生成 overload 函数(每个 overload 支持一个额外的默认值)。因此,默认值是通过添加 overloads 来实现的,而不是通过在调用站点填充参数来实现的。

以下示例显示了实际调用的 x 中的哪些外部函数。

假设此接口 IFoo.vyi

@external
def withdraw(
    assets: uint256,
    receiver: address = msg.sender,
    owner: address = msg.sender,
) -> uint256:
    ...

main.vy

import IFoo

@external
def foo(x: IFoo):
    extcall x.withdraw(0, self, self)   # keccak256("withdraw(uint256,address,address)")[:4] = 0xb460af94
    extcall x.withdraw(0)               # keccak256("withdraw(uint256)")[:4] = 0x2e1a7d4d

main.vy 中的注释高亮显示了 x 中实际调用的函数。

注意

Vyper 编译器提供了标志 -f method_identifiers,该标志公开了合约的所有方法标识符,并且可能有助于检查默认值的行为方式。

13. 函数必须显式导出

Vyper 自动公开编译目标的接口[2]。必须使用 exports 指令显式导出来自导入的外部函数(和公共 getter),以使它们公开。以下合约不公开来自 lib1foo,即该函数不是运行时代码的一部分,并且无法调用。

lib1.vy

i: uint256

@external
def foo():
    pass

@deploy
def __init__():
    self.i = 42

main.vy

import lib1

initializes: lib1

@deploy
def __init__():
    lib1.__init__()

14. send 函数行为

Vyper 具有内置的 send,用于传输 Ether,它不会在调用中转发任何 gas,而是依赖于 EVM[3] 在进行 Ether 传输时提供的 gas 补贴(2300 gas)。仅当传输的值为非空时才提供 gas 补贴。这意味着如果使用 value=0 调用 send,则由于缺少 gas,该调用将 revert 。此行为与 Solidity 不同,后者检查附加值是否为 0,如果是,则在调用中转发 2300 gas。

15. 可以通过 --venom 启用 Venom codegen

Venom 是 Vyper 的新的实验性且非常高效的后端。可以使用 --venom 标志轻松启用它,这可能会给人一种错误的安全感。该后端尚未经过审计,建议谨慎使用。

16. 可以初始化无状态模块

__init__ 函数应初始化模块的状态。Vyper 要求初始化导入者使用的所有有状态模块。然而,即使是无状态模块也可以初始化。这可能会造成一种令人困惑的情况,即开发人员认为他向导入的模块添加了一个状态,但实际上他忘记了这样做。

注意

Github 上有关于此问题的公开 讨论

17. Vyper 的标志与 Solidity 的枚举

Vyper 提供了一个用户定义的 Flag 类型,类似于 Solidity 中的 enums,但存在重要的差异。在 Solidity 中,枚举的选项表示为从 0 开始并递增 1 的无符号整数。成员的最大数量为 256。它们支持比较,但不能应用算术和按位运算符。

Vyper 中的标志也最多有 256 个成员。这些成员由 uint256 值表示,其形式为 2^n,其中 n 是成员在范围 0 <= n <= 255 中的索引。它们支持与 innot in 运算符的其他包含进行比较。此外,它们支持按位运算符。由于不同的 2^n 幂表示成员,我们可以轻松地对它们进行按位运算,例如,对于标志 Roles,(Roles.MANAGER | Roles.USER) 将组合这两个角色。

18. 标志的语义

项目 16 介绍了 Flag 类型以及它与 Solidity 枚举的关系。我们将在本项目中探讨 Flag 类型的一些复杂性。

假设我们有以下标志类型,并且 deployer 接收 MANAGER 和 ADMIN 角色:

flag Roles:
    MANAGER # 0b001
    ADMIN   # 0b010
    USER    # 0b100

roles: HashMap[address, Roles]

@deploy
def __init__():
    self.roles[msg.sender] = (Roles.MANAGER | Roles.ADMIN)

现在,让我们尝试编写一个函数来检查 msg.sender 是否具有 MANAGER 或 ADMIN 角色(部署者满足此条件)。

def only_admin_or_manager():
    assert (
        self.roles[msg.sender] == Roles.ADMIN
        or self.roles[msg.sender] == Roles.MANAGER
    )

我们的部署者不会通过此检查,因为他的角色包含 MANAGER 和 ADMIN 角色;因此,在他的角色中翻转了 2 位 (0b011)。因此,如果我们仅与单个角色进行比较,则断言将失败。

现在,让我们假设 in 运算符。假设 msg.sender 具有角色 (Roles.MANAGER | Roles.USER),并假设以下 in 运算符的用法:

def only_admin_and_manager():
    assert (self.roles[msg.sender] in (Roles.ADMIN | Roles.MANAGER))

尽管用户具有 Roles.USER(不在右侧)并且缺少角色 `roles.ADMIN,此断言仍将通过

注意

有关语义的更多讨论,请参见 此链接。语义可能会在即将到来的 重大版本 0.5.0 中发生更改。

19. Pragma 评估

Vyper 允许使用 # pragma 注释来配置编译过程。用户可以设置版本、优化级别或目标 EVM 版本等属性。

Vyper 还允许从多个模块组成合约,并且每个模块都可以有自己的 pragmas。在这种情况下,设置仅来自编译目标(同时检查所有模块的编译器版本 pragma)。因此,例如,如果用户在不同的模块中设置不同的优化级别,则编译过程将顺利通过,并且仅使用来自编译目标的设置。

20. for 语句中可迭代对象的评估

Vyper 允许多种 for 循环迭代方式。其中一种是迭代可迭代对象。让我们考虑文字可迭代对象和以下合约:

x: uint256
trace: DynArray[uint256, 3]

@external
def test() -> DynArray[uint256, 3]:
    for i: uint256 in [self.use_side_effect(), self.use_side_effect(), self.use_side_effect()]:
        self.x += 1
        self.trace.append(i)
    return self.trace

@view
def use_side_effect() -> uint256:
    return self.x

如果我们调用 test(),结果将是 [0, 0, 0],因为在进入循环之前会评估一次列表,并存储结果。然后,循环迭代此存储的值,因此它不会读取 self.x += 1 产生的副作用。

21. 具有越界 Calldata/Code 读取的解码行为

根据 EVM 规范,当使用 CALLDATACOPYCALLDATALOADCODECOPY 时,对于越界字节,将复制 0。

这些操作码被 Solidity 和 Vyper 抽象化,并且除了其他用途外,还用于 ABI 解码器中:

  1. 解码外部函数参数,
  2. 解码构造函数参数。

Solidity 始终确保要解码的数据适合由 calldatasize/ codesize 限制的提供的缓冲区。

另一方面,Vyper 确实检查要解码类型的静态部分是否适合提供的缓冲区(例如,对于动态数组,它将检查 head 是否适合),但不检查动态部分。

重要

Vyper 中的检查对于从其他数据位置(如内存或存储)的读取是严格的,在这些位置可能会发生读取“脏”字节的情况,并且这些字节并非完全由用户控制。

让我们假设 Vyper 和 Solidity 中的合约公开了函数 foo,该函数从 calldata 中获取一个动态数组作为参数。

store: DynArray[uint256, 10]

@external
def foo(a:DynArray[uint256, 10]):
    self.store = a
contract Foo{

  uint256[] store;

  function foo(uint256[] calldata a) public {
      store = a;
  }
}

现在,让我们为以函数 foo 为目标的低级 call 制作数据。根据 abi-spec,数组被编码为一个元组 - head 指向数组的长度(在本例中为 2)。Vyper 将从后面的零字节解码这两个数组元素。Solidity 将 revert。

method_id =  "8b44cef1"
head =   "0000000000000000000000000000000000000000000000000000000000000020"
length = "0000000000000000000000000000000000000000000000000000000000000002"
elem0 = ""
elem1 = ""

## 以下低级调用将通过
## store 的内容将为 [0, 0]
boa.env.raw_call(
    foo.address,
    data=bytes.fromhex(method_id + head + length + elem0 + elem1)
)

注意

Vyper 采用此策略是因为调用者可能一开始就自己提供了这些参数。这也是一种优化形式。如果调用者错误地编码了参数,并且解码例程读取了原始参数本应存在的零字节,则可能会出现问题。

注意

此项目由 @trocher 建议并共同撰写。

结论

介绍了一个包含 21 个可能存在问题的 Vyper 构造的列表。重点放在了语义可能与开发人员的直接直觉或其他语言的经验不同的项目上。

如果我要指出一些真正需要注意的项目,那就是

  1. 1) 求值顺序
  2. 3) DynArray 内存分配
  3. 4) 调用约定是按值传递
  4. 9) 内部函数的可支付性
  5. 12) 默认参数
  6. 18) 标志的语义

编译器团队正在积极解决许多项目,以使该语言尽可能安全和可读。

致谢

我从 Vyper 文档和 issue tracker 中获得了一些示例。此处的许多建议已经存在于文档中。此处描述的一些问题是在审计期间或由语言用户提出的。

我要感谢 @pcaversaccio 帮助改进了这篇文章。@trocher 也提供了宝贵的反馈,他共同撰写并提出了项目 21。

我还想推荐 @trocherSecureum race。在撰写本文时,他的一些示例被用作灵感。


  1. 语法赋值表示编译器检查赋值/调用是否存在的概念,而不是其保证的执行路径。↩︎

  2. 编译目标是用户请求编译的目标模块(它代表最终合约)。↩︎

  3. 有关 EVM 补贴,请参见 链接。↩︎

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

0 条评论

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