RACE #34 Of The Secureum Bootcamp Epoch∞

This is a Write-Up of RACE-34, 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 MiloTruck (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!

November 10, 2024 by patrickd


Code

All 8 questions are based on the following contract.

CrossChainMessenger.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

library SafeCall {
    function safeCall(address _to, uint256 _gas, bytes memory _data) external returns (bool _success) {
        assembly {
            _success := call(_gas, _to, 0, add(_data, 32), mload(_data), 0, 0)
        }
    }

    function unsafeCall(address _to, uint256 _gas, bytes memory _data) internal returns (bytes memory) {
        assembly {
            let _success := call(_gas, _to, 0, add(_data, 32), mload(_data), 0, 0)

            // Copy return data
            returndatacopy(0, 0, returndatasize())

            // Revert on error, otherwise return data from the call
            switch _success
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

/*
Assume this contract is part of a cross-chain messaging protocol.
A message is sent from a source to destination chain as follows:
1. Users call sendMessage() on the source chain to send a message.
2. The protocol reads the MessageSent event emitted from sendMessage() offchain.
3. The protocol calls relayMessage() on the destination chain with the same message.
*/
contract CrossChainMessenger is Ownable(msg.sender) {
    event MessageSent(Message message);

    struct Message {
        address sender;
        address to;
        uint256 gas;
        bytes data;
    }

    uint256 public constant MAXIMUM_GAS_LIMIT = 100_000;
    uint256 public constant GAS_BUFFER = 40_000;

    mapping(bytes32 => bool) public failedMessages;

    /**
     * @notice Send a message from the source chain.
     *
     * @param to   Address that receives the message on the destination chain.
     * @param gas  Minimum amount of gas sent to the receiver.
     * @param data Calldata the receiver is called with.
     */
    function sendMessage(address to, uint256 gas, bytes memory data) external {
        Message memory message = Message({sender: msg.sender, to: to, gas: gas, data: data});

        emit MessageSent(message);
    }

    /**
     * @notice Receive a message on the destination chain.
     *
     * @param message Message struct containing the sender, receiver, gas and calldata.
     */
    function relayMessage(Message calldata message) external onlyOwner {
        require(gasleft() >= (message.gas * 64) / 63 + GAS_BUFFER, "Insufficient gas");

        bool success = SafeCall.safeCall(message.to, message.gas, message.data);

        if (!success) {
            bytes32 messageHash = keccak256(abi.encode(message));
            failedMessages[messageHash] = true;
        }
    }

    /**
     * @notice Replay a message that failed execution in relayMessage().
     *
     * @param message Message struct containing the sender, receiver, gas and calldata.
     */
    function replayMessage(Message calldata message) public {
        require(msg.sender == message.sender || msg.sender == message.to, "Not sender or receiver");

        bytes32 messageHash = keccak256(abi.encode(message));
        require(failedMessages[messageHash], "Invalid message");

        require(gasleft() >= (message.gas * 64) / 63 + GAS_BUFFER, "Insufficient gas");

        bool success = SafeCall.safeCall(message.to, message.gas, message.data);
        require(success, "Message execution failed");

        failedMessages[messageHash] = false;
    }

    /**
     * @notice Forcefully replay failed messages.
     *
     * @param messages An array of message structs to execute.
     */
    function forceReplayMessages(Message[] calldata messages) external onlyOwner {
        for (uint256 i; i < messages.length; i++) {
            Message memory message = messages[i];

            bytes32 messageHash = keccak256(abi.encode(message));
            require(failedMessages[messageHash], "Invalid message");
            failedMessages[messageHash] = false;

            uint256 gasLimit = message.gas < MAXIMUM_GAS_LIMIT ? message.gas : MAXIMUM_GAS_LIMIT;
            require(gasleft() >= (gasLimit * 64) / 63 + GAS_BUFFER, "Insufficient gas");

            SafeCall.unsafeCall(message.to, gasLimit, message.data);
        }
    }
}

Question 1 of 8

The protocol facilitates cross-chain messaging by:

  • A. Transmitting messages sent from a source chain to the receiver on a destination chain through relayMessage().
  • B. Allowing messages that revert during execution in relayMessage() to be re-executed in replayMessage().
  • C. Allowing anyone to execute failed messages in replayMessage().
  • D. Allowing the protocol to execute failed messages on anyone’s behalf.
Solution

Correct is A, B, D.

This is a giveaway question meant to test the participant's understanding of the contract. From looking at the code, A, B and D are clearly true. C is incorrect according to the first require statement within replayMessage().


Question 2 of 8

In relayMessage(), the gasleft() check multiplies message.gas by 64 / 63 to:

  • A. Avoid rounding errors due to division in Solidity rounding down.
  • B. Account for the reduction in gas passed to a callee due to EIP-150.
  • C. Reserve sufficient gas for the function to complete execution after safeCall().
  • D. Provide extra gas in case the message sender specifies too little gas.
Solution

Correct is B.

The EIP-150 (opens in a new tab) standard indeed defines the "send all but one 64th" gas rule: It dictates that only 63/64 of the currently remaining gas is passed to a callee.

The question is whether the check truly accounts for the reduction in gas passed to the callee:

require(gasleft() >= (message.gas * 64) / 63 + GAS_BUFFER, "Insufficient gas");

Let's say we specify y{y} as gas amount sent during the CALL. Then the actual gas received by the callee is y6364{y}\cdot\frac{{63}}{{64}}. But we want this received amount to be exactly message.gas, let's call it x{x}, then y6364=x{y}\cdot\frac{{63}}{{64}}={x}. By solving for y{y} we will obtain the gas amount we'd have to specify for x{x} to arrive at the callee: y=x6453{y}={x}\cdot\frac{{64}}{{53}}. This matches with the require() statement.

If you're wondering whether checks like this actually happen in practice, you can search for the require statement on codeslaw (opens in a new tab) and find many projects where it's used.

This check indeed also reserves sufficient gas for the function to complete after safeCall(), but it does so using the GAS_BUFFER. This is not related to the multiplication by 64 / 63 as the question suggests.

(Answers A and D are fillers)


Question 3 of 8

In relayMessage(), the external call to message.to may receive less gas than message.gas because:

  • A. Calling safeCall() performs a delegate call, which reduces the gas remaining by 1/64.
  • B. Calling safeCall() performs an internal call, which consumes gas for a few opcodes.
  • C. Copying message from calldata to memory when calling safeCall() consumes gas.
  • D. None of the above, the gasleft() check ensures sufficient gas is always passed to the callee.
Solution

Correct is A, C.

There's an important difference (opens in a new tab) in how external functions of Solidity libraries are handled compared to internal functions: Internal functions are "inlined" into the contract calling it, in other words, the functions bytecode is added to the calling contract and the call is a JUMP. On the other hand, external functions of libraries are deployed within a separate contract. When a contract makes a call to such an external library function, it does so using the DELEGATECALL opcode.

  • Therefore safeCall() indeed performs a delegate call while unsafeCall() performs an internal call.
  • And EIP-150 applies to DELEGATECALL (opens in a new tab)s as well.
  • Additionally, calling safeCall() requires copying message (declared with data location calldata in CrossChainMessenger's functions) from calldata to memory before the call can be executed with the message data as parameter.
  • For large message.data this copy operation could indeed consume more gas than anticipated by GAS_BUFFER.

Question 4 of 8

A failed message may never be executed if:

  • A. Trying to execute the message in replayMessage() fails once.
  • B. A sender sends multiple messages with the exact same Message struct without checking if execution in relayMessage() was successful.
  • C. The message was sent with empty message.data.
  • D. None of the above, a sender can always call replayMessage() to retry a failed message.
Solution

Correct is B.

There is no way to uniquely identify two messages with the exact same sender, to, gas and data. If multiple messages with the same Message struct are sent, they will all have the same messageHash when being stored in the failedMessages mapping. Therefore, replayMessage() can only be called once, even though multiple messages failed.

A and C are filler answers: A message is only removed from the failedMessages mapping if the replay succeeded. The message.data being empty has no impact on the ability to replay messages.


Question 5 of 8

If replayMessage() could only be called by message.sender (i.e. no msg.sender == message.to condition check in require()), it would be problematic because:

  • A. There is no way to verify if the caller of sendMessage() is the address that calls replayMessage().
  • B. An EOA that calls sendMessage() on a source chain cannot call replayMessage() on the destination chain as EOAs are chain-specific.
  • C. Messages sent from contracts that directly call sendMessage() may not be replayable if execution fails.
  • D. None of the above, there are no issues with the current implementation.
Solution

Correct is C.

Unless a contract's deployment was specifically prepared (opens in a new tab) for this, it's unlikely for a contract to have the same address across different chains. In such cases, messages sent from contracts that directly call sendMessage() may not be replayable if execution fails when requiring msg.sender == message.sender alone.

With the additional msg.sender == message.to condition the receiver is able to replay the message in such cases.

A and B are filler answers: You can obviously keep track of the msg.sender values, and EOAs are not chain-specific and can be used across chains.


Question 6 of 8

Is replayMessage() susceptible to reentrancy?

  • A. No, the gasleft() check prevents any state changes from occurring in a reentrant call.
  • B. No, the failedMessages[messageHash] check prevents any reentrancy attacks.
  • C. Yes, reentrancy can be used to block the execution of future messages.
  • D. Yes, reentrancy can be used to execute a single failed message multiple times.
Solution

Correct is D.

Notice how failedMessages[messageHash] is reset to false only after safeCall(). Executing a failed message multiple times can be achieved by re-entering replayMessage() in safeCall().

A possible scenario where this could be exploited would be if the protocol receiving the message makes an unsafe external call to an untrusted contract. Such a malicious contract could then re-enter replayMessage() before safeCall() has returned, causing the protocol to be called once again with the same message. A protocol assuming that each message may only arrive once could benefit the attacker by eg. rewarding them twice.

C isn't possible and A, B are filler answers that are unrelated to reentrancy.


Question 7 of 8

When forceReplayMessages() is called, it should not revert. However, a receiver contract (i.e. message.to) can force the function to revert by:

  • A. Reverting in the function called by unsafeCall().
  • B. Returning false in the function called by unsafeCall().
  • C. Consuming all the remaining gas in the function called by unsafeCall() without reverting.
  • D. Performing a return bomb attack (i.e. return a huge chunk of data, causing forceReplayMessage() to consume more gas than the block gas limit).
Solution

Correct is A.

While safeCall() returns a boolean depending on whether the CALL was successful, the unsafeCall() function will "bubble up" the callee's revert causing forceReplayMessages() to revert. If the callee returns false instead of reverting it is simply treated as returned data.

Assuming that forceReplayMessage() is called with sufficient gas to execute all messages, the gas limit on each message prevents any execution from consuming all remaining gas in forceReplayMessages().

D is a trick option. Although unsafeCall() reads an unbounded amount of data from the called function, only a maximum of 100,000 gas is passed to the called function. Due to the memory expansion cost, the function will not be able to create a huge chunk of data in memory to be returned, hence a return bomb attack is not possible.


Question 8 of 8

When forceReplayMessages() is called with multiple messages, it will:

  • A. Execute all messages in the messages array.
  • B. Always revert when trying to execute the first message.
  • C. Execute all messages in the messages array and revert after the last call.
  • D. Only execute the first message in the messages array.
Solution

Correct is D.

At a first glance, it may appear that it is correctly looping through all messages, but the problem is that unsafeCall() is not making use of a regular (Solidity) return statement, but rather executes the RETURN opcode which will exit the currently executed contract and not simply return to the calling function. Remember that, unsafeCall() being an internal library function, it is not externally-called but instead inlined into the CrossChainMessenger contract. Therefore, when forceReplayMessages() calls unsafeCall() it will JUMP to the function's bytecode which will then terminate execution with RETURN instead of jumping back to forceReplayMessages()'s bytecode.

A PoC demonstrating this can be found here: https://gist.github.com/MiloTruck/837ecb49fe18901d70bf03241548768b (opens in a new tab)

(Answers B and C are fillers)