Command Palette

Search for a command to run...

Delegatecall — Storage Layout Collision

A proxy delegates all calls to an implementation contract. Both contracts declare state variables starting at slot 0, but in different order. The implementation's initialize() writes to slot 0 — which holds the proxy's owner — clobbering it and handing control to an attacker.

~7 min read

Available

Delegatecall is Ethereum's mechanism for proxy patterns: the implementation contract's code runs in the proxy's storage context. But if the proxy and implementation declare state variables in different orders, a write in the implementation corrupts a different variable in the proxy. This lab has a proxy with owner at slot 0 and an implementation that writes to slot 0 for a different variable.

1. How delegatecall works

Solidity state variables are stored in sequential 32-byte slots starting at slot 0. When contract A calls contract B with delegatecall, B's code executes but reads and writes A's storage. B uses slot offsets relative to its OWN variable declarations — not A's. If B declares `uint256 counter` at slot 0, and A has `address owner` at slot 0, then B.counter++ overwrites A.owner.

This is the foundational property of proxy patterns: the proxy delegates all logic to an implementation, and the implementation manipulates the proxy's storage. The proxy's slot layout is the ground truth. The implementation's slot layout must perfectly mirror the proxy's, or writes in the implementation corrupt the proxy.

Proxy patterns that use delegatecall (TransparentUpgradeableProxy, UUPS, Beacon) are in every major DeFi protocol. Storage layout mismatches are a recurring class of critical vulnerability. An auditor must always draw the slot table for both contracts and verify alignment.

2. The bug — slot 0 clobbering

The VulnerableProxy declares owner at slot 0 and implementation at slot 1. The VulnerableImplementation declares initialized (bool) at slot 0 and value at slot 1. When the proxy delegatecalls the implementation's initialize()() function, the implementation sets initialized = true at slot 0. That slot holds the proxy's owner. Owner is overwritten with the bytes representation of true (address 0x0000...0001 or a mangled value).

After this delegatecall, the proxy's owner is no longer the deployer — it is some small address derived from the boolean encoding. The original owner loses all onlyOwner privileges. If an attacker can trigger the initialize() call, they can pass their own address as the new initialization value, taking ownership.

The EIP-1967 standard addresses this: instead of sequential slots, proxy admin variables are stored at pseudo-random keccak256-derived slots far from slot 0, making accidental collision with implementation variables essentially impossible.

3. The attack — trigger initialize via delegatecall

The VulnerableProxy fallback function delegatecalls all calls to the implementation. initialize()() in the implementation is callable by anyone (or has insufficient guards). Attack pseudocode:

// Proxy.owner = deployer at slot 0
// VulnerableImplementation.initialized (bool) also at slot 0
// Attacker calls: proxy.initialize(attackerAddress)
// Fallback delegatecalls implementation.initialize(attackerAddress)
// Implementation sets slot 0 = true (initialized flag)
// BUT slot 0 in proxy context = owner
// owner is now overwritten → attacker controls proxy

After the collision, the attacker calls upgradeImplementation(maliciousImpl) (now unguarded since they are 'owner'), replacing the implementation with their own contract. Full proxy takeover.

Attack timeline

  1. 1

    Deploy

    Proxy is deployed with deployer as owner (slot 0). Implementation is deployed separately. Proxy stores implementation address at slot 1.

  2. 2

    Identify slot collision

    Attacker reads both contracts. Notes that VulnerableImplementation.initialized occupies slot 0, same as VulnerableProxy.owner. Realizes that calling initialize() via the proxy will overwrite owner.

  3. 3

    Call initialize via proxy

    Attacker calls proxy.initialize(attackerAddress). Proxy's fallback delegatecalls implementation.initialize(). Implementation writes slot 0 = true. In proxy storage context, slot 0 = owner. Owner is now mangled.

  4. 4

    Ownership hijack

    Attacker crafts a follow-up call or picks an initialization value that sets slot 0 to their own address. onlyOwner check now passes for the attacker.

  5. 5

    Full takeover

    Attacker calls upgradeImplementation(maliciousImpl). Proxy now delegates all calls to attacker's contract. All funds and state in the proxy are under attacker control.

4. The fix — EIP-1967 storage slots

Use EIP-1967 unstructured storage slots for proxy admin variables. The slot address is derived as keccak256(string) - 1, which is pseudo-random and collision-resistant:

// EIP-1967 slot for implementation address
bytes32 constant IMPLEMENTATION_SLOT =
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

// EIP-1967 slot for admin address (proxy owner)
bytes32 constant ADMIN_SLOT =
    0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

function _getAdmin() internal view returns (address admin) {
    assembly { admin := sload(ADMIN_SLOT) }
}

function _setAdmin(address newAdmin) internal {
    assembly { sstore(ADMIN_SLOT, newAdmin) }
}

Use OpenZeppelin's TransparentUpgradeableProxy or UUPS pattern — both use EIP-1967 slots. These are battle-tested and do not require manual slot management. Never write your own proxy without using a proven slot layout.

When auditing upgradeable contracts, draw the storage layout table for BOTH the proxy and all implementation versions. Check that new implementation versions do not reorder existing slots (they can only append). Storage layout incompatibility between versions is also a critical finding.

5. What to look for as an auditor

  • Find every delegatecall in the codebase. For each, list the caller's slot layout and the callee's slot layout. Are they identical? If not, flag every slot that differs.
  • Proxy patterns (Transparent, UUPS, Beacon): check that admin variables (owner, pendingOwner, implementation) use EIP-1967 slots — not sequential slots starting at 0.
  • Upgradeable contracts: verify that adding state variables in a new implementation version does not reorder existing variables. New variables must be appended at the end, or use a storage gap.
  • Look for unguarded initialize()() functions on logic contracts. If the logic contract can be initialized directly (not via proxy), an attacker can set themselves as owner.
  • Libraries called via delegatecall: even a simple `counter++` in a library can clobber a critical variable if the library's slot 0 matches the caller's owner slot.
  • Inheritance order in Solidity affects slot layout. Contract A inheriting B then C assigns B's variables first, then C's, then A's own. Changing inheritance order is a storage layout collision.

With the slot collision model in hand, open the Hunt tab. Draw the slot layout for both contracts, identify the collision, and describe how an attacker exploits it.

← Back to skill tree