本文档是 Secureum RACE 39 的题目和答案,主要考察了Vyper语言编写的智能合约的安全性知识,包括合约调用失败的情况、token的transfer函数、权限验证、函数选择器、存储槽等问题,通过分析合约代码,选择正确的描述或解决方案。
在以下问题中,假设所有合约都部署在以太坊主网上。每个问题至少有一个正确答案,但有些可能有多个。每当引用模块 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
给定以下合约,以及对 foo()
的调用,其中 gas=100_000
(假设发送者有足够的 ETH 来支付消息值),以下哪些陈述是正确的?
## pragma version 0.4.1
@payable
@external
def foo():
send(self, msg.value)
@payable
@external
def __default__():
pass
解答
正确答案: C
解释:
foo()
和一个回退函数 (__default__
)。foo()
函数被标记为 @payable
,并使用 send()
内置函数通过回退函数将 msg.value
转回合约。GAS_STIPEND
)。send()
内置函数执行的调用 gas=0
(除非使用 kwarg 另行指定)。也就是说,当转移零 ETH 时,send()
内置函数不会添加 gas 补贴(不像 Solidity 的 transfer()
那样)。msg.value
为零,则不会转发 gas,并且调用将失败。因此:
A. 不正确 - 因为当 msg.value
为零时,调用可能会失败。
B. 不正确 - 当 msg.value
不为零时,调用可能会成功。
C. 正确。
D. 不正确 - 合约成功编译。
假设你正在使用 IERC20(token).transfer(dst, 100)
对任意 ERC-20 代币进行外部调用,你应该明确地向调用添加什么?
skip_contract_check=True
default_return_value=True
value=0
gas=2300
解答
正确答案: B
解释:
transfer()
函数时,你必须考虑实现方式的变化。default_return_value=True
,调用将把缺失的返回值默认为 True
,从而防止因非标准行为而导致的回退。因此:
A. 不正确 - 不建议跳过合约检查。
B. 正确。
C. 不正确 - value=0
是默认行为。
D. 不正确 - 仅提供 2300 gas 可能不足以调用 ERC-20 代币。此外,即使足够,也不建议硬编码 gas 值,因为未来的硬分叉可能会改变某些操作的 gas 成本。
给定以下合约,检查用户是否具有 Moderator
或 Admin
角色的正确方法是什么?
## 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"
only_admin_or_moderator_A
only_admin_or_moderator_B
only_admin_or_moderator_C
解答
正确答案: C
解释:
flag
工作方式与其他语言中的标志枚举类似(每个值代表一个位)。上面的 Access
类型表示为:User : 0b001
Admin : 0b010
Moderator: 0b100
empty(Access) == 0b000
)都是有效的,所以检查必须验证是否设置了至少一个所需的位。因此:
此函数仅检查调用者是否不仅仅是一个 User
,但不验证 Admin
或 Moderator
标志的存在。
此函数检查调用者是否具有 Admin
角色 XOR Moderator
角色,但如果出现以下情况,它将错误地失败:
( Access.Admin | Access.Moderator) = 0b110
。( Access.Admin | Access.User) = 0b011
。
在 vyper 中,关键字 in
检查两个操作数上的任何标志是否同时设置,self.accessOf[msg.sender] in (Access.Admin | Access.Moderator)
等效于 (self.accessOf[msg.sender] & 0b110) != 0b000
。
只有答案 C 是正确的。
给定以下合约:
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
的有效函数选择器?
0xdbae851a
0x41aa4785
0x6acbda94
0x986a9642
解答
正确答案: A 和 D
解释:
E
)被转换为 ABI 类型 uint256
。Bytes[N]
类型被转换为 ABI 类型 bytes
。hello_vyper_world(uint256)
hello_vyper_world(uint256,bytes)
method_id(hello_vyper_world(uint256)): 0xdbae851a
method_id(hello_vyper_world(uint256,bytes)): 0x986a9642
vyper -f method_identifiers foo.vy
。因此,A 和 D 是有效的选择器。
给定以下合约,以下哪些陈述是正确的?
(模块 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,
)
解答
正确答案: B
解释:
ownable
的 transfer_ownership
)不会自动在编译合约中公开。exports: ownable.transfer_ownership
)。因此:
transfer_ownership
函数不会在外部公开,因此所有者无法将所有权转移到任何地址。
ownable.__init__
函数由合约的构造函数调用,并将所有权转移给合约的部署者。
_transfer_ownership()
从外部无法访问,并且仅在构造函数中调用。
因此,只有 B 是正确的。
给定问题 5 中的代码,以下哪些陈述是正确的?
weight
之和可能大于 BPS解答
正确答案: A 和 C
解释:
Vyper 静态地分配内存。对于定义为 DynArray[Receiver, max_value(uint32)]
的动态数组,非常大的上限强制保留大量内存(大小为 max_value(uint32) * 32 * 2 + 32
字节)。当写入此块之后分配的任何变量时,将触发并收取该块的内存扩展费用。由于其成本远高于以太坊区块 gas 限制,因此执行将耗尽 gas。这种过高的 gas 成本实际上使得设置接收者成为不可能。
_set_receivers
函数确保所有接收者权重的总和等于 BPS
,因此总和不能超过 BPS
。
deposit()
函数仅标记为 @payable
(而不是 @external
),这意味着它无法从外部访问。虽然可以通过其他方式(例如,自毁)将 ETH 强制放入合约中,但没有用于存放 ETH 的预期方法。
D. 不正确。
给定问题 5 中的代码,并假设系统使用的所有代币都是受信任的、符合 ERC-20 标准的,并且没有异常行为(例如,重新定基、转移与请求金额不同的金额、转移费用、双重入口点、不符合标准的接口或Hook) - 例如,像 DAI 这样的代币 - 以下哪些陈述是正确的?
解答
正确答案: B
解释:
非白名单代币可以通过 recover
函数恢复,白名单代币可以通过 distribute_tokens
适当管理。
根据 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()
的接收者将从其他人那里窃取代币。
recover
函数会阻止未经授权恢复的白名单代币。
因此,只有 B 是正确的。
给定以下合约,调用 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)
0
1
keccak256(concat(convert(3,bytes32),convert(3,bytes32)))
keccak256(concat(convert(4,bytes32),convert(3,bytes32)))
keccak256(concat(convert(3,bytes32),convert(4,bytes32)))
convert(convert(keccak256(concat(convert(3,bytes32),convert(3,bytes32))),uint256)+3,bytes32)
convert(convert(keccak256(concat(convert(4,bytes32),convert(3,bytes32))),uint256)+3,bytes32)
convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)
convert(convert(keccak256(concat(convert(3,bytes32),convert(3,bytes32))),uint256)+2,bytes32)
convert(convert(keccak256(concat(convert(4,bytes32),convert(3,bytes32))),uint256)+2,bytes32)
convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+2,bytes32)
convert(convert(keccak256(keccak256(concat(convert(3,bytes32),convert(3,bytes32)))),uint256)+2,bytes32)
convert(convert(keccak256(keccak256(concat(convert(4,bytes32),convert(3,bytes32)))),uint256)+2,bytes32)
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
此处需要考虑的关键点是:
ownable
模块的存储插入到声明语句 initializes: ownable
的位置。A
的字段没有紧密打包,因为 Vyper 永远不会打包存储变量。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]
因此:
keccak256(concat(convert(3,bytes32),convert(4,bytes32)))
。convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)
。所有其他选项均不正确。
- 原文链接: hackmd.io/@trocher/ByQK9...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!