Ethernaut#6 - Delegation

Ethernaut #6 - Delegation

#6

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

Things that might help

  • Look into Solidity’s documentation on the delegatecall low level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope.
  • Fallback methods
  • Method ids
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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;
}
}
}

Contract Breakdown

Let us breakdown the contracts to understand what each function and piece of code does. This time we have two contracts: Delegate and Delegation, both compiled with solidity version ^0.8.0.

Delegate Contract

Variables
1
address public owner;

A public variable owner of type address.

Constructor
1
2
3
constructor(address _owner) {
owner = _owner;
}

Takes an address _owner and sets it as the owner.

Functions
1
2
3
function pwn() public {
owner = msg.sender;
}

The pwn function simply sets the owner to msg.sender. Pretty straightforward.

Delegation Contract

Variables
1
2
address public owner;
Delegate delegate;

It has its own owner variable and a reference to a Delegate contract instance.

Constructor
1
2
3
4
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

The constructor takes the address of a deployed Delegate contract and stores a reference to it. It also sets owner to msg.sender.

Functions
1
2
3
4
5
6
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}

The fallback function is the interesting part. When any call is made to the Delegation contract that doesn’t match an existing function, the fallback is triggered. It takes whatever msg.data was sent and forwards it as a delegatecall to the Delegate contract.

Solution

To understand this exploit we need to understand how delegatecall works.

delegatecall executes the code of the target contract but in the context of the calling contract. This means it uses the calling contract’s storage, msg.sender, and msg.value. So when Delegate‘s pwn() function runs via delegatecall, owner = msg.sender modifies the owner in Delegation‘s storage, not Delegate‘s storage.

So our attack plan is simple: we need to trigger the fallback function on Delegation with msg.data set to the function selector of pwn().

The function selector is the first 4 bytes of the keccak256 hash of the function signature. For pwn(), the selector is abi.encodeWithSignature("pwn()").

Steps to pass:

  • In Remix, load the Delegation contract at the instance address from Ethernaut.
  • We need to send a transaction to the Delegation contract with the calldata set to the function selector of pwn().
  • We can do this through the low level Transact interaction in Remix. Set the calldata to 0xdd365b8b which is the function selector for pwn(). You can compute this with web3.utils.keccak256("pwn()") and take the first 4 bytes.
  • Alternatively from the Ethernaut console: await contract.sendTransaction({data: web3.utils.keccak256("pwn()")})

When the transaction goes through, the fallback function fires, delegatecalls to Delegate.pwn(), and since it runs in the context of Delegation, it sets Delegation‘s owner to our address.

delegatecall is powerful but extremely dangerous. When using it, always be careful about storage layout and who can control the target address. Many real-world hacks have exploited delegatecall to overwrite critical storage variables like ownership.