RACE #22 Of The Secureum Bootcamp Epoch∞
This is a Write-Up of RACE-22, Quiz of the Secureum Bootcamp (opens in a new tab) for Ethereum Smart Contract Auditors. It was designed by the legendary Secureum Mentor Tincho (opens in a new tab), creator of Damn Vulnerable DeFi (opens in a new tab) and founder of The Red Guild (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!
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!
October 3, 2023 by patrickd
Question 1 of 8
contract CannotReceiveETH {
receive() external payable {
revert();
}
function hasETH() external view returns (bool) {
return address(this).balance > 0;
}
}
Select the true statement(s) about the above contract:
- A. It's impossible for the
hasETH
function to ever returntrue
- B. The contract's ETH balance may increase if the deployer sends ETH during the deployment
- C. The contract's ETH balance can increase if it's the target of a
SELFDESTRUCT
opcode - D. The contract's ETH balance can increase if it's the target of a beacon chain withdrawal
Solution
Correct is C, D.
- B: Solidity contracts require a payable constructor in order to be able to receive ETH during deployment. Attempting to send anything will revert.
- C: It's always possible to inject ETH balance into a deployed contract by specifying it as the receiver address when self-destructing another contract as this won't invoke the receiver's bytecode and doesn't give it a chance to reject.
- D: Beacon chain withdrawals (opens in a new tab) happen at protocol level outside of the EVM. They are gas-free and will not invoke the contract's bytecode.
- A: As it's possible for the contract to receive ETH after all, the
hasETH
function may return true under the mentioned circumstances.
Question 2 of 8
contract Example {
event FallbackExecuted(bytes data, uint256 value);
event ReceiveExecuted(uint256 value);
fallback() external payable {
emit FallbackExecuted(msg.data, msg.value);
}
receive() external payable {
emit ReceiveExecuted(msg.value);
}
}
Select the true statement(s) about the above contract:
- A. The
ReceiveExecuted
event is emitted when ETH is sent in a call, regardless of the length of the call’s calldata - B. The
FallbackExecuted
event is emitted when ETH is sent in a call and the call’s calldata is not empty - C. The
FallbackExecuted
event is emitted when no ETH is sent in a call and the call’s calldata is empty - D. The
ReceiveExecuted
event is emitted when ETH is sent in a call and the call’s calldata starts with0xa3e76c0f
(the function signature ofreceive()
) - E. The
receive
function only has 2300 gas available, regardless of how much the caller has sent
Solution
Correct is B.
- A: If the calldata isn't empty,
receive
is not executed (opens in a new tab). - B: The
fallback
is executed as a last resort function when none of the others match. In this case, thereceive
function is not executed because the calldata is not empty, so execution goes to thefallback
. - C: In this case the
receive
function is executed. - D: If the calldata is not empty, the
receive
is not executed. Also note that bothfallback
andreceive
are not like external/public functions. They have no function signature. - E: Is false because
receive
doesn't limit the amount of gas available. Gas may be limited when the contract was called viatransfer()
though, for example.
Question 3 of 8
contract Example {
function foo(uint8 data, uint64 length) external {
// ...
}
}
In the above contract, what are some safety checks automatically included by the Solidity compiler?
- A. Panic if the call has value greater than zero
- B. Panic if the calldata's size is not larger than 4 bytes
- C. Panic if the calldata's size is larger than 68 bytes
- D. Panic if the first parameter cannot be ABI-decoded to a
uint8
type - E. Panic if the second parameter cannot be ABI-decoded to a
uint64
type
Solution
Correct is A, B, D, E.
- A: To functions that are not marked as
payable
, Solidity adds bytecode for ensuring that themsg.value
sent is indeed zero. - B: If the calldata's size is exactly 4 bytes, it may contain the
foo
function's correct signature but it would error since the calldata is not of sufficient length to attempt decoding the function's parameters from. If the calldata sent does not map to an existing function, or is shorter than 4 bytes, it would error too since there's nofallback
orreceive
functions to handle such a case. - C: Solidity's ABI decoder will ignore any additional data sent that it doesn't need. If the
foo
function was being called, it would not error if the calldata size is larger than the function's signature and its expected parameter data. - D/E: Solidity will indeed revert if parameter values are sent that do not fit within the mentioned types (ie. overflow while down-casting).
To verify these, you can compile the contract (solc --ir Example.sol
), read the Yul output, and see the actual checks included by the compiler. Here's the Yul output (opens in a new tab). Tincho has marked the relevant lines with the comment //QUIZZ
so you can find them.
Question 4 of 8
contract Example {
uint8[] public someArray = new uint8[](300);
function foo(uint32 a, uint32 b, uint32 index) external {
unchecked {
someArray[index] = uint8(a / b);
}
}
}
Which of the statement(s) is/are true about the above contract?
- A. Due to the use of
unchecked
, out-of-bound access protections are disabled. So when theindex
parameter is greater than 300, execution does not revert - B. Due to the use of
unchecked
, whenb
is 0,a / b
does not revert and results in 0 - C. The use of
unchecked
eliminates the compiler’s automatic overflow check when casting the result ofa / b
touint8
- D. None of the above
Solution
Correct is D.
- A: Unchecked-blocks do not disable out-of-bound access checks in Solidity. You can verify this by copying the contract in Remix, calling the function with an index of 299 and checking that the tx doesn't revert. Then do the same, but with an index of 300, and the tx reverts.
- B: Unchecked-blocks do not cause division-through-zero occurrences to be ignored. Easy to verify in Remix too.
- C: Unsafe downcasting will indeed not revert, but not because of the use of unchecked, but because the down-cast is made explicitly. If you remove the unchecked block, and the result of is greater than 255, execution still succeeds (silently overflowing the result). Again, you can check this in Remix.
Question 5 of 8
contract Example {
function callAndRevert(address target, bytes calldata payload) external {
assembly (“memory-safe”) {
call(gas(), target, callvalue(), add(payload, 32), payload, 0, 0)
returndatacopy(0, 0, returndatasize())
revert(0, returndatasize())
}
}
}
Which of the statement(s) is/are true about the above contract when trying to compile using solc 0.8.17 without optimizations?
- A. Compilation fails because
callvalue()
is used but the function is not payable - B. Compilation fails because the assembly block is marked
“memory safe”
but memory can potentially be read and written - C. Compilation succeeds, although the compiler emits a warning due to the unused return value of
call
- D. Compilation succeeds without any warnings
- E. None of the above
Solution
Correct is E.
- A: A function not being payable simply means that there's no check whether value was sent when the function is called. But checking
callvalue
/msg.value
is still allowed. - B: The “memory safe” flag (opens in a new tab) merely tells the compiler that it may rely on the assembly block respecting Solidity's memory layout and therefore being able to apply certain optimizations. Compilation won't fail from the assembly-block reading or writing memory in any way.
- C/D: Doesn't compile because the
payload
variable is a data element and can't be accessed like that. Instead one has to use its.offset
or.length
attributes. A second compilation error is that call returns a value which is not used, and it needs to be either assigned or discarded.
Question 6 of 8
contract Example {
function callAndRevert(address target, bytes memory payload) external payable {
assembly (“memory-safe”) {
let result := call(gas(), target, callvalue(), add(payload, 32), payload, 0, 0)
returndatacopy(0, 0, returndatasize())
revert(0, returndatasize())
}
}
}
A developer does some minor changes on the previous contract, resulting in the above contract. Which of the statement(s) is/are true when calling callAndRevert
?
- A. The transaction reverts before the external call if the bytes in the
payload
parameter are not properly ABI-encoded - B. The transaction reverts before the final
revert
operation whentarget
is an account without code - C. The transaction reverts before the final
revert
operation when execution in thetarget
account reverts - D. The transaction reverts before the final
revert
operation when the callee runs out of gas - E. None of the above
Solution
Correct is E.
- A: The
payloads
contents are not checked for their ABI-compatibility before the call is made as it may be non-ABI encoded contents that the target is expecting. It's simply thatbytes
could be anything, it may be something ABI-encoded, it may be something completely different, like the binary data of an image. - B: No,
CALL
ing a contract without any code directly will always succeed. Thisassembly
-block skips the check contract-size check that Solidity would do before attempting to call thetarget
. - C: If the execution in the
target
account reverts, this won't revert the entire transaction but only the actions done within the context of thatCALL
. Whether a revert happened can be known by checking the boolean value returned by executing theCALL
opcode. The revert is not bubbled-up to the caller context, so execution continues. - D: If the callee runs out of gas, the revert is again not bubbled-up. Furthermore, one 64th of gas is always put aside for the caller. Even if the callee uses up all of the gas they had available, the caller should still have some gas left to execute a few more operations and it may be enough to finish the transaction without reverting.
Question 7 of 8
contract Example {
function callAndRevert(address target, bytes memory payload) external payable {
assembly (“memory-safe”) {
let result := call(gas(), target, callvalue(), add(payload, 32), payload, 0, 0)
returndatacopy(0, 0, returndatasize())
revert(0, returndatasize())
}
}
}
Continuing with the same contract, what are the consequences of annotating the assembly
block as “memory-safe”
?
- A. It’s a good practice to help auditors, and never affects the compiler’s behavior
- B. The bytes in
payload
are checked to be ABI-encoded before storing them in memory - C.
returndatacopy
will revert ifreturndatasize
is greater than zero, due to writing to Solidity’s reserved memory space - D. Return bomb attacks are prevented due to safety checks introduced by the compiler on the size of the returned data copied to memory
- E. None of the above
Solution
Correct is E.
- A: No, it's a flag telling communicating to the compiler that the
assembly
-block respect's Solidity's memory layout and it affects the compiler's use of optimizations. - B: No, since
bytes
are allowed to be any sort of value and are not necessarily always ABI-encoded. They are simply copied into memory without checks until an attempt to actually decode them is made. - C: No, writing into Solidity's reserved memory space will not cause a revert. There's no code checking this and the EVM is unaware of Solidity's expectations around memory. There's nothing preventing this.
- D: Solidity does not add any safety checks to
assembly
-blocks. The responsibility lies with the author.
Question 8 of 8
Alice and Bob have the exact same Solidity contract. Each one compiles the contract in their machines with the same compiler version and settings (e.g., running solc Example.sol —bin
) . Then they compare the resulting outputs. Which of the following statement(s) is/are true?
- A. The output is the same, because the contract and compiler version and settings are exactly the same
- B. The output is different, because Mercury and Venus are not aligned at the moment of compilation
- C. The output is different, because by default the bytecode includes extra non-executable bytes that depend on each one’s compilation environment
- D. The output is different, but they could use a compiler flag that would make solc produce the same outputs everywhere
Solution
Correct is C, D.
By default, solc includes the metadata hash as part of the output bytecode. This hash is dependent on the compilation environment (for example, path and filename). Therefore the output will be different between Alice and Bob. The compiler flag that would solve the issue is --metadata-hash none
, which removes the hash from the output.
You can verify this with two equal contracts in the same directory with different filenames. Compile them as usual, and compare the bytecode, noting that it's different. Then compile again but with the flag, and you'll see the output now is the same.
Lesson: Solidity is weird.