🌳 Advanced ⏱️ 55 min

Delegatecall & Proxy Vulnerabilities

Delegatecall is one of the most powerful — and dangerous — features in the EVM. It runs the code of another contract but in the storage context of the calling contract. This is the foundation of proxy-upgradeable contracts: the proxy stores all state, while the implementation holds all logic. This architecture enables upgradeable contracts, but it introduces an entire category of bugs unique to proxies: storage collisions, uninitialized implementations, selector clashes, and storage layout corruption during upgrades.

🚨 Critical

The Parity Wallet bug froze $150M permanently because anyone could call the shared library's initialization function. Wormhole bridge lost $320M because of an unchecked delegatecall to an unverified address. Delegatecall vulnerabilities are among the most catastrophic possible bugs in smart contracts.

Delegatecall: Storage Context Explained

📋 Delegatecall Storage Context — What Gets Modified?
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Implementation { address public owner; // slot 0 uint256 public value; // slot 1 function setOwner(address newOwner) external { owner = newOwner; // Writes to slot 0 } function setValue(uint256 newValue) external { value = newValue; // Writes to slot 1 } } contract ProxyDemo { address public admin; // slot 0 — SAME as Implementation's owner! address public implementation; // slot 1 — SAME as Implementation's value! fallback() external payable { // delegatecall: runs Implementation code in THIS contract's storage (bool ok,) = implementation.delegatecall(msg.data); require(ok); } } // STORAGE COLLISION ATTACK: // When attacker calls proxy.setOwner(attackerAddress): // Implementation.setOwner runs but in Proxy's storage // It writes to SLOT 0 → overwrites Proxy.admin (not Implementation.owner) // Attacker becomes admin of the proxy! // When attacker calls proxy.setValue(maliciousAddress): // It writes to SLOT 1 → overwrites Proxy.implementation // Now all future calls go to attacker's malicious implementation!

EIP-1967: Standardized Proxy Storage Slots

EIP-1967 solves storage collision between proxy admin variables and implementation variables by using predetermined "random" slots derived from a hash — far away from slot 0, 1, 2 that user variables occupy.

🔒 EIP-1967 Standardized Slots
// EIP-1967 slots are derived from well-known strings, making them // collision-resistant against normal storage layout (slots 0, 1, 2...) // Implementation slot: bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; // Admin slot: bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; contract EIP1967Proxy { function _getImplementation() internal view returns (address impl) { assembly { impl := sload(IMPL_SLOT) } } function _setImplementation(address newImpl) internal { assembly { sstore(IMPL_SLOT, newImpl) } } fallback() external payable { address impl = _getImplementation(); assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) if iszero(result) { revert(0, returndatasize()) } return(0, returndatasize()) } } }

Proxy Patterns Comparison

PatternUpgrade Logic LocationKey RisksGas Cost
Transparent Proxy (OZ)Proxy contract (admin-only)Selector clash between admin and user callsHigher (routing check per call)
UUPSImplementation contractIf implementation is destroyed, proxy is brickedLower (no admin check per call)
Beacon ProxyBeacon contractBeacon compromise upgrades all proxies at onceHigher (extra SLOAD for beacon)
Diamond (EIP-2535)Facets via diamond cutsComplex storage layout, selector managementHighest (multi-facet routing)

Uninitialized Implementation Contract

⚠️ Parity Hack Pattern — Uninitialized Library
// The Parity Wallet library was deployed separately from user wallets // User wallets delegatecall into the library for wallet logic // The library's initWallet() was callable by ANYONE // An attacker called initWallet([attackerAddress]), became library owner // Then called kill(attackerAddress) which called selfdestruct on the library // All user wallets that relied on the library were permanently bricked // ~514 wallets permanently frozen, holding $150M+ contract VulnerableLibrary { address public owner; bool private initialized; // ❌ No protection — anyone can call this and become owner function initWallet(address[] calldata _owners) external { owner = msg.sender; initialized = true; } function kill(address payable to) external { require(msg.sender == owner); selfdestruct(to); // Destroys the library — all proxies die } } // ✅ FIX: Disable initializers on the implementation contract import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; contract SecureImplementation is Initializable { address public owner; // ✅ Locks the implementation so initialize() can never be called on it constructor() { _disableInitializers(); } // ✅ Can only be called once, via the proxy, using the initializer modifier function initialize(address _owner) external initializer { owner = _owner; } }

Storage Layout Changes During Upgrades

⚠️ Storage Corruption on Upgrade — Slot Shift Bug
// VersionA — original implementation contract TokenV1 { address public owner; // slot 0 uint256 public totalSupply; // slot 1 mapping(address => uint256) balances; // slot 2 } // VersionB — upgrade INSERTS a new variable in the middle contract TokenV2_BROKEN { address public owner; // slot 0 ✅ bool public paused; // slot 1 ← ❌ NEW! Shifts everything below uint256 public totalSupply; // slot 2 ← Was slot 1! totalSupply is now corrupted mapping(address => uint256) balances; // slot 3 ← Was slot 2! All balances are corrupted } // ✅ FIXED: Only APPEND new variables at the end contract TokenV2_FIXED { address public owner; // slot 0 ✅ uint256 public totalSupply; // slot 1 ✅ mapping(address => uint256) balances; // slot 2 ✅ bool public paused; // slot 3 ← APPENDED at end ✅ } // ✅ BEST PRACTICE: Gap pattern reserves space for future variables contract TokenWithGap { address public owner; // slot 0 uint256 public totalSupply; // slot 1 mapping(address => uint256) balances; // slot 2 uint256[47] private __gap; // slots 3-49 reserved for future variables // New variables can be added in V2/V3 by replacing gap entries }

Delegatecall to User-Supplied Address: Arbitrary Execution

⚠️ Unchecked delegatecall — Arbitrary Code Execution
contract DelegatecallVulnerable { address public owner; mapping(address => uint256) public balances; // ❌ CRITICAL: Allows delegatecall to ANY address // Attacker passes their malicious contract as target // Malicious contract calls selfdestruct(attacker) in THIS contract's context // Result: this contract is destroyed forever function execute(address target, bytes calldata data) external { require(msg.sender == owner, "Not owner"); // ❌ target is caller-controlled — arbitrary code execution target.delegatecall(data); } } // Attacker's payload contract contract MaliciousPayload { function destroy(address payable recipient) external { // Called via delegatecall — runs in victim's context // selfdestruct destroys the victim proxy permanently selfdestruct(recipient); } } // ✅ FIX: Only delegatecall to a verified implementation address contract SafeDelegatecall { address public owner; mapping(address => bool) public approvedImplementations; function addImplementation(address impl) external { require(msg.sender == owner); approvedImplementations[impl] = true; } function execute(address target, bytes calldata data) external { require(msg.sender == owner); require(approvedImplementations[target], "Unapproved implementation"); target.delegatecall(data); } }

Foundry Test: Storage Slot Collision Detection

🧪 Foundry Test — Storage Collision and Proxy Slot Check
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; contract ProxyStorageTest is Test { bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; EIP1967Proxy public proxy; Implementation public impl; function setUp() public { impl = new Implementation(); proxy = new EIP1967Proxy(address(impl)); } /// @notice Verify implementation is stored at EIP-1967 slot, not slot 0 function test_proxyStorageAtCorrectSlot() public { bytes32 slotValue = vm.load(address(proxy), IMPL_SLOT); address storedImpl = address(uint160(uint256(slotValue))); assertEq(storedImpl, address(impl), "Implementation at wrong slot"); } /// @notice Verify no storage collision at slot 0 function test_noCollisionAtSlotZero() public { bytes32 slot0 = vm.load(address(proxy), bytes32(0)); // Slot 0 should NOT contain the implementation address assertNotEq( address(uint160(uint256(slot0))), address(impl), "Implementation should NOT be at slot 0" ); } /// @notice Storage upgrade safety check — layout must match function test_upgradeLayoutCompatibility() public { // Write some state through proxy Implementation(address(proxy)).setValue(42); // Upgrade to V2 (compatible layout — appended variables only) ImplementationV2 newImpl = new ImplementationV2(); proxy.upgradeTo(address(newImpl)); // Original value must be preserved after upgrade assertEq(ImplementationV2(address(proxy)).value(), 42, "Value must persist after upgrade"); } }

Real-World Exploits

ProtocolYearLossProxy Bug
Parity Wallet2017$150M frozenUnprotected initWallet() on shared library; attacker self-destructed it
Wormhole Bridge2022$320MUnverified delegatecall target; attacker bypassed signature verification
Audius2022$6MStorage collision between proxy admin slot and governance storage variable

Key Takeaways

  • Delegatecall runs code in the caller's storage. Every state write in the called contract modifies the calling proxy's storage — not the implementation's. Storage layout must be identical between proxy and implementation.
  • Always call _disableInitializers() in implementation constructors. An unprotected implementation can be initialized by anyone. In the Parity case, the attacker then called selfdestruct on it, bricking all proxies.
  • Use EIP-1967 standard storage slots. These prevent collisions between proxy admin variables and user-facing implementation variables. Never put admin variables at slots 0, 1, 2 in a proxy.
  • Only append new variables in upgrades — never insert. Adding a variable in the middle shifts all subsequent variables to different slots, corrupting all stored data. The gap pattern reserves space to prevent this.
  • Never delegatecall to a user-supplied address. This is arbitrary code execution in your contract's context. An attacker can call selfdestruct and permanently destroy the proxy.
  • Verify storage layout compatibility before every upgrade. Use Hardhat's proxy upgrade validation or OpenZeppelin's upgrades plugin, which automatically checks for incompatible storage changes.

Proxy Upgrade Audit Checklist

When auditing any upgradeable contract system, work through these checks systematically before completing the review:

🔍 Proxy Security Audit Steps
// PROXY AUDIT CHECKLIST — run through for EVERY upgradeable contract // 1. INITIALIZATION // □ Does the implementation constructor call _disableInitializers()? // □ Is initialize() protected by the initializer modifier? // □ Does initialize() set all critical state (owner, roles, addresses)? // □ Is there a reinitializer for each major version upgrade? // 2. STORAGE LAYOUT // □ Does the proxy use EIP-1967 standardized slots? // □ Are there any user variables in the proxy contract itself? // □ Does the implementation use gap variables for future additions? // □ If upgrading: are all new variables appended, never inserted? // 3. ACCESS CONTROL // □ Is the upgrade function protected by onlyOwner or similar? // □ Is the owner a multisig wallet (not an EOA)? // □ Is there a timelock between proposal and execution of upgrades? // 4. DELEGATECALL SAFETY // □ Are there any delegatecall calls to user-supplied addresses? // □ Is the implementation address validated before upgrading? // □ Can the implementation call selfdestruct in the proxy context? // 5. FUNCTION SELECTOR CLASHES // □ For Transparent Proxy: does admin routing work correctly? // □ Are there any 4-byte selector collisions between proxy and impl? // □ For UUPS: does the implementation have an upgradeTo() function?

Function Selector Clashes in Transparent Proxies

⚠️ Selector Clash — Admin Calls the Wrong Function
// Function selectors are the first 4 bytes of keccak256(function signature) // Two different functions can have the same 4-byte selector (collision) // Example: "clash()" and some implementation function share the same 4 bytes // When admin calls proxy.clash() expecting proxy behavior, // the implementation function runs instead (or vice versa) // Transparent Proxy solution: route based on caller identity // If msg.sender == admin: execute proxy function directly // If msg.sender != admin: delegatecall to implementation // Admin can never accidentally call implementation functions contract TransparentProxy { address public immutable admin; address public implementation; modifier ifAdmin() { if (msg.sender == admin) { _; // Admin: execute proxy-level function } else { _fallback(); // Non-admin: delegatecall to implementation } } // This function is ONLY callable by admin // Non-admins never reach it — they always go through delegatecall function upgradeTo(address newImpl) external ifAdmin { implementation = newImpl; } function _fallback() internal { 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()) } } } }

UUPS: Upgrade Logic in the Implementation

⚠️ UUPS Risk — Bricked Proxy if Implementation Is Destroyed
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; contract UUPSImpl is UUPSUpgradeable, OwnableUpgradeable { constructor() { _disableInitializers(); } function initialize(address owner) external initializer { __Ownable_init(owner); __UUPSUpgradeable_init(); } // ✅ Only owner can authorize upgrades function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} // ❌ RISK: If there is no _authorizeUpgrade or it is empty, // ANYONE can upgrade the proxy to a malicious implementation // ❌ RISK: If implementation calls selfdestruct (even indirectly), // the implementation contract is destroyed. The proxy still exists // but delegatecall to the destroyed address always returns (false, "") // Proxy is permanently bricked — no upgrade possible, no calls work }

Storage Layout Migration Safety

When upgrading a proxy, the new implementation's storage layout must be perfectly compatible with the previous version. Any change that shifts existing slot assignments — adding a variable in the middle, changing a variable's type to a different size, removing a variable — causes storage collisions that silently corrupt data or expose attack vectors.

Safe vs Unsafe Storage Layout Changes
// ✅ SAFE: Only append new storage at the end contract ImplementationV1 { address public owner; // Slot 0 uint256 public value; // Slot 1 bool public paused; // Slot 2 } contract ImplementationV2Safe { address public owner; // Slot 0 — SAME ✅ uint256 public value; // Slot 1 — SAME ✅ bool public paused; // Slot 2 — SAME ✅ uint256 public newFee; // Slot 3 — NEW, appended ✅ } // ❌ UNSAFE: Inserting variable shifts all subsequent slots contract ImplementationV2Broken { address public owner; // Slot 0 — SAME ✅ uint256 public newFee; // Slot 1 — ⚠️ WAS: value (now corrupted!) uint256 public value; // Slot 2 — ⚠️ WAS: paused (type mismatch!) bool public paused; // Slot 3 — ⚠️ Was at slot 2, now orphaned } // ✅ BEST PRACTICE: Use storage gaps to reserve upgrade space contract ImplementationWithGap { address public owner; uint256 public value; bool public paused; // 47 slots reserved for future variables in this base contract uint256[47] private __gap; }

Key Takeaways

  • Delegatecall executes code in the caller's storage context — the called contract reads and writes to the calling contract's storage slots, not its own.
  • Storage layout compatibility is a hard constraint for proxy upgrades — never insert variables in the middle; only append at the end or use storage gaps.
  • EIP-1967 standardizes proxy implementation and admin slots to avoid collisions with implementation storage — always use EIP-1967 compliant proxy patterns.
  • Uninitializated implementation contracts are exploitable — the Parity hack froze $150M by calling initWallet() on the uninitialized library contract.
  • UUPS proxies risk being permanently bricked if the implementation contract loses its upgrade function (via selfdestruct or by deploying an implementation without _authorizeUpgrade).
  • Use OpenZeppelin's upgrade plugin (@openzeppelin/hardhat-upgrades or forge-upgrades) to automatically validate storage compatibility before any upgrade.