👥 Delegation - The Delegatecall Takeover
Challenge Overview
The goal of this level is to claim ownership of the Delegation contract.
The Catch: We are given a Delegation contract that does not directly expose any ownership modification functions. However, it references a helper contract called Delegate and forwards unrecognized calls to it.
Technical Analysis
Vulnerability: Uncontrolled Delegatecall to Target Contract
The Delegation contract utilizes a fallback function that forwards raw input calldata to the Delegate contract:
fallback() external { (bool result,) = address(delegate).delegatecall(msg.data); if (result) { this; }}The Root Cause
The vulnerability stems from the use of delegatecall on arbitrary user data (msg.data):
delegatecallContext: Unlikecall,delegatecallruns the code of the target contract (Delegate) inside the context of the calling contract (Delegation). This means thatmsg.sender,msg.value, and critically, the storage space ofDelegationare used during execution.- Storage Layout Alignment: Both contracts define
ownerin storage slot 0:Delegateslot 0:ownerDelegationslot 0:owner
- Target Function: The
Delegatecontract has a publicpwn()function:function pwn() public {owner = msg.sender;}
When we invoke Delegation with the calldata keccak256("pwn()"):
- The fallback function in
Delegationcaptures the call. - It delegatecalls
Delegatewithmsg.datacontaining the selector forpwn(). - The
Delegatecontract’spwn()logic executes inside the context ofDelegation. - It sets slot 0 (
ownerinDelegation) tomsg.sender(the player EOA).
The Exploit: The Multi-Step Solution
- Calculate the Method Selector: Calculate the first 4 bytes of
keccak256("pwn()")(which is0xdd365b8b). - Send Raw Transaction: Send a transaction to the
Delegationcontract with empty value and the calldata set to0xdd365b8b. - Verify Takeover: Check that
Delegation.ownerhas been updated to the player address.
Proof of Concept (PoC)
Below is the JavaScript console walkthrough:
// Calculate the selector of pwn() and send a transaction triggering the fallbackawait sendTransaction({ from: player, to: contract.address, data: web3.utils.sha3("pwn()").substring(0, 10) // 0xdd365b8b});
// Verify ownership transitionconst currentOwner = await contract.owner();console.log("Is player owner?", currentOwner === player);Auditor’s Lessons
- Delegatecall is Dangerous:
delegatecalleffectively hands over absolute root privileges of your contract’s storage to the target. Never usedelegatecallwith untrusted target addresses or permit arbitrary calldata forwarding without strict access control. - Storage Layout Collision: When using proxy patterns or delegatecalls, you must ensure the storage layouts of both contracts match perfectly. A collision can lead to unintended state corruption or ownership hijacking.
- Validate Selector Execution: If forwarding calls via fallback, restrict which function selectors are permitted to prevent calling critical initialization or administrative functions.
Remediation
Avoid using delegatecall for generic or untrusted proxy setups. If an upgradeable proxy pattern is required, use industry-standard proxy libraries (such as OpenZeppelin’s proxy contracts) and restrict storage access using explicit access modifiers:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
contract SecureDelegation { address public owner; address public delegate;
constructor(address _delegate) { delegate = _delegate; owner = msg.sender; }
modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; }
// Direct, explicit calls instead of delegating raw inputs function upgradeDelegate(address _newDelegate) external onlyOwner { delegate = _newDelegate; }}