安全 - Secureum RACE 39 - Trocher

  • trocher
  • 发布于 2025-04-11 10:37
  • 阅读 11

本文档是 Secureum RACE 39 的题目和答案,主要考察了Vyper语言编写的智能合约的安全性知识,包括合约调用失败的情况、token的transfer函数、权限验证、函数选择器、存储槽等问题,通过分析合约代码,选择正确的描述或解决方案。

Secureum RACE #39

在以下问题中,假设所有合约都部署在以太坊主网上。每个问题至少有一个正确答案,但有些可能有多个。每当引用模块 ownable 时,都假定它是以下模块:

## pragma version 0.4.1

## simplified version of https://github.com/pcaversaccio/snekmate/blob/main/src/snekmate/auth/ownable.vy

owner: public(address)

@deploy
@payable
def __init__():
    self._transfer_ownership(msg.sender)

@external
def transfer_ownership(new_owner: address):
    self._check_owner()
    assert new_owner != empty(address)
    self._transfer_ownership(new_owner)

@internal
def _check_owner():
    assert msg.sender == self.owner

@internal
def _transfer_ownership(new_owner: address):
    old_owner: address = self.owner
    self.owner = new_owner

问题 1

给定以下合约,以及对 foo() 的调用,其中 gas=100_000(假设发送者有足够的 ETH 来支付消息值),以下哪些陈述是正确的?

## pragma version 0.4.1

@payable
@external
def foo():
    send(self, msg.value)

@payable
@external
def __default__():
    pass
  • [ ] A. 调用永远不会失败
  • [ ] B. 调用总是会失败
  • [ ] C. 调用可能会失败
  • [ ] D. 合约无法编译

解答

正确答案: C

解释:

  • 合约包含两个函数:foo() 和一个回退函数 (__default__)。
  • foo() 函数被标记为 @payable,并使用 send() 内置函数通过回退函数将 msg.value 转回合约。
  • 关键点: 根据 EVM 规范,当调用转移非零数量的 ETH 时,EVM 会向子上下文添加 2300 gas (GAS_STIPEND)。
  • 在 Vyper 中,send() 内置函数执行的调用 gas=0(除非使用 kwarg 另行指定)。也就是说,当转移零 ETH 时,send() 内置函数不会添加 gas 补贴(不像 Solidity 的 transfer() 那样)。
  • 因此,如果 msg.value 为零,则不会转发 gas,并且调用将失败。

因此:

  • A. 不正确 - 因为当 msg.value 为零时,调用可能会失败。

  • B. 不正确 - 当 msg.value 不为零时,调用可能会成功。

  • C. 正确。

  • D. 不正确 - 合约成功编译。

    • *

问题 2

假设你正在使用 IERC20(token).transfer(dst, 100) 对任意 ERC-20 代币进行外部调用,你应该明确地向调用添加什么?

  • [ ] A. skip_contract_check=True
  • [ ] B. default_return_value=True
  • [ ] C. value=0
  • [ ] D. gas=2300

解答

正确答案: B

解释:

  • 当调用 ERC-20 代币transfer() 函数时,你必须考虑实现方式的变化。
  • 一些代币(如 Tether/USDT)不按 ERC-20 标准的规定返回布尔值。
  • 通过指定 default_return_value=True,调用将把缺失的返回值默认为 True,从而防止因非标准行为而导致的回退。

因此:

  • A. 不正确 - 不建议跳过合约检查。

  • B. 正确。

  • C. 不正确 - value=0 是默认行为。

  • D. 不正确 - 仅提供 2300 gas 可能不足以调用 ERC-20 代币。此外,即使足够,也不建议硬编码 gas 值,因为未来的硬分叉可能会改变某些操作的 gas 成本。

    • *

问题 3

给定以下合约,检查用户是否具有 ModeratorAdmin 角色的正确方法是什么?

## pragma version 0.4.1

flag Access:
    User
    Admin
    Moderator

accessOf: HashMap[address, Access]

def only_admin_or_moderator_A():
    if self.accessOf[msg.sender] == Access.User:
        raise "User access denied"

def only_admin_or_moderator_B():
    if not (
        self.accessOf[msg.sender] == Access.Admin
        or self.accessOf[msg.sender] == Access.Moderator
    ):
        raise "User access denied"

def only_admin_or_moderator_C():
    if not (self.accessOf[msg.sender] in (Access.Admin | Access.Moderator)):
        raise "User access denied"
  • [ ] A. only_admin_or_moderator_A
  • [ ] B. only_admin_or_moderator_B
  • [ ] C. only_admin_or_moderator_C
  • [ ] D. 以上所有

解答

正确答案: C

解释:

  • Vyper 的 flag 工作方式与其他语言中的标志枚举类似(每个值代表一个位)。上面的 Access 类型表示为:
User     : 0b001
Admin    : 0b010
Moderator: 0b100
  • 因为任何组合(甚至 empty(Access) == 0b000)都是有效的,所以检查必须验证是否设置了至少一个所需的位。

因此:

  • A. 不正确。

此函数仅检查调用是否不仅仅是一个 User,但不验证 AdminModerator 标志的存在。

  • B. 不正确。

此函数检查调用是否具有 Admin 角色 XOR Moderator 角色,但如果出现以下情况,它将错误地失败:

  1. 调用同时具有这两个角色:( Access.Admin | Access.Moderator) = 0b110
  2. 调用是管理员或版主,但也是用户,例如:( Access.Admin | Access.User) = 0b011
    • C. 正确。

在 vyper 中,关键字 in 检查两个操作数上的任何标志是否同时设置,self.accessOf[msg.sender] in (Access.Admin | Access.Moderator) 等效于 (self.accessOf[msg.sender] & 0b110) != 0b000

  • D. 不正确。

只有答案 C 是正确的。


问题 4

给定以下合约:

flag E:
    a

@external
def hello_vyper_world(a: E, b: Bytes[4] = b'1234') -> Bytes[4]:
    return slice(msg.data, 0, 4)

以下哪些是函数 hello_vyper_world 的有效函数选择器?

  • [ ] A. 0xdbae851a
  • [ ] B. 0x41aa4785
  • [ ] C. 0x6acbda94
  • [ ] D. 0x986a9642

解答

正确答案: A 和 D

解释:

  • 在 Vyper 中:
    1. 标志(如 E)被转换为 ABI 类型 uint256
    2. Bytes[N] 类型被转换为 ABI 类型 bytes
    3. 当函数具有默认参数时,Vyper 会为每个重载生成一个入口点。
  • 因此,函数签名的两种规范表示形式是:
    • hello_vyper_world(uint256)
    • hello_vyper_world(uint256,bytes)
  • 获取这些规范表示形式的 keccak256 哈希的前 4 个字节会产生:
    • method_id(hello_vyper_world(uint256)): 0xdbae851a
    • method_id(hello_vyper_world(uint256,bytes)): 0x986a9642
  • 或者,检查 vyper 合约的方法标识符的简单方法是 vyper -f method_identifiers foo.vy

因此,AD 是有效的选择器。


问题 5

给定以下合约,以下哪些陈述是正确的?

(模块 ownable 在 RACE 的顶部定义)

## pragma version 0.4.1

import ownable
from ethereum.ercs import IERC20

initializes: ownable

receivers: DynArray[Receiver, max_value(uint32)]
BPS: constant(uint256) = 10_000
token_whitelist: HashMap[IERC20, bool]
token_balances: public(HashMap[IERC20, HashMap[address, uint256]])
token_balance_tracked: public(HashMap[IERC20, uint256])
struct Receiver:
    addr: address
    weight: uint256

@deploy
def __init__(initial_receivers: address[4]):
    ownable.__init__()
    for receiver: address in initial_receivers:
        self.receivers.append(Receiver(addr=receiver, weight=BPS // 4))

@external
def set_token_whitelist(token: IERC20, status: bool):
    ownable._check_owner()
    self.token_whitelist[token] = status

@internal
def _set_receivers(_receivers: DynArray[Receiver, max_value(uint32)]):
    total_weight: uint256 = 0
    for receiver: Receiver in _receivers:
        assert receiver.addr != empty(address), "receiver is the zero address"
        assert receiver.weight > 0, "receiver weight is zero"
        assert receiver.weight <= BPS, "receiver weight is too high"
        total_weight += receiver.weight
    assert total_weight == BPS, "total weight is not 100%"
    self.receivers = _receivers

@internal
def transfer_in(token: IERC20, amount: uint256) -> uint256:
    assert self.token_whitelist[token]
    if amount > 0:
        extcall token.transferFrom(msg.sender, self, amount)
    return amount

@external
def set_receivers(_receivers: DynArray[Receiver, max_value(uint32)]):
    ownable._check_owner()
    self._set_receivers(_receivers)

@payable
def deposit(token: address, amount: uint256):
    if token == empty(address):
        assert msg.value == amount
    else:
        assert msg.value == 0
        self.transfer_in(IERC20(token), amount)

@external
def distribute_tokens(token: IERC20, amount: uint256 = 0):
    balance: uint256 = unsafe_add(
        staticcall token.balanceOf(self), self.transfer_in(token, amount)
    )

    new_balance: uint256 = balance - self.token_balance_tracked[token]

    # Leave dust due to rounding errors in the untracked balance, to be distributed next time
    ## 由于未跟踪余额中的舍入误差,留下小额零钱,以便下次分发
    for receiver: Receiver in self.receivers:
        receiver_amount: uint256 = new_balance * receiver.weight // BPS
        self.token_balance_tracked[token] += receiver_amount
        self.token_balances[token][receiver.addr] += receiver_amount

@external
def claim_tokens(token: IERC20, to: address, amount: uint256):
    assert self.token_whitelist[token]
    assert self.token_balances[token][msg.sender] >= amount

    self.token_balances[token][msg.sender] -= amount
    self.token_balance_tracked[token] -= amount
    extcall token.transfer(to, amount)

@external
def recover(token: IERC20, to: address, amount: uint256, force: bool = False):
    ## 任何人都可以从合约中恢复非白名单**代币**
    if token.address == empty(address):
        ownable._check_owner()
        assert force, "force required"
        send(to, amount)
    else:
        # Anyone can recover non-whitelisted tokens from the contract
        assert not self.token_whitelist[token]

        ## 强制恢复
        success: bool = raw_call(
            token.address,
            abi_encode(
                to,
                amount,
                method_id=method_id("transfer(address,uint256)"),
            ),
            revert_on_failure=False,
        )
  • [ ] A. 所有可以将所有权转移到除零地址之外的任何地址
  • [ ] B. 初始所有是合约的部署
  • [ ] C. 任何人都可以在未经所有同意的情况下取得合约的所有权
  • [ ] D. 以上都不是

解答

正确答案: B

解释:

  • 在 Vyper 中,来自导入模块的外部函数(例如来自 ownabletransfer_ownership)不会自动在编译合约中公开。
  • 要公开此类函数,你需要一个显式语句(例如,exports: ownable.transfer_ownership)。

因此:

  • A. 不正确

transfer_ownership 函数不会在外部公开,因此所有无法将所有权转移到任何地址。

  • B. 正确。

ownable.__init__ 函数由合约的构造函数调用,并将所有权转移给合约的部署

  • C. 不正确。

_transfer_ownership() 从外部无法访问,并且仅在构造函数中调用。

  • D. 不正确。

因此,只有 B 是正确的。


问题 6

给定问题 5 中的代码,以下哪些陈述是正确的?

  • [ ] A. 无法设置接收
  • [ ] B. 在某些情况下,接收weight 之和可能大于 BPS
  • [ ] C. 没有实现简单的 ETH 存款方式
  • [ ] D. 以上都不是

解答

正确答案: A 和 C

解释:

  • A. 正确。

Vyper 静态地分配内存。对于定义为 DynArray[Receiver, max_value(uint32)] 的动态数组,非常大的上限强制保留大量内存(大小为 max_value(uint32) * 32 * 2 + 32 字节)。当写入此块之后分配的任何变量时,将触发并收取该块的内存扩展费用。由于其成本远高于以太坊区块 gas 限制,因此执行将耗尽 gas。这种过高的 gas 成本实际上使得设置接收成为不可能。

  • B. 不正确。

_set_receivers 函数确保所有接收权重的总和等于 BPS,因此总和不能超过 BPS

  • C. 正确。

deposit() 函数仅标记为 @payable(而不是 @external),这意味着它无法从外部访问。虽然可以通过其他方式(例如,自毁)将 ETH 强制放入合约中,但没有用于存放 ETH 的预期方法。

  • D. 不正确。

    • *

问题 7

给定问题 5 中的代码,并假设系统使用的所有代币都是受信任的、符合 ERC-20 标准的,并且没有异常行为(例如,重新定基、转移与请求金额不同的金额、转移费用、双重入口点、不符合标准的接口或Hook) - 例如,像 DAI 这样的代币 - 以下哪些陈述是正确的?

  • [ ] A. ERC20 代币可能会永远卡在合约中
  • [ ] B. 一些接收可能会获得比他们应该获得的更多的代币
  • [ ] C. 既不是接收也不是管理员的用户,可以从合约中窃取白名单代币
  • [ ] D. 以上都不是

解答

正确答案: B

解释:

  • A. 不正确。

非白名单代币可以通过 recover 函数恢复,白名单代币可以通过 distribute_tokens 适当管理。

  • B. 正确。

根据 GHSA-g2xh-c426-v8mf,Vyper 从右到左评估几个表达式的参数。这包括 unsafe_add()。在计算时

balance: uint256 = unsafe_add(
    staticcall token.balanceOf(self), self.transfer_in(token, amount)
)

这意味着将先执行对 transfer_in 的调用,然后再读取 token.balanceOf(self)

  • self.transfer_in(token, amount) 返回 amount
  • token.balanceOf(self) 返回 initial_balance + amount

因此,计算出的 balance 变为 initial_balance + 2 * amount,而不是预期的 initial_balance + amount。这意味着所有接收将被分配过多的代币,并且合约将无力偿债。第一个调用 claim_tokens() 的接收将从其他人那里窃取代币

  • C. 不正确。

recover 函数会阻止未经授权恢复的白名单代币

  • D. 不正确。

因此,只有 B 是正确的。


问题 8

给定以下合约,调用 set() 时写入到哪个(哪些)存储槽?(请注意,没有 使用存储布局覆盖)

## pragma version 0.4.1
## pragma evm-version cancun

import ownable
initializes: ownable

struct A:
    a: uint128
    b: bool

a: A
b: transient(address)
c: HashMap[uint256, DynArray[uint256, 5]]

@deploy
def __init__():
    ownable.__init__()
    self.c[4] = [1, 2]

@nonreentrant
@external
def set():
    assert len(self.c[4]) == 2
    self.c[4].append(12)
  • [ ] A. 0
  • [ ] B. 1
  • [ ] C. keccak256(concat(convert(3,bytes32),convert(3,bytes32)))
  • [ ] D. keccak256(concat(convert(4,bytes32),convert(3,bytes32)))
  • [ ] E. keccak256(concat(convert(3,bytes32),convert(4,bytes32)))
  • [ ] F. convert(convert(keccak256(concat(convert(3,bytes32),convert(3,bytes32))),uint256)+3,bytes32)
  • [ ] G. convert(convert(keccak256(concat(convert(4,bytes32),convert(3,bytes32))),uint256)+3,bytes32)
  • [ ] H. convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)
  • [ ] I. convert(convert(keccak256(concat(convert(3,bytes32),convert(3,bytes32))),uint256)+2,bytes32)
  • [ ] J. convert(convert(keccak256(concat(convert(4,bytes32),convert(3,bytes32))),uint256)+2,bytes32)
  • [ ] K. convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+2,bytes32)
  • [ ] L. convert(convert(keccak256(keccak256(concat(convert(3,bytes32),convert(3,bytes32)))),uint256)+2,bytes32)
  • [ ] M. convert(convert(keccak256(keccak256(concat(convert(4,bytes32),convert(3,bytes32)))),uint256)+2,bytes32)
  • [ ] N. convert(convert(keccak256(keccak256(concat(convert(3,bytes32),convert(4,bytes32)))),uint256)+2,bytes32)

解答

正确答案: E 和 H

解释:

调用 set() 期间写入的存储槽是:

  • 动态数组 self.c[4] 的索引 2 处的值,因为它设置为 12
  • 动态数组 self.c[4] 的长度,因为它由 append 函数更新。

cancun 用作 EVM 版本意味着 @nonreentrant 密钥存储在临时存储中,而不是常规存储中。

可以使用 vyper -f layout foo.vy 获得合约的存储布局,如下所示:

0x00: ownable.owner
0x01: a.a
0x02: a.b
0x03: c

此处需要考虑的关键点是:

  1. ownable 模块的存储插入到声明语句 initializes: ownable 的位置。
  2. 结构 A 的字段没有紧密打包,因为 Vyper 永远不会打包存储变量。
  3. 变量 b 是临时的,不占用存储槽。

鉴于与映射键 k 对应的值位于 keccak256(slot || k)(当 k 是值类型时),self.c[4] 的存储槽是:

s0 = keccak256(concat(convert(3,bytes32),convert(4,bytes32)))

请注意,这与 Solidity 不同,后者会执行 keccak256(k || slot)

Vyper 不以与 Solidity 相同的方式存储动态数组,因为在编译时已知最大边界,长度存储在第一个槽中,所有元素存储在后续的连续槽中。 对于 self.c[4] 处的数组,这将导致以下布局:

s0  : length
s0+1: array[0]
...
s0+5: array[4]

因此:

  • E. 正确 - 长度存储在 keccak256(concat(convert(3,bytes32),convert(4,bytes32)))
  • H. 正确 - 元素值存储在 convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)

所有其他选项均不正确。

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

0 条评论

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