Storage layout collisions are among the most treacherous bugs in Solidity. Unlike arithmetic overflows or access control failures, they produce no revert, no error, and no event. The EVM silently reads or writes the wrong slot, and your contract continues executing with corrupted state. A colliding write can overwrite an admin address with token balance data, turn a paused flag into the upper bytes of a uint256, or allow an attacker to take ownership by calling a function that was never meant to touch access control variables.
This post covers six distinct storage collision vulnerability classes that appear in proxy and upgradeable contract patterns, with vulnerable code, attack explanation, and the correct fix for each.
1. Slot 0 Admin/Implementation Collision (Classic Transparent Proxy Bug)
The earliest transparent proxy implementations stored administrative variables — the proxy admin address, the implementation address — as ordinary Solidity state variables starting at slot 0. Any implementation contract that also declared a state variable at slot 0 would silently alias the proxy's admin storage.
// VULNERABLE: naive proxy — admin stored at slot 0
contract NaiveProxy {
address public admin; // slot 0
address public implementation; // slot 1
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()) }
}
}
}
// VULNERABLE: implementation that collides with proxy admin slot
contract TokenV1 {
address public owner; // slot 0 — collides with NaiveProxy.admin
uint256 public totalSupply; // slot 1 — collides with NaiveProxy.implementation
function transferOwnership(address newOwner) external {
require(msg.sender == owner);
owner = newOwner; // OVERWRITES proxy.admin via delegatecall
}
}
When a user calls transferOwnership through the proxy, delegatecall executes the implementation logic in the proxy's storage context. Writing to owner (slot 0) overwrites admin (slot 0). After the call, the proxy admin is gone and the proxy is unupgradeable.
Fix — EIP-1967 pseudo-random slots:
// SAFE: EIP-1967 compliant proxy
contract EIP1967Proxy {
// keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
// keccak256("eip1967.proxy.admin") - 1
bytes32 private 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) }
}
function _getImplementation() internal view returns (address impl) {
assembly { impl := sload(IMPLEMENTATION_SLOT) }
}
}
The -1 subtraction is deliberate: it ensures the slot value is not the raw keccak256 output, preventing a scenario where a preimage could be crafted to force collision. OpenZeppelin's TransparentUpgradeableProxy and ERC1967Proxy use this pattern.
2. Inheritance Ordering Collision in Upgrades
Solidity assigns storage slots sequentially based on the linearized inheritance order (C3 linearization). When you upgrade a contract and change the inheritance list, slot assignments shift for every variable in the hierarchy.
// V1: Token inherits only Ownable
// Slot 0: _owner (from Ownable)
// Slot 1: _totalSupply
// Slot 2: _balances (mapping)
contract TokenV1 is Ownable {
uint256 public _totalSupply; // slot 1
mapping(address => uint256) public _balances; // slot 2
}
// VULNERABLE V2: Pausable inserted BEFORE Ownable
// Slot 0: _paused (from Pausable) ← NEW — shifts everything
// Slot 1: _owner (from Ownable) ← was slot 0 — CORRUPTED
// Slot 2: _totalSupply ← was slot 1 — CORRUPTED
// Slot 3: _balances ← was slot 2 — CORRUPTED
contract TokenV2 is Pausable, Ownable {
uint256 public _totalSupply; // slot 2 — shifted
mapping(address => uint256) public _balances; // slot 3 — shifted
}
After upgrading to V2, the proxy's storage still holds the V1 layout. What was the owner address at slot 0 is now read as _paused. What was _totalSupply at slot 1 is now read as the owner address. Every subsequent access reads the wrong data.
Fix — append-only inheritance, gap variables:
// SAFE V2: Ownable stays first, Pausable appended, gap reserves future slots
contract TokenV2 is Ownable, Pausable {
uint256 public _totalSupply; // slot 1 — unchanged
mapping(address => uint256) public _balances; // slot 2 — unchanged
// Reserve 48 slots for future base contract variables
uint256[48] private __gap;
}
The __gap array is a standard OpenZeppelin pattern. Each base contract reserves a block of slots so that adding new state variables to a base contract in a future upgrade fills the gap rather than shifting subsequent slots.
3. Mapping vs Array Slot Overlap
Dynamic arrays in Solidity store their length at the declared slot N, and their elements starting at keccak256(N). Mappings store values at keccak256(key . N). In pathological layouts, a mapping key can hash to the same slot as an array element.
// VULNERABLE: array length at slot 0, mapping at slot 1
contract VulnerableStorage {
uint256[] public items; // slot 0 = length; elements at keccak256(0)
mapping(uint256 => uint256) public lookup; // slot 1; value at keccak256(key . 1)
function addItem(uint256 val) external {
items.push(val); // increments slot 0
}
// An attacker who controls `key` can precompute a key such that
// keccak256(key . 1) == keccak256(0) + N for some array index N
// Reading lookup[key] reads items[N] and vice versa
function exploit(uint256 crafted_key) external view returns (uint256) {
return lookup[crafted_key]; // may alias items element
}
}
This is the class of bug that made the Ethereum storage expansion attack theoretical — finding a key whose hash lands on a live array element. In practice, exploitability depends on the attacker controlling key values and array sizes simultaneously.
Fix — isolate dynamic collections with explicit slot management:
// SAFE: use mappings-only for attacker-controlled keys,
// or use EIP-1967-style hashed namespaces for critical storage
contract SafeStorage {
// Fixed-size array avoids length-at-slot-N aliasing
uint256[1024] public items;
uint256 public itemCount; // separate counter — no array length at slot 0
mapping(uint256 => uint256) public lookup;
}
4. Struct Packing Across Upgrade Versions
Solidity packs multiple small types into a single 32-byte slot when they fit. If a V2 upgrade inserts a new field into an existing struct, the compiler repacks the struct. Old on-chain data is then decoded according to the new field layout and produces corrupted values.
// V1 struct: two uint128 values packed into slot 0
struct Position {
uint128 amount; // bytes 0-15 of slot
uint128 timestamp; // bytes 16-31 of slot
}
contract VaultV1 {
mapping(address => Position) public positions;
}
// VULNERABLE V2: new field inserted in the middle — breaks packing
struct Position {
uint128 amount; // bytes 0-15 of slot
uint64 lockPeriod; // bytes 16-23 — NEW FIELD, inserted mid-struct
uint64 timestamp; // bytes 24-31 — was uint128, now truncated to uint64
}
contract VaultV2 {
mapping(address => Position) public positions;
// Existing data: slot holds [amount (16 bytes)][timestamp (16 bytes)]
// V2 reads it as: [amount][lockPeriod = high bytes of old timestamp][timestamp = low bytes]
// Result: lockPeriod and timestamp are both wrong for all existing positions
}
An attacker who understands the layout shift can predict the decoded lockPeriod value from the old timestamp bytes and exploit any logic gated on that field — for example, bypassing a lock period check because corrupted bytes decode to zero.
Fix — only append to structs, never insert:
// SAFE V2: new field appended after existing fields
struct Position {
uint128 amount; // bytes 0-15 of slot 0 — unchanged
uint128 timestamp; // bytes 16-31 of slot 0 — unchanged
uint64 lockPeriod; // slot 1, bytes 0-7 — appended, no repacking
}
contract VaultV2 {
mapping(address => Position) public positions;
// Old data still correctly decoded for amount and timestamp
// lockPeriod defaults to 0 for existing positions — handle gracefully in logic
}
5. Unstructured Storage Collision via SSTORE in Assembly
Inline assembly that writes to hardcoded slot values can silently collide with Solidity-managed variables if the chosen slot happens to match the compiler's sequential assignment.
// VULNERABLE: assembly writes to slot 0 without checking layout
contract AssemblyProxy {
address public owner; // compiler assigns slot 0
function setImplementation(address impl) external {
require(msg.sender == owner);
assembly {
// Developer intended to store implementation at "slot 0"
// but owner is also at slot 0 — OVERWRITES owner
sstore(0, impl)
}
}
// Now owner == impl address; any ownership check is compromised
}
This pattern also appears in libraries that use hardcoded slots for feature flags or cached values, not realizing the host contract already occupies those slots.
Fix — use keccak256-derived slot addresses:
// SAFE: unstructured storage with collision-resistant slot
contract AssemblyProxySafe {
address public owner; // slot 0
// keccak256("com.myprotocol.proxy.implementation") - 1
bytes32 private constant IMPL_SLOT =
0x9e5b63b4d5b6a1c2f8e3d4a7b1c9e2f5a8d3c6b9e4f7a2d5c8b1e4f7a0d3c6;
function setImplementation(address impl) external {
require(msg.sender == owner);
assembly {
sstore(IMPL_SLOT, impl) // far from slot 0 — no collision
}
}
function getImplementation() external view returns (address impl) {
assembly { impl := sload(IMPL_SLOT) }
}
}
Choose slot values using keccak256("your.unique.namespace.variable") - 1. The -1 ensures the constant is not the direct hash preimage, preventing crafted inputs from targeting it.
6. Diamond Storage Namespace Collision (EIP-2535)
EIP-2535 diamonds use multiple facets that each manage their own storage. The recommended pattern (Diamond Storage) gives each facet a unique struct at a hashed namespace. When two facets use the same namespace string — or when a developer forgets to namespace entirely — their structs overlap at the same starting slot.
// VULNERABLE: two facets using identical or default namespace
library FacetAStorage {
struct Layout {
address admin;
uint256 adminFee;
}
// Generic namespace — easy to accidentally duplicate
bytes32 constant STORAGE_SLOT = keccak256("diamond.storage");
function layout() internal pure returns (Layout storage s) {
bytes32 slot = STORAGE_SLOT;
assembly { s.slot := slot }
}
}
library FacetBStorage {
struct Layout {
uint256 price; // slot keccak256("diamond.storage") + 0
address treasury; // slot keccak256("diamond.storage") + 1
}
// SAME namespace — collides with FacetA
bytes32 constant STORAGE_SLOT = keccak256("diamond.storage");
function layout() internal pure returns (Layout storage s) {
bytes32 slot = STORAGE_SLOT;
assembly { s.slot := slot }
}
}
// FacetA.admin == FacetB.price (same slot)
// FacetA.adminFee == FacetB.treasury (same slot)
// Updating price in FacetB overwrites admin in FacetA
An attacker who can trigger a price update in FacetB can overwrite the admin address in FacetA, gaining administrative control.
Fix — globally unique namespaces per facet:
// SAFE: each facet uses a unique, descriptive namespace
library FacetAStorage {
struct Layout {
address admin;
uint256 adminFee;
}
// Protocol-specific + facet-specific namespace
bytes32 constant STORAGE_SLOT =
keccak256("com.myprotocol.v1.facets.admin.storage");
function layout() internal pure returns (Layout storage s) {
bytes32 slot = STORAGE_SLOT;
assembly { s.slot := slot }
}
}
library FacetBStorage {
struct Layout {
uint256 price;
address treasury;
}
// Completely different namespace — guaranteed separation
bytes32 constant STORAGE_SLOT =
keccak256("com.myprotocol.v1.facets.pricing.storage");
function layout() internal pure returns (Layout storage s) {
bytes32 slot = STORAGE_SLOT;
assembly { s.slot := slot }
}
}
Enforce namespace uniqueness as part of your code review process or via static analysis. Treat colliding namespace strings the same way you would treat colliding function selectors — a critical bug regardless of whether exploitation is currently practical.
What ContractScan Detects
ContractScan performs static analysis of Solidity source and bytecode to identify storage collision vulnerabilities before deployment. The table below maps each vulnerability class covered in this post to the detection method and severity rating ContractScan applies.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Slot 0 admin/implementation collision | Cross-contract storage layout comparison for proxy + implementation pairs | Critical |
| Inheritance ordering shift on upgrade | V1/V2 slot map diffing via linearized inheritance analysis | High |
| Mapping vs array slot aliasing | Symbolic slot computation and collision search across storage declarations | Medium |
| Struct packing break across versions | Struct field layout comparison between upgrade versions | High |
Assembly SSTORE to compiler-managed slots |
Bytecode-level slot tracking cross-referenced against Solidity variable assignments | Critical |
| Diamond facet namespace collision | Namespace string deduplication across all facet storage libraries in scope | Critical |
Scans run against your entire project including proxies, implementations, and facets together — not in isolation — because most storage collision bugs are only visible when the full deployment topology is analyzed as a unit.
Related Posts
- Upgradeable Contract Initialization: Uninitialized Proxy Vulnerabilities — the companion risk to storage collisions: what happens when initializers are skipped or called twice in proxy deployments.
- Function Selector Clash in Proxy Security — how 4-byte selector collisions between proxy admin functions and implementation functions allow callers to trigger unintended behavior.
\n\n## Important Notes\n\nThis post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.