Web3 & smart contracts
Verified by ROOTVIEL13 minProxy Storage Collisions
An upgradeable proxy and its implementation contract disagree about what lives at a given storage slot, so a write meant for one variable silently overwrites another — corrupting ownership, balances, or the implementation address itself — most often after an upgrade changes the layout.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
- Analysis family
- Rootchain
- CWE
- CWE-668, CWE-843
- OWASP
- SC05
Definition
A proxy storage collision is a state-corruption flaw in the delegatecall-based proxy pattern. The proxy holds the contract state; the implementation (logic) contract holds the code. When the implementation executes via delegatecall, it reads and writes the proxy’s storage using its own storage layout — the compiler assigns each state variable a slot number based on declaration order. If two pieces of code that run against the same storage disagree about which variable owns slot N, a write to one is a write to the other.
Two distinct sub-cases exist. Proxy-vs-implementation collision: a variable the proxy declares (for example an admin or implementation pointer) lands on the same slot as a variable the implementation declares, so logic calls clobber proxy control data. Implementation-vs-implementation collision: an upgrade reorders, inserts, or changes the type of a variable, so the new code interprets the slots left behind by the old code as entirely different fields.
Context
The pattern is ubiquitous because Ethereum contract code is immutable once deployed; upgradeability is achieved by separating storage (proxy) from logic (implementation) and swapping the implementation address. Any system using transparent proxies, UUPS proxies, beacon proxies, or diamonds (EIP-2535) carries this hazard, and the risk concentrates at upgrade time, when a new layout meets old storage.
- ▸Transparent and UUPS proxies where the proxy itself holds admin/implementation variables alongside delegated logic.
- ▸Upgradeable token or vault contracts whose storage layout evolves across versions.
- ▸Diamond proxies (EIP-2535) where multiple facets share one storage space and must coordinate layout.
- ▸Libraries used via delegatecall, whose declared variables land in the caller’s storage.
Technical fundamentals
The EVM stores contract state in a flat key→value map of 256-bit slots. Solidity assigns statically-sized state variables to sequential slots starting at 0, in declaration order; mappings and dynamic arrays reserve a slot as a seed and hash into the wider space. There is no runtime type information: a slot is 32 raw bytes, and whichever code runs decides how to interpret them. delegatecall runs the callee’s bytecode in the caller’s storage context, so the implementation’s slot assignments are applied to the proxy’s storage.
The fix encoded in EIP-1967 is to place proxy-owned pointers (implementation, admin, beacon) at deterministic pseudo-random slots — for example the implementation slot keccak256("eip1967.proxy.implementation") - 1 — chosen so no normal sequential variable allocation can land on them. This is "unstructured storage": proxy metadata is deliberately scattered far from slot 0 so it cannot collide with the implementation’s linear layout.
- Storage slot
- A 256-bit addressable cell of contract state; Solidity assigns variables to slots by declaration order.
- delegatecall
- A call that executes the callee’s code against the caller’s storage, balance, and address context — the mechanism behind proxies.
- Unstructured storage (EIP-1967)
- Placing proxy pointers at hashed, non-sequential slots so implementation variables cannot collide with them.
- Storage gap
- A reserved array (e.g. uint256[50] __gap) in a base contract so future variables can be added without shifting a child’s slots.
Root cause
The defect is that slot assignment is positional and untyped, while the proxy pattern makes two independently-compiled contracts share one storage space. Nothing in the EVM enforces that the layout the storage was written with matches the layout it is later read with. A collision arises whenever (a) the proxy declares its own control variables in the same linear space the implementation uses, or (b) an upgrade changes the implementation’s layout without preserving the ordering and types of every pre-existing variable.
It is a design/alignment defect, not a single buggy line: each contract is internally correct, but their layouts are mutually inconsistent. The harm is silent because the EVM raises no error — it simply writes bytes to a slot whose meaning has changed.
How the attack works, step by step
Collisions can be triggered accidentally by an upgrade or exploited deliberately. The deliberate path:
- 1The attacker identifies a proxy whose control variable (e.g. an implementation or admin pointer stored at a low sequential slot) overlaps a slot the implementation treats as a writable user field.
- 2The attacker calls an ordinary implementation function that writes to that user field — a setter, a balance update, a mapping write that resolves to the same slot.
- 3The write lands on the slot the proxy uses for control, overwriting the implementation address or admin with an attacker-chosen value.
- 4On the next call, the proxy delegatecalls to the attacker’s address, or treats the attacker as admin.
- 5The attacker now controls all logic the proxy executes and all state it holds.
The accidental path is often more damaging in aggregate: an upgrade inserts a new variable near the top of the layout, every subsequent variable shifts by one slot, and balances, allowances, or role mappings are now read from the wrong slots — corrupting the entire contract’s state the moment the new code runs.
Vulnerable example
A synthetic, illustrative proxy that stores its implementation pointer at a sequential slot, colliding with the first variable of the logic contract:
synthetic — do not use; illustrates the flaw// SPDX-License-Identifier: MIT // UNSAFE: proxy control variable shares the linear slot space with logic. pragma solidity ^0.8.20; contract NaiveProxy { // slot 0 — the pointer the proxy depends on address public implementation; constructor(address impl) { implementation = impl; } fallback() external payable { address impl = implementation; // read slot 0 assembly { calldatacopy(0, 0, calldatasize()) let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } } contract LogicV1 { // slot 0 — COLLIDES with NaiveProxy.implementation address public owner; function setOwner(address a) external { owner = a; } // writes slot 0 ! }
Calling setOwner through the proxy writes the attacker’s address into slot 0 — which the proxy reads as implementation. The next call delegatecalls to attacker-controlled code.
Impact
- ▸Integrity: silent, total corruption of contract state when an upgrade misaligns the layout — balances, allowances, and role mappings read from wrong slots.
- ▸Authorization: overwriting the admin or owner slot hands control of the proxy to an attacker.
- ▸Code control: overwriting the implementation pointer redirects all delegatecalls to attacker-chosen logic — effectively arbitrary code execution over the contract’s state and funds.
- ▸Irreversibility: corruption may be unnoticed until funds move; a bricked proxy can be unrecoverable if the upgrade mechanism itself is clobbered.
Detection
Static layout comparison is the primary detector: compile both the old and new implementation (and the proxy) and compare the computed storage layout slot-by-slot, flagging any variable whose slot, type, or offset changed, and any proxy control variable sharing the implementation’s linear space. Solidity emits this layout; tools built on it (OpenZeppelin Upgrades plugins, solc --storage-layout) automate the diff. Differentiate: a static layout diff that shows a changed slot is a static finding of a precondition; it becomes a confirmed defect only when a concrete write is shown to corrupt a specific field.
Dynamic detection re-runs state-changing calls on a fork and checks whether a documented invariant (owner unchanged by a balance update, implementation pointer stable across logic calls) still holds. A clean run over the paths you exercised does not prove the absence of a collision on an unexercised path.
How autonomous agents investigate it
An autonomous agent begins by reconstructing both storage layouts — treating the compiler’s storage-layout output as ground truth rather than reading declaration order by eye — and aligning them slot-by-slot across the upgrade boundary. It flags collisions in two classes: proxy-metadata vs logic variable, and old-layout vs new-layout drift. It then cross-checks whether EIP-1967 slots are used for proxy pointers and whether base contracts reserve storage gaps.
Each candidate collision is a hypothesis, not a conclusion. The agent proposes a concrete call sequence that would write to the suspect slot and predicts the corrupted field. Until that sequence is executed and the corruption observed, the result is a SIGNAL — a flagged layout mismatch — not a FINDING. The agent must not report "the proxy is safe" from a clean layout diff; the honest output of an unexercised path is UNKNOWN.
How Rootchain analyzes it
- Support level
- Verified — proxy storage collision is a classic, deterministically checkable class; Rootchain both diffs layouts statically and reproduces corruption dynamically.
- Evidence model
- Evidence-first. A layout diff is a signal; a Finding requires executing a real write on a disposable local chain or local fork and observing the targeted slot change meaning.
- Declared property
- “A logic-contract write must not alter the proxy’s implementation/admin pointer” and “every pre-existing variable keeps its slot, offset and type across an upgrade.”
Rootchain composes a candidate exploit with AI — a sequence of ordinary calls predicted to land on the colliding slot — then deploys old and new implementations behind the proxy on a local blockchain or a local fork and executes that sequence. A deterministic, AI-free verifier reads the raw slot before and after and checks it against the declared invariant: did a logic write change the implementation pointer, or did a renamed field inherit stale bytes? Only an observed, reproduced corruption is graded a Finding. There is no live-network attack mode; proof is obtained only against disposable local state.
A clean verifier run means the collision was not reproduced on the paths exercised — UNKNOWN, never "safe". Maturity of the dynamic reproduction varies by proxy flavour (diamonds and beacon proxies are harder to enumerate than a single UUPS layout); keep that caveat.
Mitigation
Separate the proxy’s metadata from the logic’s storage, and freeze the logic’s layout as append-only across upgrades.
- ▸Preventive: store proxy pointers at EIP-1967 unstructured slots so they cannot collide with sequential variables; never declare ordinary state variables in the proxy contract.
- ▸Preventive: treat implementation storage layout as append-only — only add new variables at the end, never reorder, remove, or change the type of an existing variable; reserve storage gaps in upgradeable base contracts.
- ▸Process: run an automated storage-layout diff (OpenZeppelin Upgrades plugin or solc --storage-layout) in CI and block any upgrade that moves an existing slot.
- ▸Preventive: inherit from audited upgradeable bases (OpenZeppelin upgradeable contracts) rather than hand-rolling the proxy and initialization logic.
Corrected example
The proxy stores its implementation pointer at the EIP-1967 slot, out of reach of any sequential variable, and the logic contract treats its layout as append-only:
synthetic — EIP-1967 unstructured storage// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SaferProxy { // keccak256("eip1967.proxy.implementation") - 1 bytes32 private constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; constructor(address impl) { _setImpl(impl); } function _setImpl(address impl) private { assembly { sstore(IMPL_SLOT, impl) } } fallback() external payable { assembly { let impl := sload(IMPL_SLOT) // never slot 0 calldatacopy(0, 0, calldatasize()) let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } } contract LogicV2 { address public owner; // slot 0 — safe: proxy pointer is not here uint256 public value; // slot 1 // Upgrades may ONLY append below this line. uint256[48] private __gap; // reserve room for future variables }
Why it works: the implementation pointer lives at a hashed slot no linear allocation reaches, so logic writes cannot clobber it; the append-only rule plus the storage gap keeps every existing variable on its original slot across upgrades.
Limits: EIP-1967 removes proxy-vs-logic collisions but does nothing for implementation-vs-implementation drift — a careless reorder still corrupts state. The storage gap only helps if every base in the inheritance chain reserves one and sizes it correctly; diamonds need per-facet namespaced storage, not a single gap.
Verifying the mitigation
Verify by trying to defeat it on a local chain or fork: deploy the proxy, run every state-changing logic call, and assert the EIP-1967 implementation and admin slots are byte-for-byte unchanged; then simulate the upgrade and assert, via a layout diff and a read-back of known values, that every pre-existing variable still holds its prior value. Re-run the compiler storage-layout diff in CI and confirm no existing slot moved. Report results as "no collision reproduced within the tested call set", never as a guarantee.
A layout diff that is clean is a static property over the compared versions; it does not cover a future upgrade or a facet added later.
Limitations & honesty
- ▸A clean layout diff proves only that the two compared versions align — not that the live storage matches (prior buggy upgrades may already have corrupted it).
- ▸Dynamic reproduction covers only the call paths exercised; an unexercised write that hits a colliding slot stays UNKNOWN.
- ▸Diamond (EIP-2535) and beacon proxies multiply the layout surface and are harder to enumerate completely.
- ▸No figures, incident counts, or CVE/SWC identifiers are asserted here beyond the referenced, verifiable sources.
References
- [1]EIP-1967: Standard Proxy Storage Slots
- [2]Solidity docs — Layout of State Variables in Storage
- [3]OpenZeppelin — Proxies & Upgrades (writing upgradeable contracts, storage gaps)
- [4]SWC-112: Delegatecall to Untrusted Callee
- [5]EIP-2535: Diamonds, Multi-Facet Proxy
- [6]OWASP Smart Contract Top 10
References point to public, well-known technical sources for further study.
Related articles
