Storage collision vulnerabilities sit in a peculiar category of smart contract bugs: they require no exploitable function call, no overflow, no reentrancy loop. They emerge from the structural assumptions the EVM makes about how contracts use storage — assumptions that proxy patterns quietly violate. Understanding them demands a clear mental model of how the EVM lays out storage, why proxies inherit a fundamental tension with that model, and what specific patterns consistently produce collisions in practice.
How EVM Storage Slots Work
Every smart contract on the EVM has access to a persistent key-value store: a mapping from 256-bit keys (slots) to 256-bit values. This store is specific to the contract’s address. When a proxy delegates a call to an implementation contract, the execution happens in the proxy’s storage context — the implementation’s bytecode runs, but all SSTORE and SLOAD operations read from and write to the proxy’s storage.
The Solidity compiler assigns storage slots sequentially, starting at slot 0, following a strict set of rules:
- State variables are packed into slots in declaration order.
- Value types smaller than 32 bytes are packed together into a single slot if they fit.
- Dynamic types (mappings, dynamic arrays) use a slot for metadata or are always stored at a hashed location computed from their slot number and a key.
- Structs occupy contiguous slots following the same packing rules.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract StorageLayout {
// Slot 0
address public owner; // 20 bytes
// Slot 0 (packed with owner, 12 bytes remaining — but bool is 1 byte)
bool public paused; // 1 byte — packed into slot 0 alongside owner
// Slot 1
uint256 public totalSupply; // 32 bytes — full slot
// Slot 2
mapping(address => uint256) public balances; // slot 2 holds metadata;
// actual values at keccak256(key . 2)
}
The critical point: the compiler makes no distinction between “this contract’s variables” and “some other contract’s variables.” It only counts declarations. When a proxy and an implementation both compile independently with their own state variables, their slot assignments are independent and potentially overlapping.
Slot Conflicts in Proxy Patterns: Before EIP-1967
The naive proxy pattern stores the implementation address in its own state variables. This is where the fundamental collision originates.
The Naive Proxy
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// VULNERABLE: naive proxy with direct storage variable
contract NaiveProxy {
address public implementation; // Slot 0
constructor(address _impl) {
implementation = _impl;
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// The implementation contract
contract TokenV1 {
address public owner; // Also slot 0 — COLLISION with proxy's `implementation`
function initialize(address _owner) external {
owner = _owner; // This overwrites proxy.implementation !
}
function transfer(address to, uint256 amount) external {
// ...
}
}
When initialize is called through the proxy, owner = _owner writes to slot 0 in the proxy’s storage. But slot 0 is where the proxy stored the implementation address. The next delegatecall reads slot 0 to find the implementation address and instead finds _owner — an arbitrary address that is almost certainly not a valid contract. The proxy is now permanently bricked.
This is not a theoretical concern. It is the default outcome of the naive approach and the exact problem that motivated the development of structured proxy standards.
EIP-1967: Pseudo-Random Storage Slots
EIP-1967 resolves the collision by placing proxy administrative variables at pseudo-random storage slots, far outside the sequential range used by normal Solidity compilation. The chosen slots are derived by hashing a well-known string and subtracting one, ensuring they cannot be accidentally occupied by a normal variable declaration.
// Implementation slot: bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
// = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
// Admin slot: bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
// = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// EIP-1967 compliant proxy (simplified)
contract EIP1967Proxy {
// These constants are the well-known EIP-1967 slots
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 private constant ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address _impl, address _admin) {
_setSlot(IMPLEMENTATION_SLOT, uint256(uint160(_impl)));
_setSlot(ADMIN_SLOT, uint256(uint160(_admin)));
}
function _setSlot(bytes32 slot, uint256 value) internal {
assembly {
sstore(slot, value)
}
}
function _getSlot(bytes32 slot) internal view returns (address addr) {
assembly {
addr := sload(slot)
}
}
function upgradeTo(address newImpl) external {
require(msg.sender == _getSlot(ADMIN_SLOT), "Not admin");
_setSlot(IMPLEMENTATION_SLOT, uint256(uint160(newImpl)));
}
fallback() external payable {
address impl = _getSlot(IMPLEMENTATION_SLOT);
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Because no Solidity variable declaration will ever be assigned slot 0x3608...bbc through normal sequential allocation (there are only around 2^160 practical variables before that range), the collision surface is effectively eliminated for the proxy’s own administrative variables. However, EIP-1967 does not solve all storage collision problems — it only removes one specific class of them.
Uninitialized Variables in Upgradeable Contracts
Upgradeable contracts cannot use constructors for initialization logic. A constructor runs during deployment of the implementation contract’s bytecode, but state written during constructor execution is written to the implementation’s own storage — not to the proxy’s storage. When users interact through the proxy, that initialization never happened in the proxy’s context.
The solution is the initialize function pattern. But this pattern introduces its own vulnerability: if initialization is never called, or is called with incorrect assumptions, variables remain at their default zero values, which can have severe security implications.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// VULNERABLE: uninitialized upgradeable contract
contract VaultV1 {
address public owner; // Slot 0 — default: address(0)
bool public initialized; // Slot 0 (packed) — default: false
// No protection: anyone can call this before the legitimate deployer
function initialize(address _owner) external {
owner = _owner;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Not owner");
payable(owner).transfer(amount);
}
}
An attacker monitoring the mempool sees the proxy deployment and calls initialize before the legitimate owner does. They become the owner of a contract holding user funds.
The Fix: Initialization Guard
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// FIXED: initialization guard
contract VaultV1Safe {
address public owner;
bool private _initialized; // packed in slot 0 with owner
modifier initializer() {
require(!_initialized, "Already initialized");
_initialized = true;
_;
}
function initialize(address _owner) external initializer {
require(_owner != address(0), "Zero address");
owner = _owner;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Not owner");
payable(owner).transfer(amount);
}
}
Production frameworks like OpenZeppelin’s Initializable add additional protections: a re-entrancy guard during initialization and a mechanism to disable initialization at the implementation level, preventing the implementation contract itself from being initialized and then used as a target for delegatecall-based exploits.
Disabling Initializers on Implementation Contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// Pattern: disable initializer at the implementation level
contract VaultV1Production {
address public owner;
uint8 private _initVersion;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// Lock the implementation contract itself
_initVersion = type(uint8).max;
}
function initialize(address _owner) external {
require(_initVersion == 0, "Already initialized");
_initVersion = 1;
require(_owner != address(0), "Zero address");
owner = _owner;
}
}
The Proxy Admin Variable Collision
A subtler and historically impactful collision exists between the proxy’s administrative state and the implementation’s declared state. Even with EIP-1967, a pattern called the Transparent Proxy introduces a function selector clash: the proxy and the implementation may expose functions with identical selectors, and routing decisions must be made based on the caller’s identity (admin vs. user).
But before EIP-1967 standardized the admin slot, frameworks stored the admin in slot 1 or slot 2 — sometimes chosen to avoid slot 0 but still within normal sequential range.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// VULNERABLE: older proxy pattern storing admin at slot 1
contract OldAdminProxy {
address private _implementation; // Slot 0
address private _admin; // Slot 1
fallback() external payable {
// delegatecall to _implementation...
}
}
// Implementation V1
contract ImplementationV1 {
uint256 public totalSupply; // Slot 0
address public treasury; // Slot 1 — COLLIDES with proxy._admin
}
A call to set treasury through the proxy writes to slot 1 in the proxy’s storage. Slot 1 holds _admin. The admin address is now corrupted. If the admin address is used to gate upgrade functions, the contract becomes permanently locked — no one can upgrade it, because the stored admin address is now the treasury address, not the deployer’s address.
The Fix: Use EIP-1967 Slots Everywhere
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// FIXED: all admin state in EIP-1967 slots, no sequential storage in proxy
contract SafeAdminProxy {
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 private constant ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
// No state variables declared here — zero sequential storage footprint
// All administrative state lives in named EIP-1967 slots
function _admin() internal view returns (address a) {
assembly { a := sload(ADMIN_SLOT) }
}
function _implementation() internal view returns (address impl) {
assembly { impl := sload(IMPLEMENTATION_SLOT) }
}
}
// Implementation can now freely use slots 0, 1, 2... without risk
contract ImplementationV1Safe {
uint256 public totalSupply; // Slot 0 — safe
address public treasury; // Slot 1 — safe
}
Storage Layout Drift Between Contract Versions
Perhaps the most common real-world storage collision in production systems is layout drift during upgrades. When a new implementation version is deployed, its storage layout must be a strict extension of the previous version’s layout. Any insertion, removal, or reordering of state variables breaks the layout and causes the new code to read from the wrong slots.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// VERSION 1 — deployed and used in production
contract TokenV1 {
address public owner; // Slot 0
uint256 public totalSupply; // Slot 1
mapping(address => uint256) public balances; // Slot 2
}
// VERSION 2 — BAD UPGRADE: inserted a variable before totalSupply
contract TokenV2_BAD {
address public owner; // Slot 0
bool public paused; // Slot 1 ← INSERTED — shifts everything below
uint256 public totalSupply; // Slot 2 ← WAS slot 1 — now reads wrong data
mapping(address => uint256) public balances; // Slot 3 ← WAS slot 2 — all balances lost
}
In TokenV2_BAD, the value that was totalSupply in V1 (slot 1) is now read as the paused flag. Existing balance mappings now compute hashes from slot 3 instead of slot 2, making every balance appear as zero to the new code. No explicit exploit is required — the upgrade itself is the attack.
The Fix: Append Only, Use Gap Variables
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// VERSION 1 — with storage gap reserved for future use
contract TokenV1Safe {
address public owner; // Slot 0
uint256 public totalSupply; // Slot 1
mapping(address => uint256) public balances; // Slot 2
// Reserve 47 slots for future variables (gap fills slots 3–49)
uint256[47] private __gap;
}
// VERSION 2 — SAFE UPGRADE: append within the gap
contract TokenV2Safe {
address public owner; // Slot 0 — unchanged
uint256 public totalSupply; // Slot 1 — unchanged
mapping(address => uint256) public balances; // Slot 2 — unchanged
bool public paused; // Slot 3 ← new variable appended, consumes one gap slot
// Gap is now 46 slots (slots 4–49)
uint256[46] private __gap;
}
The __gap array pattern is a standard defense-in-depth measure. By pre-allocating a block of empty slots, future versions can introduce new state variables by consuming gap slots, without disturbing the layout of any inherited or previously deployed state.
How Inheritance Order Affects Storage Layout
In Solidity, inheritance linearization (C3 linearization) determines not only method resolution order but also storage layout order. The base-most contract’s variables come first, then each derived contract’s variables in the order they appear in the inheritance chain. Changing the order of base contracts in a contract X is A, B declaration reorders storage.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Ownable {
address public owner; // Slot 0
}
contract Pausable {
bool public paused; // Slot 1 (if Ownable comes first)
}
// Layout: owner (slot 0), paused (slot 1)
contract TokenV1 is Ownable, Pausable {
uint256 public totalSupply; // Slot 2
}
// BAD UPGRADE: reversed inheritance order
// Layout: paused (slot 0), owner (slot 1) — everything shifts
contract TokenV2_InheritanceBug is Pausable, Ownable {
uint256 public totalSupply; // Slot 2 (same slot, but slots 0 and 1 swapped)
}
In TokenV2_InheritanceBug, slot 0 now holds paused instead of owner. Any code reading owner from slot 0 gets a bool cast to an address — a near-zero address — effectively locking the contract. The existing owner value in the proxy’s slot 0 is now interpreted as the paused flag: since it was a non-zero address, paused reads as true.
The Fix: Lock Inheritance Order
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// Always maintain identical inheritance order across versions
contract TokenV2Safe is Ownable, Pausable {
uint256 public totalSupply; // Slot 2 — unchanged
uint256 public maxSupply; // Slot 3 — new, safely appended
}
When working with complex inheritance hierarchies, it is advisable to document the complete storage layout as a comment at the top of each implementation contract, explicitly listing every slot and its source contract. This documentation becomes a mandatory review artifact before any upgrade is executed.
The UUPS Pattern and Self-Upgrade Collision Risk
The Universal Upgradeable Proxy Standard (UUPS) moves the upgrade logic into the implementation contract itself rather than the proxy. This eliminates the transparent proxy’s function selector collision problem but introduces a different storage consideration: the implementation must declare no state before the upgrade-related state, and the upgrade function must be present in every version.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// UUPS base — upgrade logic lives here
abstract contract UUPSUpgradeable {
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function upgradeTo(address newImplementation) external virtual {
_authorizeUpgrade(newImplementation);
assembly {
sstore(IMPLEMENTATION_SLOT, newImplementation)
}
}
function _authorizeUpgrade(address newImplementation) internal virtual;
}
// VULNERABLE: UUPS implementation that removes upgrade function in V2
contract VaultV1UUPS is UUPSUpgradeable {
address public owner; // Slot 0
function initialize(address _owner) external {
require(owner == address(0));
owner = _owner;
}
function _authorizeUpgrade(address) internal override {
require(msg.sender == owner);
}
}
// BAD V2: forgot to inherit UUPSUpgradeable — upgrade function disappears
contract VaultV2_NoUpgrade {
address public owner; // Slot 0
// No upgradeTo function — contract is now permanently locked at this version
// Any storage layout changes here also collide with V1 layout in proxy storage
}
The fix is to always inherit UUPSUpgradeable in every version, and to include a storage layout compatibility check in the upgrade authorization logic or in the CI/CD pipeline.
Tools for Detecting Storage Collisions
Manual review of storage layouts is error-prone at scale. Several tools have been developed to automate detection.
Hardhat Storage Layout Plugin
The hardhat-storage-layout plugin generates a structured report of every state variable’s slot and offset for every contract in the project. Running it against both the old and new implementation versions and diffing the outputs reveals any layout changes.
# Install
npm install --save-dev hardhat-storage-layout
# In hardhat.config.js
require("hardhat-storage-layout");
# Generate layout
npx hardhat check
OpenZeppelin Upgrades Plugin
The @openzeppelin/hardhat-upgrades plugin validates storage layout compatibility automatically during upgrade deployment. It maintains a manifest of deployed contracts and their layouts, and rejects upgrades that introduce incompatible changes.
// hardhat deployment script
const { upgrades, ethers } = require("hardhat");
async function main() {
// Deploy initial proxy
const VaultV1 = await ethers.getContractFactory("VaultV1Safe");
const proxy = await upgrades.deployProxy(VaultV1, [deployer.address]);
await proxy.waitForDeployment();
// Upgrade — will REVERT if storage layout is incompatible
const VaultV2 = await ethers.getContractFactory("VaultV2Safe");
await upgrades.upgradeProxy(proxy, VaultV2);
// Error thrown automatically if slot layout changes
}
Slither
Slither’s slither-check-upgradeability module performs static analysis on proxy-implementation pairs. It detects uninitialized variables, missing initializer guards, and layout incompatibilities.
# Run upgradeability checks
slither-check-upgradeability . VaultProxy VaultV1Safe
# Specific detectors relevant to storage collisions:
# - uninitialized-local
# - incorrect-modifier
# - suicidal (can brick proxy if impl selfdestruct)
# - variable-scope
Custom Foundry Invariant Test
For teams using Foundry, writing an invariant test that reads raw slot values and asserts their expected types provides a runtime safety net:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract StorageLayoutInvariantTest is Test {
address proxy;
function setUp() public {
// Deploy proxy and implementation
}
function invariant_ownerSlotIsValidAddress() public view {
// Read slot 0 directly from proxy storage
bytes32 slot0Value = vm.load(proxy, bytes32(uint256(0)));
address storedOwner = address(uint160(uint256(slot0Value)));
// Assert the value is a plausible address, not a corrupted implementation pointer
assertTrue(storedOwner != address(0), "Owner slot corrupted");
assertTrue(storedOwner.code.length == 0 || storedOwner == address(this),
"Owner slot contains contract address — possible collision");
}
function invariant_implementationSlotIntact() public view {
bytes32 implSlot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 implValue = vm.load(proxy, implSlot);
address impl = address(uint160(uint256(implValue)));
assertTrue(impl != address(0), "Implementation slot is zero");
assertTrue(impl.code.length > 0, "Implementation slot points to EOA");
}
}
Storage Layout Audit Checklist
The following checklist consolidates every control point discussed in this article into a structured review process. It should be executed for every new implementation deployment, every upgrade, and every base contract modification.
Proxy Architecture
- No sequential state variables in the proxy contract. All proxy-owned state (implementation address, admin address, beacon address) is stored using EIP-1967 pseudo-random slots or equivalent named slots.
- EIP-1967 slot constants are byte-accurate. Verify the constant values match the specification:
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1). - No function selector clashes between proxy administrative functions and implementation functions. Use a transparent proxy with caller-based routing, or UUPS with logic only in the implementation.
Initialization
- No constructor logic with state-mutating effects in any upgradeable implementation contract. All initialization happens in
initialize. - Initialization function is guarded against repeated calls. An
_initializedflag or equivalent is checked before any state is written. - Implementation contract’s initializer is disabled at the implementation level (constructor sets
_initialized = type(uint8).maxor equivalent) to prevent the implementation itself from being exploited as an uninitialized contract. -
initializeis called atomically with proxy deployment in a single transaction or in the constructor, preventing front-running.
Storage Layout Compatibility (Upgrade Reviews)
- No variables inserted before existing variables. New variables are appended only after all existing declarations.
- No variables removed from the layout. Removing a variable shifts all subsequent slots.
- No variables reordered. The relative order of all existing declarations is identical between versions.
- Inheritance order is identical between V1 and V2 (and all subsequent versions). The
contract V2 is A, Bdeclaration matchescontract V1 is A, Bexactly. - Storage gaps are used in all base contracts that may need future variables. Gaps are reduced by exactly the number of new variables introduced, never eliminated entirely.
- Mapping key types and value types are unchanged. Changing
mapping(address => uint256)tomapping(address => uint128)does not change slot assignment but changes how values are decoded — treat as a breaking change. - Struct field ordering is unchanged. Modifying struct field order is equivalent to reordering standalone state variables.
Type-Level Checks
- Variable types are compatible across versions. Widening a
uint128touint256in place is safe only if the variable occupies the full slot alone. If it was packed with another variable, the other variable’s position changes. - Packed variable groups are intact. If slot N packs
bool flagAandbool flagB, addingbool flagCin V2 between them breaks the packing offlagB.
UUPS-Specific
- Every implementation version inherits the upgrade mechanism. A V2 that drops
UUPSUpgradeableinheritance permanently locks the contract. -
_authorizeUpgradeis properly access-controlled and cannot be called by arbitrary addresses. -
upgradeTovalidates the new implementation is a valid contract (code length check) before writing to the implementation slot.
Tooling and CI/CD
- Storage layout snapshot is generated and committed for every deployed implementation version.
- Automated layout diff is run as part of the upgrade CI pipeline. Any diff that is not a pure append of new variables at the end blocks the deployment.
- Slither upgradeability detector is run against every proxy-implementation pair.
- OpenZeppelin upgrades plugin (or equivalent) validates compatibility before deployment scripts execute.
- Foundry invariant tests assert critical slot values remain consistent with expected types and ranges after every upgrade simulation.
Deployment Verification
- Post-deployment slot verification reads EIP-1967 slots directly and confirms they contain the expected implementation and admin addresses.
- Post-upgrade state verification reads critical state variables (owner, total supply, key configuration values) directly from raw storage and confirms they match pre-upgrade values.
- Event emissions from upgrade functions are verified on-chain before the upgrade is considered complete.
Storage collisions occupy a unique threat surface because they are invisible to the ABI layer. A contract’s public interface can appear completely unchanged while its storage is silently misinterpreted. The combination of formal tooling, rigorous layout documentation, atomic initialization, and a disciplined append-only upgrade discipline is not optional — it is the minimum viable process for any system that uses delegatecall. Every shortcut taken in this process is a slot waiting to be corrupted.