DEV Community

ohmygod
ohmygod

Posted on

The Proxy Upgrade Kill Chain: 5 Vulnerability Patterns Your Auditor Probably Missed — And the Free Toolkit to Find Them

UUPS, Transparent, Diamond, Beacon — every pattern has its own way to die. Here's how to autopsy them before attackers do.


Proxy contracts govern over $48 billion in DeFi TVL. They're the invisible layer between users and logic — the mechanism that lets protocols upgrade without redeploying. And they're one of the most consistently misaudited components in the entire Ethereum ecosystem.

The OWASP Smart Contract Top 10 for 2026 elevated "Proxy and Upgradeability Vulnerabilities" to the #3 spot, up from #7 in 2025. That's not because the patterns are new. It's because the attacks are getting more sophisticated, and the tooling hasn't kept up.

This article introduces five proxy vulnerability patterns that static analyzers routinely miss, and a practical detection toolkit using Slither, Foundry, and Semgrep — all free, all open source.

Pattern 1: Uninitialized UUPS Implementation — The $20M Timebomb

UUPS (Universal Upgradeable Proxy Standard, EIP-1822) puts the upgrade logic in the implementation contract. This is elegant — until someone realizes the implementation itself is a deployed contract that anyone can interact with directly.

The classic scenario:

// Implementation contract deployed but never initialized
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
    function initialize(address owner) external initializer {
        __Ownable_init(owner);
        __UUPSUpgradeable_init();
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}
Enter fullscreen mode Exit fullscreen mode

If the implementation contract's initialize() was never called directly on the implementation address, an attacker can:

  1. Call initialize(attackerAddress) on the raw implementation
  2. Become the owner of the implementation
  3. Call upgradeTo(maliciousContract) on the implementation
  4. selfdestruct the implementation (pre-Dencun) or brick it

Post-Dencun, selfdestruct won't destroy the contract, but the attacker can still upgrade the implementation to a contract that returns garbage, breaking all proxies pointing to it.

Detection with Slither:

# Check if implementation contracts have unprotected initializers
slither . --detect uninitialized-state,unprotected-upgrade \
  --filter-paths "node_modules|test"
Enter fullscreen mode Exit fullscreen mode

Slither's built-in unprotected-upgrade detector catches some cases but misses UUPS implementations deployed separately. Supplement with a custom check:

# slither_uups_check.py — Custom Slither script
from slither import Slither

def check_uups_initialization(target):
    slither = Slither(target)
    for contract in slither.contracts:
        if any(b.name == 'UUPSUpgradeable' for b in contract.inheritance):
            init_funcs = [f for f in contract.functions 
                         if 'initializer' in [m.name for m in f.modifiers]]
            if init_funcs:
                print(f"⚠️  {contract.name} has UUPS + initializer pattern")
                print(f"   Verify implementation at deployment address is initialized")
                for f in init_funcs:
                    print(f"{f.name}() must be called on implementation directly")

check_uups_initialization(".")
Enter fullscreen mode Exit fullscreen mode

Foundry invariant test:

// test/ProxyInvariant.t.sol
function invariant_implementationInitialized() public {
    // Get implementation address from proxy storage slot
    bytes32 slot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
    address impl = address(uint160(uint256(vm.load(address(proxy), slot))));

    // Attempt to initialize — should revert
    vm.expectRevert("Initializable: contract is already initialized");
    VaultV1(impl).initialize(address(this));
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Storage Collision Across Upgrades — The Silent Corruption

When upgrading from V1 to V2, new storage variables must be appended — never inserted. But what happens when V2 inherits from a different base contract that shifts the storage layout?

// V1: storage layout
contract VaultV1 is OwnableUpgradeable, ReentrancyGuardUpgradeable {
    uint256 public totalDeposits;  // slot N
    mapping(address => uint256) public balances;  // slot N+1
}

// V2: developer adds PausableUpgradeable BETWEEN existing bases
contract VaultV2 is OwnableUpgradeable, PausableUpgradeable, ReentrancyGuardUpgradeable {
    uint256 public totalDeposits;  // slot N — but N shifted!
    mapping(address => uint256) public balances;  // slot N+1 — corrupted!
    uint256 public newVariable;  // collides with something else
}
Enter fullscreen mode Exit fullscreen mode

The PausableUpgradeable insertion shifts every subsequent storage slot. User balances silently corrupt. The protocol looks fine until someone tries to withdraw and gets the wrong amount — or zero.

Detection with Foundry + forge inspect:

# Compare storage layouts between versions
forge inspect VaultV1 storage-layout --pretty > v1_layout.txt
forge inspect VaultV2 storage-layout --pretty > v2_layout.txt
diff v1_layout.txt v2_layout.txt
Enter fullscreen mode Exit fullscreen mode

Automated Semgrep rule:

# semgrep-rules/proxy-storage-collision.yaml
rules:
  - id: proxy-inheritance-order-change
    patterns:
      - pattern: |
          contract $V2 is $BASE1, $NEW_BASE, $BASE2 {
            ...
          }
    message: >
      New base contract $NEW_BASE inserted between existing bases.
      This may shift storage layout and corrupt proxy state.
      Always append new bases at the end of the inheritance list.
    severity: ERROR
    languages: [solidity]
    metadata:
      category: proxy-safety
      references:
        - https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable#storage-gaps
Enter fullscreen mode Exit fullscreen mode

The __gap pattern — and why it's not enough:

OpenZeppelin recommends __gap arrays to reserve storage slots:

contract VaultV1 is OwnableUpgradeable {
    uint256 public totalDeposits;
    uint256[49] private __gap; // Reserve 49 slots
}
Enter fullscreen mode Exit fullscreen mode

But __gap only works if every base contract uses it, and if developers correctly reduce the gap size when adding variables. In practice, teams miscalculate gap sizes roughly 15% of the time (based on OpenZeppelin Defender upgrade safety check data from 2025).

Pattern 3: Beacon Proxy Admin Hijacking — The Multiplier

Beacon proxies (EIP-1967) point to a Beacon contract that holds the implementation address. Upgrade the beacon once, and every proxy using that beacon updates simultaneously. This is powerful — and terrifying.

Proxy A ──┐
Proxy B ──┼──→ Beacon ──→ Implementation
Proxy C ──┘
Enter fullscreen mode Exit fullscreen mode

If the Beacon's upgradeTo() has weak access control, an attacker upgrades one contract and drains hundreds of proxy instances simultaneously.

Real pattern found in the wild:

// Beacon with owner set in constructor (not initializer)
contract VaultBeacon is UpgradeableBeacon {
    constructor(address impl) UpgradeableBeacon(impl) {
        // Owner is msg.sender — the deployer EOA
        // If deployer key is compromised, all proxies fall
    }
}
Enter fullscreen mode Exit fullscreen mode

Detection script — enumerate all beacon proxies on-chain:

// Foundry script to check beacon ownership
contract BeaconAudit is Script {
    function run() public view {
        address beacon = 0x...; // Your beacon address

        // Check owner
        address owner = UpgradeableBeacon(beacon).owner();
        console.log("Beacon owner:", owner);

        // Flag if owner is EOA (not multisig/timelock)
        uint256 codeSize;
        assembly { codeSize := extcodesize(owner) }
        if (codeSize == 0) {
            console.log("WARNING: Beacon owned by EOA, not contract");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Initializer Re-entrancy — The Double-Init

The initializer modifier prevents a function from being called twice. But what if an attacker reenters during initialization, before the initialized flag is set?

contract LendingPool is Initializable {
    function initialize(address _token) external initializer {
        token = IERC20(_token);
        // External call during initialization!
        token.approve(address(this), type(uint256).max);
        // If _token is attacker-controlled, they can reenter
        // before initializer modifier sets _initialized = true
    }
}
Enter fullscreen mode Exit fullscreen mode

OpenZeppelin v4.9+ uses a _initializing boolean to prevent this, but many protocols still use older versions or custom initialization patterns.

Semgrep detection rule:

rules:
  - id: initializer-external-call
    patterns:
      - pattern: |
          function $FUNC(...) ... initializer {
            ...
            $CONTRACT.$METHOD(...);
            ...
          }
      - metavariable-regex:
          metavariable: $METHOD
          regex: ^(approve|transfer|call|delegatecall|send)$
    message: >
      External call during initialization may allow reentrancy 
      before the initialized flag is set. Move external calls 
      after all state initialization.
    severity: WARNING
    languages: [solidity]
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Diamond Proxy Selector Collision — The Function Shadowing

Diamond proxies (EIP-2535) route function calls to different facets based on the function selector (first 4 bytes of calldata). Selector collisions — different functions with the same 4-byte selector — let an attacker call a privileged function through what looks like a benign one.

The probability of a random collision is low (1 in 2³²), but selectors can be intentionally bruteforced:

# Find a function signature with the same selector as "transfer(address,uint256)"
# Target selector: 0xa9059cbb
import hashlib
from eth_abi import encode

target = bytes.fromhex("a9059cbb")
for i in range(10_000_000):
    sig = f"exploit_{i}(address,uint256)"
    h = hashlib.new('keccak256', sig.encode()).digest()[:4]
    if h == target:
        print(f"Collision: {sig} → 0x{h.hex()}")
        break
Enter fullscreen mode Exit fullscreen mode

Detection — enumerate all selectors in a Diamond:

// Read all facets and check for selector conflicts
contract DiamondAudit is Script {
    function run() public view {
        IDiamondLoupe loupe = IDiamondLoupe(diamondAddress);
        IDiamondLoupe.Facet[] memory facets = loupe.facets();

        // Build selector → facet mapping and check for duplicates
        mapping(bytes4 => address) seen;
        for (uint i = 0; i < facets.length; i++) {
            for (uint j = 0; j < facets[i].functionSelectors.length; j++) {
                bytes4 sel = facets[i].functionSelectors[j];
                if (seen[sel] != address(0)) {
                    console.log("COLLISION:", uint32(sel));
                    console.log("  Facet 1:", seen[sel]);
                    console.log("  Facet 2:", facets[i].facetAddress);
                }
                seen[sel] = facets[i].facetAddress;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Unified Proxy Audit Checklist

Before approving any proxy upgrade, run through this:

Check Tool Command
Implementation initialized Slither + custom script python slither_uups_check.py
Storage layout preserved Foundry forge inspect --storage-layout + diff
No new bases inserted mid-inheritance Semgrep semgrep --config proxy-storage-collision.yaml
Beacon owner is multisig/timelock Foundry script forge script BeaconAudit
No external calls during init Semgrep semgrep --config initializer-external-call.yaml
No selector collisions (Diamond) Foundry script forge script DiamondAudit
Upgrade function has timelock Manual Check _authorizeUpgrade() / onlyOwner
Storage gaps correctly sized OpenZeppelin Upgrades plugin npx @openzeppelin/upgrades validate

Solana Parallel: Program Upgrade Authority

Solana doesn't use proxies, but program upgrades carry equivalent risks. The program's upgrade authority is the equivalent of a proxy admin:

# Check if a Solana program is upgradeable and who controls it
solana program show <PROGRAM_ID>

# Best practice: transfer upgrade authority to a multisig
solana program set-upgrade-authority <PROGRAM_ID> \
  --new-upgrade-authority <MULTISIG_ADDRESS>
Enter fullscreen mode Exit fullscreen mode

Key audit checks for Solana programs:

  • Is the upgrade authority a multisig (Squads Protocol)?
  • Is there a timelock on upgrades?
  • Has the program been marked immutable (--final)?
  • Does the program's close instruction properly invalidate PDAs?

Conclusion: The Proxy Tax

Every proxy is a trust assumption. Users trust that the admin won't rug. They trust that upgrades won't corrupt storage. They trust that the implementation is initialized. They trust that the timelock actually delays.

Most of these trust assumptions can be verified automatically — but only if you have the right tools configured. The scripts and rules in this article are a starting point. Fork them, customize them for your protocol, and run them on every upgrade PR.

The best proxy exploit is the one you find in CI before it reaches mainnet.


All tools referenced are open source: Slither, Foundry, Semgrep. The Semgrep rules from this article are available in the DeFi Security Research series.

Top comments (0)