Upgradeable smart contracts introduce a fundamental challenge: two contracts (proxy and implementation) share the same storage space but have different variable layouts. When those layouts collide, the result is silent state corruption — one of the hardest classes of bug to debug and one of the most exploited.
This post covers the storage collision attack surface, the EIP-1967 standard that mitigates it, and the layout rules every developer working with upgradeable contracts must follow.
How Proxy Storage Works
In a proxy pattern, the proxy contract holds all state. The implementation contract holds all logic. When a user calls the proxy, the proxy uses delegatecall to run the implementation's code in the proxy's storage context.
User → Proxy.fallback() → delegatecall(Implementation)
↑ storage is HERE ↑ code is HERE
Both contracts address storage by slot number, not by variable name. Slot 0 is the first state variable, slot 1 is the second, and so on. If the proxy and implementation both have variables at slot 0 but they're different types, they silently overwrite each other.
Attack Vector 1: Implementation Slot Collision
The classic example: a proxy that stores its implementation address in slot 0.
// VULNERABLE: implementation address in slot 0
contract VulnerableProxy {
address public implementation; // slot 0
function upgradeTo(address newImpl) external onlyAdmin {
implementation = newImpl;
}
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()) }
}
}
}
// Implementation contract — also uses slot 0
contract MyToken {
address public owner; // slot 0 in implementation
// ...
function initialize(address _owner) external {
owner = _owner; // WRITES to slot 0 of PROXY
// This overwrites proxy.implementation with the owner address!
}
}
When initialize(_owner) runs via delegatecall, it writes the owner address to slot 0 of the proxy's storage. This silently corrupts proxy.implementation with the owner's address — the next delegatecall will delegate to the owner's address, likely reverting or executing attacker-controlled code.
Fix: EIP-1967 Standard Slots
EIP-1967 reserves specific, pseudo-random storage slots for proxy-internal variables so they don't collide with any reasonable implementation layout:
// EIP-1967: implementation slot = keccak256("eip1967.proxy.implementation") - 1
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
contract SafeProxy {
function _getImpl() internal view returns (address impl) {
assembly { impl := sload(IMPL_SLOT) }
}
function _setImpl(address newImpl) internal {
assembly { sstore(IMPL_SLOT, newImpl) }
}
fallback() external payable {
address impl = _getImpl();
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 EIP-1967 slot (0x360894...) is so high in the slot space that no ordinary state variable would ever be packed there. OpenZeppelin's TransparentUpgradeableProxy and UUPSUpgradeable both use this standard.
Attack Vector 2: Uninitialized Implementation Contract Hijack
UUPS (EIP-1822) proxies allow the implementation contract to authorize its own upgrades. This creates a critical risk: if the implementation contract itself is left uninitialized, anyone can call initialize() and take ownership — then upgrade to a malicious implementation.
// Deployed implementation contract — NOT initialized
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
uint256 public totalDeposited;
// Missing: _disableInitializers() in constructor
function initialize(address admin) public initializer {
__Ownable_init(admin);
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
Attack scenario:
1. Developer deploys VaultV1 as the implementation (not via proxy)
2. Forgets to call initialize() on the implementation itself
3. Attacker finds the uninitialized implementation contract on-chain
4. Calls VaultV1.initialize(attacker) — takes ownership of the implementation contract
5. Calls VaultV1.upgradeTo(maliciousImpl) — upgrades the UUPS proxy to attacker-controlled logic
6. Proxy now points to attacker's implementation — protocol drained
Real incident: This exact pattern was exploited in multiple UUPS proxy implementations in 2021-2022, including a near-miss in the Wormhole bridge proxy.
Fix: disable initializers in the implementation constructor
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // prevents anyone from initializing the bare impl
}
function initialize(address admin) public initializer {
__Ownable_init(admin);
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
_disableInitializers() sets the initialized version to type(uint8).max, making all subsequent initializer calls revert. The bare implementation contract can never be taken over.
Attack Vector 3: Storage Layout Break During Upgrade
When upgrading to a new implementation, adding or removing state variables in the wrong position corrupts existing state.
// V1 storage layout
contract VaultV1 {
address public owner; // slot 0
uint256 public totalDeposited; // slot 1
mapping(address => uint256) public balances; // slot 2
}
// V2 — developer inserts new variable in the WRONG place
contract VaultV2 {
address public owner; // slot 0 ✓
bool public paused; // slot 1 ← NEW — PUSHES totalDeposited to slot 2!
uint256 public totalDeposited; // slot 2 — was slot 1 — NOW CORRUPTED
mapping(address => uint256) public balances; // slot 3 — was slot 2 — NOW CORRUPTED
}
After upgrading from V1 to V2:
- paused reads the value that was totalDeposited — likely a large number, so paused is "true"
- totalDeposited in V2 reads from slot 2, which was the balances mapping slot — corrupted
- The balances mapping is now at slot 3 — all user balance reads return 0
Fix: only append to storage, never insert
// V2 — correct storage extension
contract VaultV2 {
address public owner; // slot 0 ✓ (unchanged)
uint256 public totalDeposited; // slot 1 ✓ (unchanged)
mapping(address => uint256) public balances; // slot 2 ✓ (unchanged)
bool public paused; // slot 3 ← NEW, appended safely
}
Rules:
1. Never insert variables between existing ones
2. Never remove variables from the middle (use a deprecated __gap variable if needed)
3. Never change the type of an existing variable
4. Use __gap arrays to reserve space for future additions in base contracts
// Reserve 50 slots for future storage additions in base contract
uint256[50] private __gap;
Attack Vector 4: Function Selector Clash
In the transparent proxy pattern, the proxy has its own functions (upgradeTo, admin). If the implementation has a function with the same 4-byte selector as a proxy admin function, calls to that function will be intercepted by the proxy instead of forwarded.
// Proxy admin function — selector: 0x3659cfe6
function upgradeTo(address newImplementation) external;
// Implementation function that happens to have the same 4-byte selector
function collide_upgradeTo_3659cfe6() external; // same selector!
OpenZeppelin's TransparentUpgradeableProxy handles this by routing based on caller: admin calls are handled by the proxy, user calls are delegated. But this creates its own issue — the admin can never call implementation functions directly.
UUPS eliminates this problem by moving the upgrade logic into the implementation itself.
Storage Layout Checklist
Before deploying or upgrading a proxy contract:
Initial deployment:
- [ ] Implementation constructor calls _disableInitializers()
- [ ] initialize() is called on the proxy (not the bare implementation)
- [ ] EIP-1967 slots used for proxy-internal storage (or use OZ's proxy)
- [ ] Implementation address verified in EIP-1967 slot, not slot 0
Upgrades:
- [ ] Run @openzeppelin/upgrades-plugins storage layout check before deploying V2
- [ ] New variables only appended — never inserted between existing variables
- [ ] No variable type changes at existing slots
- [ ] No variable removal (use deprecated_varName or __gap if needed)
- [ ] V2 initializer guarded with reinitializer(2) — not initializer
Testing:
# Foundry: check storage layout compatibility before upgrade
forge inspect VaultV1 storage-layout
forge inspect VaultV2 storage-layout
# Diff the output — any reordering is a bug
What Scanners Detect
| Vulnerability | Slither | Mythril | Semgrep | AI |
|---|---|---|---|---|
| Uninitialized UUPS implementation | ✅ | ⚠️ | ❌ | ✅ |
| Implementation stored at slot 0 | ⚠️ | ❌ | ❌ | ✅ |
| Storage layout break between versions | ❌ | ❌ | ❌ | ✅ (with V1+V2) |
Missing _disableInitializers() |
✅ | ❌ | ❌ | ✅ |
| Function selector clash | ⚠️ | ❌ | ❌ | ✅ |
Slither has good coverage for the known proxy pitfalls (uninitialized implementations, missing _disableInitializers). Storage layout compatibility across versions requires comparing two contracts simultaneously — something AI analysis handles by reasoning about both layouts, but static tools can't do without explicit plugin support.
Scan your proxy and implementation contracts with ContractScan — the AI engine checks for uninitialized implementation risks, EIP-1967 slot usage, and initialization guard patterns in a single pass.
Related: Proxy Pattern Vulnerabilities: UUPS, Transparent, and Diamond — broader coverage of proxy security beyond storage layout.
Related: Foundry Invariant Fuzzing — test storage invariants across upgrades with fuzz tests.
Important Notes
This 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.