proxy-upgrade-safety
Proxy & Upgrade Safety
Detect vulnerabilities specific to upgradeable proxy architectures — the most widely deployed contract pattern on Ethereum (54.2% of contracts). Proxy bugs cause storage corruption, unauthorized upgrades, and complete contract takeover.
When to Use
- Auditing any contract using proxy/implementation pattern (Transparent, UUPS, Beacon, Diamond)
- Reviewing implementation contract upgrades for storage layout compatibility
- Analyzing
delegatecall-based architectures and library usage - Verifying initialization safety (can
initialize()be front-run?) - Checking Diamond (EIP-2535) facet management for selector collisions
When NOT to Use
- Non-upgradeable contracts without proxy patterns
- Pure logic audits without proxy architecture (use behavioral-state-analysis)
- Token standard compliance (use external-call-safety)
Core Concept: The Delegatecall Storage Model
When Proxy calls Implementation via delegatecall:
┌─────────────────────┐ delegatecall ┌─────────────────────┐
│ PROXY │ ──────────────────→ │ IMPLEMENTATION │
│ │ │ │
│ Storage: │ Implementation code │ Code only: │
│ slot 0: admin │ executes in proxy's │ No persistent │
│ slot 1: impl addr │ storage context │ storage │
│ slot 2: user data │ │ │
│ slot 3: user data │ │ │
└─────────────────────┘ └─────────────────────┘
Key Rule: The implementation's code reads/writes the PROXY's storage slots. If storage layouts don't match, data corruption occurs.
Five Vulnerability Classes
Class 1: Storage Layout Collision
Between Proxy and Implementation:
// Proxy contract
contract Proxy {
address public admin; // slot 0
address public implementation; // slot 1
fallback() external payable {
delegatecall(implementation);
}
}
// Implementation contract
contract ImplementationV1 {
uint256 public totalSupply; // slot 0 — COLLIDES with admin!
mapping(address => uint256) public balances; // slot 1 — COLLIDES with implementation!
}
Detection: Compare storage slot assignments between proxy and implementation. Any overlap = CRITICAL vulnerability.
Between Implementation Versions:
// V1
contract ImplementationV1 {
uint256 public totalSupply; // slot 0
address public owner; // slot 1
mapping(address => uint256) balances; // slot 2
}
// V2 — DANGEROUS: inserted variable before existing ones
contract ImplementationV2 {
bool public paused; // slot 0 — COLLIDES with totalSupply!
uint256 public totalSupply; // slot 1 — COLLIDES with owner!
address public owner; // slot 2 — COLLIDES with balances!
mapping(address => uint256) balances; // slot 3
}
Safe V2:
contract ImplementationV2 {
uint256 public totalSupply; // slot 0 — same
address public owner; // slot 1 — same
mapping(address => uint256) balances; // slot 2 — same
bool public paused; // slot 3 — NEW, appended at end
}
Class 2: Uninitialized Implementation
Proxy pattern uses initialize() instead of constructor(). If the implementation contract itself is not initialized, an attacker can call initialize() directly on it.
contract ImplementationV1 is Initializable {
address public owner;
function initialize(address _owner) external initializer {
owner = _owner;
}
function selfDestruct() external {
require(msg.sender == owner);
selfdestruct(payable(msg.sender));
}
}
Attack:
1. Implementation deployed but initialize() not called on impl itself
2. Attacker calls implementation.initialize(attacker_address)
3. Attacker is now owner of the IMPLEMENTATION contract
4. Attacker calls selfDestruct() on implementation
5. Proxy now delegatecalls to destroyed contract
6. ALL proxy calls return empty data — contract bricked
Detection:
For each implementation contract:
1. Does it have initialize() or any initializer function?
2. Was initialize() called on the implementation address (not just the proxy)?
3. Does the constructor call _disableInitializers()?
4. If no → UNINITIALIZED IMPLEMENTATION vulnerability
Class 3: Function Selector Clashing
Solidity function selectors are only 4 bytes. Collisions between proxy admin functions and implementation functions cause unexpected behavior.
// Proxy has admin function
function upgrade(address newImpl) external; // selector: 0x0900f010
// Implementation has user function with SAME selector
function collide(uint256 amount) external; // selector: 0x0900f010
// When user calls collide(), proxy intercepts it as upgrade()!
Transparent Proxy Mitigation: Admin can only call admin functions; users can only call implementation functions. But this must be correctly implemented.
Detection:
For each function in the proxy:
selector_proxy = keccak256(signature)[:4]
For each function in the implementation:
selector_impl = keccak256(signature)[:4]
If selector_proxy == selector_impl:
→ FUNCTION SELECTOR CLASH
Class 4: Missing Upgrade Authorization
UUPS Pattern: The upgrade logic lives in the implementation, not the proxy. If _authorizeUpgrade() is not properly protected, anyone can upgrade.
// VULNERABLE: Missing access control on upgrade
contract ImplementationV1 is UUPSUpgradeable {
function _authorizeUpgrade(address newImplementation) internal override {
// NO ACCESS CHECK! Anyone can upgrade!
}
}
// SAFE
contract ImplementationV1 is UUPSUpgradeable, OwnableUpgradeable {
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
// Only owner can upgrade
}
}
Detection:
For UUPS proxies:
1. Find _authorizeUpgrade() function
2. Check for access control (onlyOwner, onlyRole, require(msg.sender == admin))
3. If no access control → CRITICAL: unauthorized upgrade
4. Also check: Can _authorizeUpgrade be removed in a new version?
→ If V2 doesn't inherit UUPSUpgradeable → proxy becomes non-upgradeable (bricked)
Class 5: Delegatecall Context Confusion
Code executing via delegatecall runs with the caller's msg.sender, msg.value, and storage. Misunderstanding this context creates vulnerabilities.
// Implementation stores admin in its own constructor
contract Implementation {
address public admin;
constructor() {
admin = msg.sender; // Sets admin in IMPLEMENTATION storage
// When called via delegatecall, this is proxy's storage
// BUT constructor only runs during deployment, not via proxy!
}
}
Key Rule: Constructors NEVER run via delegatecall. Any state set in the constructor exists only in the implementation's own storage, not the proxy's.
Three-Phase Detection Architecture
Phase 1: Proxy Pattern Classification
Identify which proxy pattern is used.
| Pattern | Key Indicator | Upgrade Location |
|---|---|---|
| Transparent (EIP-1967) | _IMPLEMENTATION_SLOT at keccak256('eip1967.proxy.implementation') - 1 |
Proxy contract |
| UUPS (EIP-1822) | proxiableUUID() in implementation |
Implementation contract |
| Beacon | _BEACON_SLOT at keccak256('eip1967.proxy.beacon') - 1 |
Beacon contract |
| Diamond (EIP-2535) | diamondCut() function, facet registry |
Diamond contract |
| Minimal (EIP-1167) | Clone bytecode pattern 363d3d373d3d3d363d73... |
Not upgradeable |
Phase 2: Storage Layout Analysis
Build the complete storage map for proxy and all implementation versions.
Algorithm:
For each contract C (proxy, impl_v1, impl_v2, ...):
storage_map[C] = {}
slot = 0
For each state variable V in C (in declaration order):
storage_map[C][slot] = V
slot += size_of(V) // Consider packing for <32 byte types
For each slot S:
If storage_map[proxy][S] conflicts with storage_map[impl][S]:
→ PROXY-IMPL COLLISION at slot S
If storage_map[impl_v1][S] != storage_map[impl_v2][S]:
→ UPGRADE COLLISION at slot S
Special Cases:
- Mappings and dynamic arrays: hash-based slot calculation
- Struct packing: multiple variables per slot
- Inherited contracts: storage order follows C3 linearization
- Gap variables (
uint256[50] private __gap): reserved space for upgrades
Phase 3: Initialization & Upgrade Path Verification
Initialization Checks:
1. Does implementation use Initializable?
2. Is initialize() protected by initializer modifier?
3. Does constructor call _disableInitializers()?
4. Can initialize() be called more than once? (reinitializer)
5. Was initialize() called on impl address directly?
Upgrade Path Checks:
1. Is upgrade function access-controlled?
2. Does new impl maintain storage layout compatibility?
3. Does new impl still support upgrades? (UUPS: must inherit UUPSUpgradeable)
4. Is there a timelock on upgrades?
5. Can upgrade + initialize race condition occur?
Workflow
Task Progress:
- [ ] Step 1: Identify proxy pattern (Transparent, UUPS, Beacon, Diamond, Minimal)
- [ ] Step 2: Map storage layout of proxy contract
- [ ] Step 3: Map storage layout of all implementation versions
- [ ] Step 4: Check for storage collisions (proxy-impl and version-version)
- [ ] Step 5: Verify initialization safety (disableInitializers, initializer modifier)
- [ ] Step 6: Check function selector clashing (proxy admin vs impl functions)
- [ ] Step 7: Verify upgrade authorization (access control on upgrade path)
- [ ] Step 8: Check delegatecall context safety
- [ ] Step 9: Score findings and generate report
Output Format
## Proxy & Upgrade Safety Report
### Finding: [Title]
**Contract:** `ContractName` at `Contract.sol:L42`
**Proxy Pattern:** [Transparent | UUPS | Beacon | Diamond | Minimal]
**Class:** [Storage Collision | Uninitialized Impl | Selector Clash | Missing Auth | Context Confusion]
**Severity:** [CRITICAL | HIGH | MEDIUM]
**Issue:**
[Description of the proxy-specific vulnerability]
**Storage Layout:**
Proxy slot 0: `[proxy variable]`
Impl slot 0: `[impl variable]` ← COLLISION
**Attack Scenario:**
1. [Step-by-step exploit]
**Impact:**
[Storage corruption, unauthorized upgrade, contract bricked, etc.]
**Recommendation:**
[Use EIP-1967 slots, add _disableInitializers, add access control, append-only storage]
Quick Detection Checklist
- Does the proxy store admin/implementation at standard EIP-1967 slots (not regular slots)?
- Does the implementation's
constructor()call_disableInitializers()? - Does
initialize()use theinitializermodifier? - Do implementation upgrades ONLY append new state variables (never insert or reorder)?
- Is there a
__gapvariable for future storage expansion in base contracts? - For UUPS: Does
_authorizeUpgrade()have proper access control? - For UUPS: Does every new implementation still inherit
UUPSUpgradeable? - Are there any function selector collisions between proxy and implementation?
- Is there a timelock or multisig on the upgrade path?
For proxy pattern details, see {baseDir}/references/proxy-patterns.md. For storage collision detection, see {baseDir}/references/storage-collision-detection.md.
Rationalizations to Reject
- "We use OpenZeppelin's proxy" → OZ provides the framework, but storage layout compatibility is YOUR responsibility
- "The implementation is initialized" → Was it initialized on the IMPLEMENTATION address, or only through the proxy?
- "Constructor sets the admin" → Constructors don't run via delegatecall; admin is only set in impl's own storage
- "We tested the upgrade" → Did you verify storage layout slot-by-slot? One reordered variable corrupts everything
- "UUPS is safer than Transparent" → Only if
_authorizeUpgradeis properly protected AND maintained across upgrades - "The gap variable protects us" → Only if inherited contracts also have gaps and you never exceed the gap size