Upgradeable Proxies: When the Upgrade Is the Attack

10 min read

August 1, 2026

Site Updates

💬 Comments Available

Drop your thoughts in the comments below! Found a bug or have feedback? Let me know.

🚧 Recent Migration

Migrated from Ghost to Astro. Spot any formatting issues? Report them!

Upgradeable Proxies: When the Upgrade Is the Attack

Table of contents

Contents

👋 Introduction

Hey everyone!

Last week we poisoned the install step. This week we go back to smart contracts, where the mechanism built to fix bugs is itself the bug.

Deployed contract bytecode is immutable, so a flaw normally can’t be patched without migrating every user to a new address. Upgradeable proxies solve that by splitting a contract in two: a proxy that holds the funds and a permanent address, and a swappable implementation that holds the logic. Own the pointer that decides which implementation runs, and you own the funds behind it. This is the most powerful single primitive in a DeFi codebase, and the most catastrophic when it slips.

Parity proved the stakes in 2017. One unprivileged user called a function nobody thought was reachable, took ownership of a shared library, and destroyed it. 513,774 ETH froze instantly, unrecoverable to this day. Nobody stole it. It just became permanently unusable.

This week: how the proxy and delegatecall actually work, storage collisions that overwrite the admin pointer, the uninitialized implementation that hands an attacker ownership, the selfdestruct that bricks an entire fleet, and the mempool bots initializing fresh proxies before their own deployers can.

Let’s get into it 👇

🔀 The Proxy and the Delegatecall

The whole pattern rests on one opcode: delegatecall. When the proxy receives a call, it forwards it to the implementation with delegatecall, which runs the implementation’s bytecode in the proxy’s storage context. The logic executes, but every storage read and write hits the proxy’s slots, not the implementation’s. The implementation is just borrowed code. The proxy is where the state and the money live.

// Simplified proxy: run the implementation's code against OUR storage
fallback() external payable {
    address impl = _implementation;   // a storage slot the proxy controls
    assembly {
        calldatacopy(0, 0, calldatasize())
        let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
    }
}

To upgrade, you just overwrite _implementation with the address of a new logic contract. The patterns differ in where that upgrade logic lives. Transparent proxies keep it on the proxy. UUPS moves it into the implementation itself for a leaner proxy. Beacons point a whole fleet of proxies at one shared address. Each choice moves the attack surface somewhere else, and the rest of this issue is a tour of where it lands.

🗄️ Storage Collisions

Here is the trap that delegatecall sets. Storage is addressed by slot index, not by variable name. The proxy and the implementation must agree on what lives in every slot, and they have no shared compiler to enforce it. Disagree, and a write meant for one variable silently lands on another.

The classic collision: a naive proxy keeps its admin or implementation pointer in slot 0, and the logic contract also uses slot 0 for its first state variable. Now a normal function writing that variable overwrites the proxy’s admin pointer.

contract Proxy       { address implementation; /* slot 0 */ }
contract Logic       { address owner;          /* slot 0 - COLLISION */ }
// Logic.setOwner() writes slot 0, silently overwriting the proxy's implementation pointer

Audius lost around 1 million USD in AUDIO tokens to this in 2022. Their proxy stored the admin in slot 0, colliding with OpenZeppelin’s initialization flags in the same slot. The corrupted flag left the contract looking un-initialized, so the attacker re-called initialize() on a live governance contract, granted themselves a fabricated voting balance, and passed a treasury-draining proposal. The rekt.news writeup traces the slot overlap. The fix is EIP-1967: park proxy metadata at pseudo-random slots derived from a keccak hash, so far from where Solidity allocates that nothing collides.

🔓 The Uninitialized Implementation

Proxies can’t use constructors. A constructor runs at deploy time in the implementation’s own context, so the proxy never sees its effects. State gets set instead through an initialize() function guarded by an initializer modifier that should run exactly once.

Here is the gap. Teams initialize the proxy and forget the implementation, because “only the proxy matters.” But the bare logic contract sits on-chain, un-initialized, with a callable initialize(). In UUPS the upgrade authorization lives in that same implementation.

// UUPS logic contract, deployed but never initialized on its own address
function initialize() public initializer {
    owner = msg.sender;   // attacker calls this DIRECTLY on the implementation
}
function _authorizeUpgrade(address newImpl) internal override onlyOwner {}
// attacker is now owner -> passes _authorizeUpgrade -> points delegatecall at anything

A whitehat found exactly this in Wormhole’s bridge in 2022. This is the same Wormhole from Issue 58 on bridges, but a different and earlier bug: the core implementation was left uninitialized, letting an attacker seize it and upgrade the logic to a malicious contract. The Immunefi review documents the disclosure that earned a record 10 million USD bounty. The defense is one line in the implementation’s constructor: _disableInitializers(). Lock the logic contract so nobody can ever initialize it directly.

The check itself takes seconds. Read the implementation address from the EIP-1967 slot, then call initialize() on that address directly in a mainnet fork. If it does not revert, the logic contract is unlocked, and in a UUPS setup that is the whole exploit. This is one of the highest-yield tests you can run against any upgradeable protocol.

💣 Selfdestruct: Bricking the Fleet

Owning the implementation does not just let you steal. It lets you delete. And deletion, through a proxy, is worse than theft, because there is nothing left to recover.

This is the Parity multisig freeze. Every Parity wallet was a thin proxy that delegatecalled into one shared library. That library was never initialized, so its owner slot was empty. An anonymous user called initWallet directly on the library, became its owner, and invoked its kill(). The selfdestruct removed the library’s code. Every one of the roughly 587 wallets pointing at it now delegatecalled into an address with no code, and turned into a brick.

// The implementation contains a reachable selfdestruct.
// Executed via the shared library, it removes the code EVERY proxy depends on.
function kill() external onlyOwner { selfdestruct(payable(msg.sender)); }

The OpenZeppelin post-mortem walks the ownership takeover step by step. Two lessons compound here. Never leave an implementation initializable, and never leave selfdestruct reachable in upgradeable logic. OpenZeppelin’s UUPSUpgradeable forces you to override _authorizeUpgrade and gate it, because there is no safe default. Forget to gate it, and anyone repoints or destroys the contract.

Beacon proxies raise the blast radius further. A whole fleet of proxies reads its implementation address from one shared beacon, so a single malicious write to that beacon upgrades, or bricks, every proxy pointing at it in one transaction. The more proxies share an upgrade source, the more one compromised pointer is worth.

🛠️ Catching It Before Deploy

Every bug so far is invisible to a normal audit that reads one contract in isolation. Storage collisions and layout mismatches only appear when you compare the proxy against the implementation, and every version against the last.

Slither ships a dedicated check for exactly this, and it runs in CI without a node:

pip install slither-analyzer
slither-check-upgradeability ProxyContract.sol Proxy ImplementationV2.sol ImplementationV2

# Flags: storage-layout mismatches (reordered / inserted / removed vars),
#        function-selector collisions between proxy and logic,
#        missing initializer modifier, and variables that changed to constant

The slither-check-upgradeability detectors catch reordered variables, selector clashes, and missing initializer guards. Pair it with the OpenZeppelin Upgrades Plugins, which refuse to deploy or upgrade a proxy whose new implementation is layout-incompatible or leaves selfdestruct/delegatecall reachable.

Selector clashing is the collision people forget. A function is dispatched by the first four bytes of its signature hash, so a proxy admin function and an implementation function can share the same selector, and a call matches both ambiguously. Transparent proxies dodge this by routing admins and everyone else to different code paths, but hand-rolled proxies rarely do, and an attacker who finds a collision can invoke admin logic through a normal-looking call. The tell for a manual reviewer is always the same: two storage layouts and two selector namespaces that have to agree, with no compiler forcing them to.

📡 Community Radar

OWASP Smart Contract Top 10 2026: Proxy and Upgradeability

The 2026 list ranks proxy and upgradeability flaws as their own top-ten category, and the reason is a live campaign. Bots now watch the mempool for freshly deployed proxies that have not been initialized yet, then call initialize() first, installing themselves as owner before the real deployer’s initialization transaction lands. OWASP cites a 2025 wave of this hitting protocols including Kinto, with losses reported around 1.55 million USD in one case and more than 10 million USD across the campaign. Initializer front-running is no longer a theoretical race. If your deploy and initialize are two separate transactions, an attacker can win the gap.

🎯 Key Takeaways

The mental model to carry into any Web3 engagement: an upgradeable contract has two attack surfaces most auditors only half-check. The logic, which everyone reads, and the wiring between proxy and implementation, which almost nobody does. Every incident here lived in the wiring. Storage slots that had to agree and did not, an implementation that should have been locked and was not, an upgrade path that should have been gated and was open.

Uninitialized implementations are the single highest-value thing to check, because they collapse into total control. Find the logic contract’s address, not just the proxy’s, and try to call initialize() on it directly. If it succeeds, you own the upgrade path, and the upgrade path owns everything. The fix is _disableInitializers() in the constructor, so its absence is your flag.

Storage collisions are the quiet one. They never throw an error, they just corrupt the wrong slot, and the damage surfaces later as a mispriced variable or a re-callable initializer. You cannot spot them reading one contract. You spot them by diffing storage layouts across the proxy and every implementation version, which is exactly what tooling exists to automate.

For the workflow: run slither-check-upgradeability on every proxy pair and the OpenZeppelin Upgrades Plugins on every deploy. If you see a UUPS proxy, hunt the uninitialized implementation first. If you see a hand-rolled proxy storing metadata in low slots, check for a slot-0 collision. If deploy and initialize are separate transactions, assume a bot is watching. Work the wiring, not just the logic.


Practice:


Thanks for reading, and happy hunting!

— Ruben

Other Issues

Supply Chain Attacks: Owning the Install Step
Supply Chain Attacks: Owning the Install Step

Previous Issue

Comments

Enjoyed the article?

Stay Updated & Support

Get the latest offensive security insights, hacking techniques, and cybersecurity content delivered straight to your inbox.

Follow me on social media