RACE #39 Of The Secureum Bootcamp Epoch∞
This is a mirror of a Write-Up on RACE-39, Quiz of the Secureum Bootcamp (opens in a new tab) for Ethereum Smart Contract Auditors. It was designed by Secureum Mentor Tanguy (aka trocher) (opens in a new tab), a Security Researcher at ChainSecurity, contributor and reviewer of the Vyper compiler.
The original version of this document can be found at https://hackmd.io/@trocher/ByQK9Vlnkx (opens in a new tab)
Participants of this quiz had to answer 8 questions within the strict time limit of 16 minutes. If you’re reading this in preparation for participating yourself, it’s best to give it a try under the same time limit!
Note that this race is focussed on the Vyper (opens in a new tab) language. In the following questions, assume all contracts are deployed on Ethereum Mainnet. Each question has at least one correct answer, though some may have more than one. Whenever the module ownable is referenced, it is assumed to be the following module:
# 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
Question 1 of 8
Given the following contract, and a call to foo()
with gas=100_000
(assuming the sender has enough ETH to cover the message value), which of the following statements are true?
# pragma version 0.4.1
@payable
@external
def foo():
send(self, msg.value)
@payable
@external
def __default__():
pass
- A. The call will never fail
- B. The call will always fail
- C. The call might fail
- D. The contract does not compile
Solution
Correct Answers: C.
- The contract contains two functions:
foo()
and a fallback (__default__
). - The
foo()
function is marked as@payable
and uses thesend()
builtin to transfermsg.value
back to the contract via the fallback function. - Key Point: According to EVM specifications (opens in a new tab), when a call transfers a non-zero amount of ETH, the EVM adds 2300 gas (
GAS_STIPEND
) to the sub-context. - In Vyper, the
send()
builtin performs a call withgas=0
(unlike specified otherwise with a kwarg). That is, when transferring zero ETH, thesend()
builtin does not add a gas stipend (unlike what Solidity'stransfer()
would do). - Therefore, if
msg.value
is zero, no gas is forwarded, and the call will fail.
Thus:
- A. Incorrect - because the call may fail when
msg.value
is zero. - B. Incorrect - the call may succeed when
msg.value
is non-zero. - C. Correct.
- D. Incorrect - the contract compiles successfully.
Question 2 of 8
Assuming you are making an external call with IERC20(token).transfer(dst, 100)
to an arbitrary ERC-20 token, what should you definitely add to the call?
- A.
skip_contract_check=True
- B.
default_return_value=True
- C.
value=0
- D.
gas=2300
Solution
Correct Answers: B.
- When calling an ERC-20 token's
transfer()
function, you must account for variations in implementations. - Some tokens (like Tether/USDT) do not return a boolean as specified by the ERC-20 standard.
- By specifying
default_return_value=True
, the call will treat a missing return value asTrue
by default, preventing a revert due to non-standard behavior.
Thus:
- A. Incorrect - skipping the contract check is not recommended.
- B. Correct.
- C. Incorrect -
value=0
is the default behavior. - D. Incorrect Providing only 2300 gas might be insufficient for a call to an ERC-20 token. Furthermore, even if it was enough, hard-coding the gas value is not recommended as future hard forks might change the gas cost of certain operations.
Question 3 of 8
Given the following contract, what is a correct way of checking if a user has the Moderator
or Admin
role?
# 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. All of the above
Solution
Correct Answers: C.
-
Vyper's
flag
work similarly to flag enums in other languages (each value represents a bit). TheAccess
type above is represented as:User : 0b001 Admin : 0b010 Moderator: 0b100
-
Because any combination (and even
empty(Access) == 0b000
) is valid, the check must verify that at least one of the desired bits is set.
Thus:
-
A. Incorrect.
This function only checks that the caller is not just a
User
but does not verify the presence ofAdmin
orModerator
flags. -
B. Incorrect.
This function checks that the caller have the
Admin
role XOR theModerator
role, but it would incorrectly fail if:- The caller has both roles:
( Access.Admin | Access.Moderator) = 0b110
. - The caller is an Admin or a Moderator but is also a User, for example:
( Access.Admin | Access.User) = 0b011
.
- The caller has both roles:
-
C. Correct.
In vyper the keyword
in
checks that any of the flags on two operands are simultaneously set,self.accessOf[msg.sender] in (Access.Admin | Access.Moderator)
is equivalent to(self.accessOf[msg.sender] & 0b110) != 0b000
. -
D. Incorrect.
Only answer C is correct.
Question 4 of 8
Given the following contract:
flag E:
a
@external
def hello_vyper_world(a: E, b: Bytes[4] = b'1234') -> Bytes[4]:
return slice(msg.data, 0, 4)
Which of the following are valid function selectors for the function hello_vyper_world
?
- A.
0xdbae851a
- B.
0x41aa4785
- C.
0x6acbda94
- D.
0x986a9642
Solution
Correct Answers: A, D.
- In Vyper:
- Flags (like
E
) are converted to the ABI typeuint256
. Bytes[N]
types are converted to the ABI typebytes
.- When a function has default arguments, Vyper generates one entry point per overload.
- Flags (like
- Therefore, the two canonical representations for the function signature are:
hello_vyper_world(uint256)
hello_vyper_world(uint256,bytes)
- Taking the first 4 bytes of the keccak256 hash of these canonical representations yields:
method_id(hello_vyper_world(uint256)): 0xdbae851a
method_id(hello_vyper_world(uint256,bytes)): 0x986a9642
- Alternatively, an easy way to check the method identifiers of a vyper contract is
vyper -f method_identifiers foo.vy
.
Thus, A and D are the valid selectors.
Question 5 of 8
Given the following contract, which of the following statements are true?
(The module ownable is defined at the top of the 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. The owner can transfer ownership to any address except the zero address
- B. The initial owner is the deployer of the contract
- C. Anyone can take ownership of the contract without the owner's consent
- D. None of the above
Solution
Correct Answers: B.
- In Vyper, external functions from an imported module (such as
transfer_ownership
fromownable
) are not automatically exposed in the compiling contract. - To expose such a function, you would need an explicit statement (e.g.,
exports: ownable.transfer_ownership
).
Thus:
-
A. Incorrect
The
transfer_ownership
function is not exposed externally, so the owner cannot transfer ownership to any address. -
B. Correct.
The
ownable.__init__
function is called by the constructor of the contract and transfers ownership to the deployer of the contract. -
C. Incorrect.
_transfer_ownership()
is unreachable from the outside and only called in the constructor. -
D. Incorrect. Hence, only B is correct.
Question 6 of 8
Given the code from Question 5, which of the following statements are true?
- A. It is not possible to set the receivers
- B. The sum of receivers'
weight
might be larger than BPS in some cases - C. No easy way to deposit ETH is implemented
- D. None of the above
Solution
Correct Answers: A, C.
-
A. Correct.
Vyper allocates memory statically. For a dynamic array defined asDynArray[Receiver, max_value(uint32)]
, the extremely large upper bound forces the reservation of a vast amount of memory (of sizemax_value(uint32) * 32 * 2 + 32
bytes). When any variable allocated after this block is written to, the memory expansion of the block is triggered and charged. As its cost is much greater than Ethereum block gas limit, the execution will run out of gas. This prohibitively high gas costs effectively make setting receivers impossible. -
B. Incorrect.
The_set_receivers
function ensures that the sum of all receiver weights equalsBPS
, so the total cannot exceedBPS
. -
C. Correct.
Thedeposit()
function is marked only as@payable
(and not as@external
), meaning it is not accessible externally. Although ETH could be forced into the contract via other means (e.g., selfdestruct), there is no intended method for depositing ETH. -
D. Incorrect.
Question 7 of 8
Given the code from Question 5, and assuming all tokens used by the system are trusted, ERC-20 compliant, with no unusual behaviors (e.g., rebasing, transfers a diffent amount than requested, fee-on-transfer, double entry points, non-compliant interface, or hooks) — for example, tokens like DAI — which of the following statements are true?
- A. ERC20 tokens might be stuck forever in the contract
- B. Some receivers could get more tokens than they should
- C. A user who is not a receiver nor the admin could steal whitelisted tokens from the contract
- D. None of the above
Solution
Correct Answers: B.
-
A. Incorrect.
Non-whitelisted tokens can be recovered via therecover
function, and whitelisted tokens are properly managed throughdistribute_tokens
. -
B. Correct.
According toGHSA-g2xh-c426-v8mf
, Vyper evaluates the argument of several expressions from right to left. This includesunsafe_add()
. When computingbalance: uint256 = unsafe_add( staticcall token.balanceOf(self), self.transfer_in(token, amount) )
This means that the call to
transfer_in
will be performed before readingtoken.balanceOf(self)
.self.transfer_in(token, amount)
returnsamount
token.balanceOf(self)
returnsinitial_balance + amount
.
As a result, the computed
balance
becomesinitial_balance + 2 * amount
instead of the intendedinitial_balance + amount
. This means that all receivers will be allocated too much tokens and the contract will be insolvent. The first receivers to callclaim_tokens()
will be stealing tokens from the others. -
C. Incorrect.
Therecover
function prevents non-authorized recovery of whitelisted tokens. -
D. Incorrect.
Thus, only B is correct.
Question 8 of 8
Given the following contract, what storage slot(s) are written to when calling set()
? (Note that no storage layout override is being used)
# 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)
Solution
Correct Answers: E, H.
The storage slot written to during a call to set()
are:
- The value at the index
2
of the Dynamic arrayself.c[4]
, as it is set to12
. - The length of the Dynamic array
self.c[4]
, as it is updated by theappend
function.
Having cancun
as the EVM version means that the @nonreentrant
key is stored in the transient storage and not in the regular storage.
The storage layout of the contract can be obtained with vyper -f layout foo.vy
and is as follows:
0x00: ownable.owner
0x01: a.a
0x02: a.b
0x03: c
Key points to consider here are:
- The storage of the
ownable
module is inserted where the statementinitializes: ownable
is declared. - The fields of the struct
A
are not tightly packed as Vyper never pack storage variables. - The variable
b
is transient and does not occupy a storage slot.
Given that value corresponding to a mapping key k
is located at keccak256(slot || k)
(when k is a value type), the storage slot of self.c[4]
is:
s0 = keccak256(concat(convert(3,bytes32),convert(4,bytes32)))
Note that this differs from Solidity, which would do keccak256(k || slot)
.
Vyper does not store dynamic arrays in the same way as Solidity, because a maximum bound is known at compile time, the length is stored in the first slot, and all elements are stored in subsequent consecutive slots. In the case of the array at self.c[4]
, this would lead to the following layout:
s0 : length
s0+1: array[0]
...
s0+5: array[4]
Thus:
- E. Correct - length is stored at
keccak256(concat(convert(3,bytes32),convert(4,bytes32)))
. - H. Correct - the element value is stored at
convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)
.
All other options are incorrect.