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.
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
// 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 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
| Pattern | Upgrade Logic Location | Key Risks | Gas Cost |
|---|---|---|---|
| Transparent Proxy (OZ) | Proxy contract (admin-only) | Selector clash between admin and user calls | Higher (routing check per call) |
| UUPS | Implementation contract | If implementation is destroyed, proxy is bricked | Lower (no admin check per call) |
| Beacon Proxy | Beacon contract | Beacon compromise upgrades all proxies at once | Higher (extra SLOAD for beacon) |
| Diamond (EIP-2535) | Facets via diamond cuts | Complex storage layout, selector management | Highest (multi-facet routing) |
Uninitialized Implementation Contract
// 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
// 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
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
// 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
| Protocol | Year | Loss | Proxy Bug |
|---|---|---|---|
| Parity Wallet | 2017 | $150M frozen | Unprotected initWallet() on shared library; attacker self-destructed it |
| Wormhole Bridge | 2022 | $320M | Unverified delegatecall target; attacker bypassed signature verification |
| Audius | 2022 | $6M | Storage 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 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
// 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
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: 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-upgradesorforge-upgrades) to automatically validate storage compatibility before any upgrade.