Proxy and Upgrade Vulnerabilities: The $300M+ Risk

Upgradeability is one of the few genuinely novel challenges in smart contract security. Traditional software can be patched in-place; deployed bytecode cannot. The proxy pattern solves this by routing calls through a stable address to a replaceable logic contract. The cost of that flexibility is a set of failure modes with no equivalent in conventional software.

The fundamental architecture:

User → Proxy Contract (holds state) → delegatecall → Implementation (holds logic)

When a call reaches the proxy’s fallback, it delegatecalls the implementation — executing implementation logic but reading and writing the proxy’s storage. The proxy and implementation must agree on a shared storage layout, and that agreement must be maintained across every upgrade. When it breaks, so does the contract.

Storage Collision

The most common proxy vulnerability. Proxies need to store the implementation address somewhere. If they use normal storage slots (slot 0, slot 1…), those slots collide with the implementation’s variables.

The wrong way:

contract BadProxy {
    address public implementation; // Slot 0
    address public admin;          // Slot 1

    fallback() external payable {
        _delegate(implementation);
    }
}

contract Implementation {
    uint256 public value;  // Slot 0 — same slot as proxy's implementation address
    address public owner;  // Slot 1 — same slot as proxy's admin

    function setValue(uint256 _value) external {
        value = _value; // This overwrites the proxy's implementation address
    }
}

Calling setValue() through the proxy writes to slot 0 in the proxy’s storage context (because delegatecall), overwriting the implementation address. The next call to the proxy will delegatecall to whatever _value was set to. If that address is an attacker’s contract, it’s full compromise.

EIP-1967 resolves this with deterministic but non-sequential storage slots derived from well-known hash values:

// Implementation address: bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
bytes32 constant IMPLEMENTATION_SLOT =
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

// Admin address: bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
bytes32 constant ADMIN_SLOT =
    0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

These slots are astronomically unlikely to be used by ordinary contract variables, because no sequential allocation of storage slots reaches them. Any proxy that stores implementation data in lower-numbered slots instead of EIP-1967 slots is a collision waiting to happen.

Furucombo (February 2021, $15M): An attacker exploited a storage collision where the proxy’s implementation slot coincided with the implementation contract’s handler variable. By calling setHandler() with their own contract address, they overwrote the proxy’s implementation pointer and redirected all calls to a drain contract.

Uninitialized Implementations

Proxy patterns can’t use constructors for initialization — constructor code runs at deployment time on the implementation address, not through the proxy. Instead, upgradeable contracts use initialize() functions called once after deployment through the proxy.

The problem: the implementation contract itself is also deployed on-chain. If initialize() was never called on the implementation directly (only on the proxy), an attacker can call it.

contract UUPSImplementation {
    address public owner;

    function initialize(address _owner) public {
        // No initializer modifier — callable more than once if not flagged
        owner = _owner;
    }

    function upgrade(address newImpl) public {
        require(msg.sender == owner, "Not owner");
        // UUPS upgrade logic
    }
}

Attack path:

  1. Identify that the implementation contract was never initialized directly
  2. Call initialize(attacker_address) on the implementation (not the proxy)
  3. Become owner of the implementation
  4. Call upgrade(malicious_contract) on the implementation
  5. Depending on the UUPS pattern, this may affect proxies pointing to this implementation

OpenZeppelin’s _disableInitializers() in the constructor prevents this:

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract SecureUUPSImplementation is Initializable {
    address public owner;

    constructor() {
        _disableInitializers(); // Implementation can never be initialized
    }

    function initialize(address _owner) public initializer {
        owner = _owner;
    }
}

Audius (July 2022, $6M): The implementation contract for a governance module was deployed without calling _disableInitializers(). An attacker initialized the implementation directly, gained admin rights, and used the upgrade path to drain $6M.

Storage Layout Breakage During Upgrades

Upgrading the implementation to a new version doesn’t migrate state — the proxy’s storage remains unchanged. If V2’s storage layout differs from V1’s, existing state is reinterpreted through the wrong schema.

// V1 layout
contract V1 {
    uint256 public value;  // Slot 0
    address public owner;  // Slot 1
    bool public paused;    // Slot 2
}

// V2 — BREAKS STORAGE
contract V2 {
    uint256 public newValue; // Slot 0 — was value (ok by naming accident)
    uint256 public value;    // Slot 1 — was owner (address now treated as uint256)
    address public owner;    // Slot 2 — was paused (bool now treated as address)
    bool public paused;      // Slot 3 — uninitialized
}

After upgrading to V2:

  • owner reads slot 2, which holds what used to be paused — almost certainly address(0) or address(1). Access control is broken.
  • paused reads slot 3, which is uninitialized storage. Returns false unconditionally.

The only safe upgrade operation is appending new variables at the end. Never insert, never reorder, never change types.

// V2 — SAFE
contract V2 {
    uint256 public value;    // Slot 0 (preserved)
    address public owner;    // Slot 1 (preserved)
    bool public paused;      // Slot 2 (preserved)
    uint256 public newValue; // Slot 3 (appended — safe)
}

For contracts that anticipate future upgrades, storage gaps reserve space at the end of each contract so inherited contracts can add variables without colliding:

contract V1 {
    uint256 public value;
    address public owner;
    bool public paused;

    uint256[50] private __gap; // Reserve 50 slots for future use
}

contract V2 {
    uint256 public value;
    address public owner;
    bool public paused;

    bool public newFlag; // Uses one slot from the gap

    uint256[49] private __gap; // Gap reduced by 1
}

Selfdestruct in Implementations

If an implementation contract can be selfdestructed, every proxy pointing to it stops working permanently. No code runs when delegatecalling an empty address; all proxied calls silently fail or return empty data.

contract Implementation {
    address public owner;

    function destroy() public {
        require(msg.sender == owner, "Not owner");
        selfdestruct(payable(owner));
    }
}

Combined with the uninitialized implementation vulnerability: attacker initializes the implementation, becomes owner, calls destroy(). Every proxy pointing to this implementation is bricked. Funds in those proxies become unrecoverable.

The Parity Wallet incident in November 2017 followed exactly this path. A user accidentally called initWallet() on the library contract (equivalent to the implementation), became owner, then called kill(). 587 multisig wallets were permanently frozen. The funds — over $150M at 2017 prices — remain locked today.

The fix is to never include selfdestruct in upgradeable implementations. If you need a “disable” mechanism, use a pausing pattern:

contract SafeImplementation {
    bool public disabled;

    modifier notDisabled() {
        require(!disabled, "Contract disabled");
        _;
    }

    function disable() public onlyOwner {
        disabled = true;
    }
}

Function Selector Collision

When a proxy exposes its own administrative functions (like upgradeTo() or admin()), those functions have 4-byte selectors. If an implementation contract has a function whose selector happens to match a proxy function, calls to that selector are intercepted by the proxy and never reach the implementation.

contract Proxy {
    function admin() external returns (address) {
        return _admin; // selector: 0xf851a440
    }

    fallback() external payable {
        _delegate(implementation);
    }
}

contract Implementation {
    // selector 0xf851a440 — same as proxy's admin()
    function admin() external returns (address) {
        return implementationAdmin;
    }
}

Any call to admin() hits the proxy’s function and never reaches the implementation’s version. This can mean an implementation function is silently unreachable, or that access control intended to gate implementation behavior is bypassed.

The transparent proxy pattern (OpenZeppelin’s TransparentUpgradeableProxy) solves this by routing calls based on caller identity: admin calls go to proxy functions, all other callers are forwarded to the implementation via delegatecall. UUPS shifts upgrade responsibility to the implementation itself, so the proxy has no admin functions to collide.

Detection Approach

Detecting proxy vulnerabilities requires analyzing both contracts together. The proxy and implementation are separate deployed artifacts, but their storage layouts must be analyzed as a unit.

Storage layout analysis: Extract the storage slots used by the proxy (both its declared variables and any EIP-1967 slots it reads/writes via assembly). Extract the storage slots used by the implementation. Flag any overlap where an implementation write operation targets the same slot the proxy uses for critical data.

Initialization check: Identify contracts that have a public initialize() function without an initializer modifier, or where the constructor does not call _disableInitializers(). Cross-reference against known proxy deployment patterns.

Upgrade compatibility: For contracts with version history, compare the storage layout of successive versions. Report any slot that changed type, moved position, or was removed.

Selfdestruct in implementation: The SELFDESTRUCT opcode is detectable in bytecode. Flag its presence in any contract identified as a proxy implementation.

Selector collision: Compute the 4-byte selectors of all proxy-exposed functions and all implementation functions. Flag any matches.

The bytecode-level approach matters here: storage layout analysis cannot rely on source code because the mapping of variable names to slot numbers is a compiler artifact. Two different source layouts can produce the same slot assignments, or the same source layout can produce different assignments depending on compiler version and optimization settings. Analyzing the actual SSTORE/SLOAD patterns in bytecode gives ground truth.

Proxy Security Checklist

For developers building upgradeable contracts:

  • Use EIP-1967 standard slots for implementation and admin storage — never low-numbered slots
  • Always call _disableInitializers() in the implementation constructor
  • Use storage gaps (uint256[N] private __gap) in base contracts
  • Only append new variables in upgrades; never insert, reorder, or change existing types
  • No selfdestruct in implementations; use pause/disable patterns instead
  • Use OpenZeppelin’s audited proxy contracts rather than custom implementations
  • Run storage compatibility tests before deploying upgrades — compare layout before and after

References

  1. EIP-1967: Standard Proxy Storage Slots
  2. EIP-1822: Universal Upgradeable Proxy Standard
  3. OpenZeppelin Proxy Documentation
  4. SWC-112: Delegatecall to Untrusted Callee