Ethernaut Motorbike Solution (After Dencun Upgrade)

Following the Dencun upgrade, the selfdestruct opcode behavior has been modified (EIP-6780). This change means that the selfdestruct no longer removes the contract code from the blockchain, rendering the Motorbike challenge seemingly unsolvable. However, there is a new approach to tackle this, which we will outline in this write-up.

The solution before the upgrade

Ethernaut's motorbike has a brand-new upgradeable engine design.

Would you be able to selfdestruct its engine and make the motorbike unusable?

Analysis

Our goal is to selfdestruct the Engine. Let's look at it first.

There are only 2 external/public functions: initialize and upgradeAndCall. Since the upgradeAndCall function looks more interesting, let's examine it first.

Engine

upgradeAndCall

The function first calls _authorizeUpgrade(), which checks if msg.sender == upgrader. Therefore, we need to control upgrader to make it ourselves. There is only one place where we can control it: initialize. Let's examine it.

// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
    _authorizeUpgrade();
    _upgradeToAndCall(newImplementation, data);
}

// Restrict to upgrader role
function _authorizeUpgrade() internal view {
    require(msg.sender == upgrader, "Can't upgrade");
}

initialize

The initialize function has an initializer modifier, which checks if the contract is initialized yet. In our case, it is not, so we can make the upgrader ourselves. Let us return to the upgradeAndCall function.

function initialize() external initializer {
    horsePower = 1000;
    upgrader = msg.sender;
}
contract Initializable {
  bool private initialized;
  // [...]
  modifier initializer() {
    require(_initializing || _isConstructor() || !_initialized, "Initializable: contract is already initialized");
  }
  // [...]
}
# check if it's initialized or not
> cast storage $ENGINE -r $RPC 0x0
0x0000000000000000000000000000000000000000000000000000000000000000

upgradeAndCall

The upgradeToAndCall then calls _upgradeToAndCall, which delegates the call to newImplementation, which we control. Therefore, if we set newImplementation to a contract that will call selfdestruct, the challenge will be solved.

// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
    _authorizeUpgrade();
    _upgradeToAndCall(newImplementation, data);
}

// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(address newImplementation, bytes memory data) internal {
    // Initial upgrade and setup call
    _setImplementation(newImplementation);
    if (data.length > 0) {
        (bool success,) = newImplementation.delegatecall(data);
        require(success, "Call failed");
    }
}

The solution after the upgrade

The selfdestruct function will no longer remove the contract code after the upgrade, so the above solution will not work.

If we take a look at EIP-6780:

This EIP changes the functionality of the SELFDESTRUCT opcode. The new functionality will be only to send all Ether in the account to the target, except that the current behaviour is preserved when SELFDESTRUCT is called in the same transaction a contract was created.

So, we can still delete the Engine contract code if it is within the same transaction as its creation.

When is the Engine contract code created? It is created when you hit the Get new instance button. But how can we make it to be in the same transaction as our solve script? We can use a contract!

I am going to create a contract that does the following:

  1. Create the Motorbike level instance.
  2. Solve it using the above solution.
  3. Submit the instance.

To do so, we need to inspect Ethernaut's code.

Ethernaut

First, it will check if the level exists. In our case, it is Motorbike Factory. Next, it will call _level.createInstance and return the instance address, which we need to keep track of.

Our objective now is to obtain the instance address. The instance is not returned from the createLevelInstance function, so we need to find an alternative method. It's important to note that all of these actions need to be carried out on-chain within a single transaction. The line emittedInstances[instance] is not relevant to us. Although the last line emit LevelInstanceCreatedLog will emit the instance address, we can't access the event log on-chain as explained in the document. Therefore, the only remaining option is statistics.createNewInstance. Let's investigate further.

function createLevelInstance(Level _level) public payable {
    // Ensure level is registered.
    require(registeredLevels[address(_level)], "This level doesn't exists");

    // Get level factory to create an instance.
    address instance = _level.createInstance{value: msg.value}(msg.sender);

    // Store emitted instance relationship with player and level.
    emittedInstances[instance] = EmittedInstanceData(msg.sender, _level, false);

    statistics.createNewInstance(instance, address(_level), msg.sender);

    // Retrieve created instance via logs.
    emit LevelInstanceCreatedLog(msg.sender, instance, address(_level));
}
Events

These logs are associated with the address of the contract that emitted them, are incorporated into the blockchain, and stay there as long as a block is accessible (forever as of now, but this might change in the future). The Log and its event data are not accessible from within contracts (not even from the contract that created them).

https://docs.soliditylang.org/en/latest/contracts.html#events

Look at playerStats[player][level] = LevelInstance(instance,...). It looks promising! But playerStats is private, so we cannot access it on-chain. Therefore, it seems impossible to get the instance address. Is there any other way to obtain the instance address on-chain?

mapping(address => mapping(address => LevelInstance)) private playerStats;
// [...]
function createNewInstance(address instance, address level, address player)
    external
    onlyEthernaut
    levelExistsCheck(level)
{
    if (!doesPlayerExist(player)) {
        players.push(player);
        playerExists[player] = true;
    }
    // If it is the first instance of the level
    if (playerStats[player][level].instance == address(0)) {
        levelFirstInstanceCreationTime[player][level] = block.timestamp;
    }
    playerStats[player][level] = LevelInstance(
        instance,
        false,
        block.timestamp,
        0,
        playerStats[player][level].timeSubmitted.length != 0
            ? playerStats[player][level].timeSubmitted
            : new uint256[](0)
    );
    levelStats[level].noOfInstancesCreated++;
    globalNoOfInstancesCreated++;
    globalNoOfInstancesCreatedByPlayer[player]++;
}

Calculate address ourselves

The address of the deployed contract is actually predictable! Referring to the Ethereum Yellow Paper, section 7 "Contract Creation", it is stated:

The address of the new account is defined as being the rightmost 160 bits of the Keccak-256 hash of the RLP encoding of the structure containing only the sender and the account nonce.

There is a pre-existing MIT-licensed Solidity code available for us to use in predicting the address!

The computeCreateAddress(address deployer, uint256 nonce) function takes two parameters: deployer, which we already have, and nonce. According to the post, the nonce cannot be obtained on-chain. While this is technically correct, it is worth noting that the nonce can actually be inferred.

Get nonce on-chain

One approach is to iterate through all possible nonces and generate addresses based on them until we find an address that is not a contract (refer to the code below). However, if the nonce is large, this method will require a significant amount of gas. Alternatively, we can obtain the nonce off-chain and then pass it to the contract.

function getNonce(address _addr) public view returns (uint256 nonce) {
    for (; ; nonce = nonce + 1) {
        address contractAddress = computeCreateAddress(_addr, nonce);
        if (!isContract(contractAddress)) return nonce;
    }
}
function isContract(address _addr) public view returns (bool) {
    // https://ethereum.stackexchange.com/questions/15641/how-does-a-contract-find-out-if-another-address-is-a-contract
    uint32 size;
    assembly {
        size := extcodesize(_addr)
    }
    return (size > 0);
}
function computeCreateAddress(address deployer, uint256 nonce) public pure returns (address);

We got all we needed to solve the challenge! Let's start exploiting!

Exploitation

See contracts/Exploit.sol. My successful exploitation tx is here. The submitInstance is called here.

Note that we should not call submitInstance() within the same transaction as solve(). This is because the level checks if it's solved using Address.isContract. See this commit for more information on Address.isContract.

function validateInstance(address payable _instance, address _player) public override returns (bool) {
    _player;
    return !Address.isContract(engines[_instance]);
}
* Furthermore, `isContract` will also return true if the target contract within
* the same transaction is already scheduled for destruction by `SELFDESTRUCT`,
* which only has an effect at the end of a transaction.