Command Palette

Search for a command to run...

Storage Collision — Incompatible Slot Layouts

Two contracts share storage via delegatecall but declare variables in different orders. A write to counter in the library lands on owner in the caller. An attacker who can increment counter enough times overwrites owner with their own address.

~7 min read

Available

When two contracts share storage via delegatecall but declare their state variables in different orders, a write to one variable in the implementation lands on a completely different variable in the caller. This lab has a Delegator contract with owner at slot 0 and a LibCounter with counter at slot 0 — a collision that lets an attacker overwrite ownership.

1. Storage slots and independent contracts

Every contract manages its own sequential storage slots. When Contract A delegatecalls Contract B, B's code runs but accesses A's storage. B uses slot indices based on B's variable declarations: if B.counter is at slot 0, then B.counter = 42 writes 42 to A's slot 0 — which might hold A.owner.

The collision becomes critical when the clobbered variable controls security. Overwriting an address-typed variable (owner, admin) with a uint256 value sets it to an unexpected address — likely a small integer that any attacker can control by choosing the right counter value.

Storage collision is subtler than a simple proxy bug: the two contracts may have been written by different teams with no awareness of each other's layout. The delegatecall is the bridge that makes them share storage, and the collision only becomes apparent when you draw the slot table.

2. Diagnosing collisions — the slot table

The audit workflow: list every state variable of Contract A in declaration order with its slot number. List every state variable of Contract B in declaration order with its slot number. Compare slot by slot. Any slot where A and B have different variable types or semantics is a collision.

In this lab: Delegator slot 0 = address owner, slot 1 = uint256 balance. LibCounter slot 0 = uint256 counter, slot 1 = address admin. Slot 0 collision: owner (address) vs counter (uint256). Slot 1 collision: balance (uint256) vs admin (address). Both are critical.

The worst collision is an address at a slot where the implementation writes a user-controlled integer. The integer 1 in a uint256 slot becomes address(1) — or 0x0000...0001. An attacker who can call increment() enough times lands on address values they control.

3. The attack — increment overwrites owner

The Delegator contract exposes delegateIncrement()() which delegatecalls LibCounter.increment()(). Each call adds 1 to slot 0. Slot 0 in the Delegator is owner. Attack pseudocode:

// Delegator.owner = 0xDEPLOYER at slot 0 (as address)
// LibCounter.counter at slot 0 (as uint256)
// delegateIncrement() sets slot 0 += 1 in Delegator's storage
// address is treated as uint160 padded to uint256
// After enough increments, slot 0 wraps to an address the attacker controls

More directly: if the implementation's initialize or setAdmin function writes a caller-controlled address to slot 1, and Delegator.balance is at slot 1, the balance can be set to an arbitrary large value (draining the contract via withdraw).

Attack timeline

  1. 1

    Read slot layout

    Attacker reads both contract sources. Draws the slot table. Identifies that Delegator.owner (slot 0, address) collides with LibCounter.counter (slot 0, uint256).

  2. 2

    Call delegateIncrement

    Attacker calls delegator.delegateIncrement() repeatedly. Each call increments slot 0 in Delegator's storage. owner transitions through address values.

  3. 3

    Align to own address

    Attacker calculates how many increments are needed for slot 0 to hold their own address (address is uint160 — attacker can compute the delta). Calls the function the exact number of times.

  4. 4

    Owner takeover

    After the final increment, Delegator.owner == attacker's address. The onlyOwner modifier now passes for the attacker.

  5. 5

    Drain

    Attacker calls withdraw(balance) as the new owner. All ETH in the Delegator is transferred to the attacker.

4. The fix — aligned layout or EIP-1967 slots

Ensure both contracts share an identical base layout. The simplest way: have the implementation inherit from the caller (or a common base contract). This guarantees slot alignment:

// APPROACH 1: shared base layout via inheritance
contract StorageBase {
    address public owner;   // slot 0
    uint256 public balance; // slot 1
}

contract LibCounter is StorageBase {
    // counter and admin extend from slot 2+ — no collision
    uint256 public counter; // slot 2
    address public admin;   // slot 3
}

contract SafeDelegator is StorageBase {
    address public immutable lib;
    // slot 0 and 1 match LibCounter's inherited layout
}

Alternatively, use EIP-1967 slots for any variable that must not collide: keccak256-derived pseudo-random addresses far from slot 0. This is the standard pattern for proxy-managed variables.

Add a storage gap in upgradeable contracts to reserve slots for future variables in the base: `uint256[50] private __gap;`. This prevents downstream contracts from being affected by new variables added to the base.

5. What to look for as an auditor

  • For every delegatecall, draw the slot table for both caller and callee. Flag any slot where the variable type, name, or semantics differs.
  • Focus on slots holding security-critical values: owner, admin, paused, totalSupply. A uint256 at the same slot as an address is almost always exploitable.
  • Check inheritance chains. Adding a state variable to a base contract shifts all derived contract variables down by one slot — a breaking storage layout change if there are live contracts using the old layout.
  • Upgradeable contracts that are upgraded: verify the new implementation's layout against the old one. Tools like OpenZeppelin's upgrades-core plugin enforce this at deploy time.
  • Libraries delegatecalled (not all libraries use delegatecall, but some do): check their storage usage. Even a utility library with one counter can corrupt a critical slot.
  • Cross-contract delegatecall patterns: if a contract delegates to multiple implementations (diamond pattern), each implementation's slots must be disjoint or use ERC-2535 facet-specific storage.

With the slot collision model clear, open the Hunt tab. Identify the two colliding slots, describe the attack path, and propose the fix.

← Back to skill tree