The upgrade is the attack surface: proxy pitfalls
Upgradeability is a trade. You give up immutability, the one property that lets users trust code without trusting you, in exchange for the ability to fix and extend the system after launch. That ability is itself a function, and it is the most powerful function in the protocol, because whoever can call it can replace every other line. Most proxy failures are not exotic. They come from forgetting that the upgrade path outranks everything it can change.
A proxy keeps the storage and forwards every call to a separate implementation contract through delegatecall, so the logic runs against the proxy's state. Upgrading means pointing the proxy at a new implementation. Four pitfalls cluster around that arrangement, and each has emptied real systems.
The uninitialized implementation
Because the proxy holds the state, the implementation contract is meant to be a stateless template. It cannot use a constructor for setup, since a constructor runs in the implementation's own storage, not the proxy's, so initialization moves to an initialize() function called through the proxy. The trap is that the implementation contract is also live at its own address, with its own empty storage, and its initialize() is usually unprotected. Anyone can call it directly and become the owner of the implementation.
On its own that can look harmless, since the implementation holds no funds. Under the UUPS pattern, where the upgrade logic lives in the implementation, it is not harmless. An attacker who initializes the implementation can gain its upgrade authority and, in some configurations, trigger a delegatecall to a contract that self-destructs the implementation, bricking every proxy that points at it. The fix is one line, and its absence is a finding:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // implementation can never be initialized directly
}
The storage collision
The proxy and implementation share a storage layout by position, not by name. Slot 0 in the implementation is slot 0 in the proxy, whatever each calls it. When a new version inserts or reorders state variables, the slots shift, and existing data is now read through the wrong names. A balance becomes an address, an owner becomes a number, and the contract operates on reinterpreted garbage.
The discipline that prevents it is to treat storage as append-only across versions: never remove or reorder existing variables, only add new ones at the end, and reserve gaps for inherited contracts. Namespaced storage, as standardized in ERC-7201, sidesteps the problem by giving each module a deterministic, non-overlapping region. Either way, an upgrade that changes storage layout is reviewed by diffing the layout, not by reading the new code alone.
Open upgrade authorization
In UUPS, the upgrade function is inherited and gated by a hook you are required to implement:
function _authorizeUpgrade(address newImpl) internal override onlyOwner {}
Leave out the onlyOwner restriction, or guard it with a role nobody holds, and the upgrade function is reachable by anyone. At that point the protocol has no security model at all, because any caller can install logic that drains it. This is a single missing modifier, and it is worth checking on every UUPS contract as a matter of routine.
The admin key itself
Suppose the code is perfect. The implementation disables initializers, the storage layout is clean, the upgrade hook is gated. The system is still only as safe as the key that holds the upgrade right. A single externally owned account that can replace all logic is a single point at which the entire protocol can be taken, by theft of that key or by its holder. The real control here is operational: put the upgrade right behind a multisig, and behind a timelock that gives users notice and a window to exit before any change takes effect. The threat model for an upgradeable protocol includes the team, and a serious design says so in its access controls.
The rule
The function that can replace the code is the highest-privilege function in the system. Lock the implementation, keep storage append-only, gate the upgrade, and hold the key like custody of every asset the protocol touches.
How we review it
- Confirm every implementation disables initializers, and try to call
initialize()on the implementation address directly. - Diff the storage layout between versions and reject any reorder or removal of existing slots.
- Verify the upgrade authorization is gated to an account that exists and is controlled as intended.
- Examine what holds the admin right: an EOA, a multisig, a timelock, and what notice users get before a change lands.
Why it hides
Proxy bugs hide because the contracts read like ordinary contracts, and the dangerous parts live outside them, in the deployment scripts, the initialization order, and the off-chain custody of a key. A review that reads only the Solidity can pass a system whose real exposure is a single account and a script that forgot one line. You find these by treating the upgrade machinery, not just the business logic, as the thing under review.
If your protocol is upgradeable, the proxy setup and the key that governs it are central to any honest review. Related: patched upstream, still live in the fork.