RACE #35 Of The Secureum Bootcamp Epoch∞
This is a Write-Up of RACE-35, Quiz of the Secureum Bootcamp (opens in a new tab) for Ethereum Smart Contract Auditors. This month's RACE was designed by Secureum Mentor and Independent Security Researcher 0x4non aka another anon (opens in a new tab).
Participants of this quiz had a single attempt 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!
As usual, I waited for submissions to close before publishing it and, to stay true to the original, I omitted syntax highlighting. Feel free to copy it into your favorite editor, but do so after starting the timer!
December 10, 2024 by patrickd
Question 1 of 8
// this is the contract that its in your generated address
contract Empty{}
You have received a bug bounty in USDC and, as usual, you generate a new wallet to receive it. After the project has paid, you use etherscan to check if the funds arrived. But you discover that your wallet is a contract! What are the odds? Which of the following statements are true?
- A. You can directly transfer the USDC using your private key.
- B. You can’t send a tx because of EIP-3607.
- C. It is still possible to recover the USDC.
- D. The USDC is lost forever.
Solution
Correct is B, C.
In the rare event that this happens, A is not possible because of B.
EIP-3607 was specifically created to handle this type of situation where a contract address and an EOA address have a collision: To prevent a situation where it turns out that an otherwise trustworthy contract address suddenly rug pulls all funds using such a collision, this Ethereum Improvement Proposal was implemented dictating that any private-key based transactions from addresses with code must be rejected by nodes and not included or accepted in blocks.
EIP-3607 warns that it can be bypassed by a blockchain reorganization or by self-destructing the contract. The former should be rather unlikely, the latter won't work anymore either with the changes to the SELFDESTRUCT
opcode introduced by the Dencun (Cancun-Deneb, March 2024) Fork where deleting the contract code is only possible if it was deployed within that same transaction. But in the first place, this empty contract doesn't allow to trigger self-destruction.
However there's still a way to rescue the funds: After all, they aren't actually located at that address, rather it's that the token contract has stored a balance for that address. As long as we have an approval to the funds of that address without actually sending transactions from it, we can rescue them using a transferFrom()
call. Naturally, a call to approve()
would be rejected by EIP-3607 just as a call to transfer()
would. But luckily USDC implements permit()
(EIP-2612) which allows us to sign an approval to the funds without an on-chain transaction.
Question 2 of 8
function docall(address vault, string memory func, bytes calldata data) external {
require(keccak256(bytes(func)) != keccak256("withdraw()"), "forbidden sig");
(bool success, ) = address(vault).call(abi.encodeWithSignature(func, data));
require(success);
}
Which of the following statements are true regarding the docall()
function?
- A. Function is secure, an user can not bypass the check and call
withdraw()
. - B. Function is not secure, an user can bypass the check and call
withdraw()
. - C. Function needs a reentrancy guard.
- D. Function will always revert.
Solution
Correct is B.
A/B: What's actually used in a call is only the first 4 bytes of the function signature's hash. This check requires the full hash to be unequal, which makes this check trivial to bypass: We can have the first 4 bytes that actually matter match with the first 4 bytes of keccak256("withdraw()")
and the rest of the hash can just be random as long as its different. Such function-signature clashes, ie. functions that share the first 4 bytes of the keccak256 hash are easily generated. Specifying hack_540276142()
as func
will do the trick here. Check out the Polynetwork hack (opens in a new tab) for a real-world example of a function-signature clash being exploited.
C: This function has no state changes. Reentrancy vulnerabilities are related to passing execution control to other (often untrusted) code while the contract's state has not been fully updated yet, allowing that other code to exploit such incomplete state.
D: It will revert depending on whether the call can be executed successfully. But it won't always revert.
Question 3 of 8
/// @dev docall is a secure function that lets you call an address with any message except “withdraw()”
function docall(address vault, bytes calldata data) external {
bytes4 sig;
assembly {
sig := calldataload(0x64)
}
// abi.encodeWithSignature("withdraw()") == 0x3ccfd60b
require(sig != 0x3ccfd60b, "forbidden sig");
(bool success, ) = address(vault).call(data);
require(success);
}
Which of the following statements are true regarding the docall()
function?
- A. Function is secure, the check cannot be bypassed.
- B. This can be easily bypassed by calling
docall(vault, abi.encodeWithSignature("withdraw()", "anything"));
. - C. Function is not secure, a user can bypass the check and call
withdraw()
. - D. This function is protected by EIP-3607.
Solution
Correct is C.
Here an attacker can exploit the fact that ABI encoding makes use of pointers for variable length types like bytes. We can put any rubbish at offset 0x64
if the actual content of the data
variable is specified to be located somewhere else.
The contract expects the calldata to have the following structure:
Offset | Length in bytes | Description |
---|---|---|
0x00 | 4 | function signature of docall(address,bytes) |
0x04 | 32 | 20 bytes of these are occupied by the vault address |
0x24 | 32 | pointer specifying the offset where the content of data is located, excludes the function signature, ie. points to 0x44 by specifying 0x40 |
0x44 | 32 | length of the raw value of data |
0x64 | 0x44 | actual raw value of data , the first 32 bytes of this are loaded by sig := calldataload(0x64) |
To exploit this, the above calldata can be rewritten to have the pointer specify a later location, such as 0x80
the value at which will be decoded and put into the data
variable. But the value loaded into the sig
variable will instead be whatever we put at 0x64
, which we can choose freely.
Answers B and C are fillers.
Question 4 of 8
contract Token {
Owner immutable public owner;
constructor(address _owner) {
owner = Owner(_owner);
}
.......
}
contract Owner {
Token immutable public token;
constructor(address _token) {
token = Token(_token);
}
.......
}
Which of the following statements are true for the above contracts?
- A. This setup is impossible to deploy.
- B. Can be deployed using a contract that uses
CREATE
. - C. Can be deployed using the
CREATE2
opcode with the above contracts' bytecode. - D. Can be deployed using a contract that uses the
CREATE3
pattern.
Solution
Correct is B, D.
The apparent problem here is that there's a circular dependency between these contracts where Token
requires the address of Owner
before it can be deployed, while Owner
requires the address of Token
to be deployed. But addresses of contracts can be deterministically determined before they're actually created on-chain.
NEXT_CREATE_ADDRESS = HASH(deployer_address, deployer_nonce)
The CREATE
opcode determines a contract's address based on the address of the contract executing the CREATE
opcode, hashed with that contract's nonce
counter. Both of these values can be known beforehand, therefore we're able to determine the deployment address without actually deploying anything yet.
NEXT_CREATE2_ADDRESS = HASH(0xff, deployer_address, salt, init_code)
The CREATE2
opcode is different in that, instead of a nonce
, it determines the deployment address based on a salt
that we can freely choose and on the initialization code of the contract to deploy.
You may think that the problem here is the use of immutable
variables, the values of which are placed into the bytecode by the constructor, therefore changing the deployment address. This is not the case though, as the constructor is causing changes to the runtime bytecode when placing the values of immutables, not the initialization bytecode that is actually used to determine the deployment address.
The actual problem is that the address
passed into the constructor is part of the initialization bytecode, throwing us back to the circular dependency problem. The initialization bytecode basically consists of 3 components: The actual initialization code (ie. constructor and storage variable assignments), the raw runtime bytecode (with empty placeholders for immutable variables ready to be filled by initialization code), and finally the ABI-encoded parameter values to be passed to the constructor.
PROXY_DEPLOYER_ADDRESS = HASH(0xff, deployer_address, salt, PROXY_DEPLOYER_BYTECODE)
NEXT_CREATE3_ADDRESS = HASH(PROXY_DEPLOYER_ADDRESS, FIRST_PROXY_DEPLOYER_NONCE)
The CREATE3
pattern is not an actual opcode, but rather a trick (opens in a new tab) that allows removing the initialization code as a factor for the deployment address. It works by deploying a simple static contract via CREATE2
that does nothing else but deploy whatever you send to it as calldata via CREATE
. Thanks to this "proxy deployer" always having the same initialization bytecode, we can pre-determine its address deterministically. We can hash this address with a nonce of 1 to determine the deployment address of whatever code we will pass to it.
The remaining questions of this RACE are based on the following code.
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// deploy example:
// new ProxyContract(address(new ImplementationContract()));
contract ProxyContract is ERC1967Proxy {
uint160 public counter;
uint public cState;
address public admin;
constructor(address logic) ERC1967Proxy(logic, abi.encodeWithSelector(ImplementationContract.initialize.selector)) {
ERC1967Utils.changeAdmin(msg.sender);
admin = msg.sender;
}
receive() external payable {}
function implementation() external view returns (address) {
return _implementation();
}
function upgradeTo(address newImplementation) external {
require(ERC1967Utils.getAdmin() == msg.sender, "NoT AdMiN");
ERC1967Utils.upgradeToAndCall(newImplementation,"");
}
function updateCounter(uint160 _counter) external {
counter = _counter;
}
function updatecState(uint newState) external {
cState = newState;
}
}
// Hint, slots of arrays are keccak256(abi.encode(SLOT_NUMBER))
// https://www.rareskills.io/post/solidity-dynamic
contract ImplementationContract is Initializable{
address public owner;
uint private constant MAX = 10;
uint256[] public buckets;
/// @dev Equivalent to: `uint72(bytes9(keccak256("_REENTRANCY_GUARD_SLOT")))`
uint256 constant private _REENTRANCY_GUARD = 0x929eee149b4bd21268;
modifier reentrantGuard() {
assembly {
let _reenter := tload(_REENTRANCY_GUARD)
if eq(_reenter, 2) {
mstore(0x00, 0xab143c06) // `Reentrancy()`.
revert(0x1c, 0x04)
}
tstore(_REENTRANCY_GUARD, 2)
}
_;
}
constructor() {
owner = msg.sender;
}
function initialize() external {
buckets = new uint256[](MAX);
buckets[0] = 1;
}
function deposit(uint bucketNumber, uint value) payable external {
require(msg.sender==owner);
unchecked { buckets[bucketNumber] += value; }
}
function withdraw(uint bucketNumber, uint value) external reentrantGuard {
require(msg.sender==owner);
unchecked {buckets[bucketNumber] -= value;}
msg.sender.call{value: value}("");
}
}
Question 5 of 8
In one tx anyone can change:
- A. The admin slot of the
ProxyContract
by callingupdatecState()
. - B. The implementation of the
ProxyContract
by callingupdatecState()
. - C. The owner of the
ImplementationContract
by callingupdateCounter()
. - D. The implementation of the
ProxyContract
by callingupdateCounter()
.
Solution
Correct is C.
C/D: There is a storage clash between the ProxyContract
's counter
variable and the ImplementationContract
's owner
variable: Due to the way how Solidity manages storage variables, both variables will be assigned to the same storage slots. Since a proxy shares the same storage with its implementation(s) that means that setting one overwrites the other. Calling updateCounter()
will cause a change in these clashing variables, but none to the proxy's implementation storage variable.
A/B: Calling updatecState()
will update the value at slot 1, which for the implementation contains the length of the buckets
array, and for the proxy contains the value of cState
. At no point does this affect the admin slot or implementation of the proxy.
Question 6 of 8
Which of the following statements are true?
- A. It is possible to reinitialize the implementation and
selfdestruct
triggering a DOS. - B. It is possible to add more than 10 elements to the bucket.
- C. It’s impossible to add more than 10 elements to the bucket.
- D. By default
ImplementationContract(proxy).owner()
is themsg.sender
.
Solution
Correct is B.
As mentioned in the previous question, due to the storage clash between cState
and the length of the buckets
array, it's possible to increase the array length and therefore add more than 10 elements to the bucket.
The initial value of ImplementationContract(proxy).owner()
will be the zero-address because the owner is set within the constructor
, instead of the initialize()
function.
Question 7 of 8
It is possible to:
- A. Change the
ERC1967Proxy.ADMIN_SLOT
value. - B. Change the proxy
IMPLEMENTATION
without callingupgradeTo(address newImplementation)
. - C. Renounce the
ERC1967Proxy
, making it impossible to regain ownership and without changing the current implementation. - D. Change the value of slot
_REENTRANCY_GUARD
to trigger a DOS inwithdraw(uint256,uint256)
.
Solution
Correct is A.
We can find the following storage slot locations in OpenZeppelin's ERC1967Utils.sol (opens in a new tab):
IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
As mentioned within the code's inline comments, we can also determine the slot location of the first array element based on the Solidity-assigned slot number of the array variable (here 1) with keccak256(abi.encode(SLOT_NUMBER))
:
> FIRST_ARRAY_ITEM_SLOT = keccak256(abi.encode(1))
0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6
With this we can see that the order of these slots within storage is IMPLEMENTATION_SLOT, FIRST_ARRAY_ITEM_SLOT, ADMIN_SLOT which means that we can keep adding elements to the buckets
array until we clash with the ADMIN_SLOT
and are able to overwrite it.
This vulnerability also makes it impossible to fully renounce ownership, as you could always exploit it to regain ownership.
The reentrancy guard is making use of transient storage, which always starts with zeroed values at the beginning of a transaction. A Denial of Service would require using the persistent storage instead.
Question 8 of 8
Which of the following statements are true?
- A.
reentrantGuard
modifier cannot be bypassed. - B.
withdraw
will get locked forever after calling it one time. - C. The
reentrantGuard
with transient storage is more gas efficient. - D. All the above.
Solution
Correct is A, C.
A: Although the reentrantGuard
modifier can indeed not be bypassed, it also never actually unlocks itself. This means that functions using the modifier can't be called twice within the same transaction.
B: While this is not true on the actual chain, it will appear like that when testing it within Foundry which runs everything within a single transaction without clearing the TRANSIENT storage between tests.
C: Modifying a transient storage value is indeed cheaper than modifying persistent storage (ie. the global state).