Upgradeable proxy security: storage, selectors and the upgrade path
A proxy splits a contract into the address users trust and the code that runs behind it. Every upgrade is a chance to break the agreement between the two. The failure modes, by layer.
- Written by
- Quantix Research, Smart Contract Security
- Published
- Reading time
- 7 minutes
- Category
- Smart Contract Vulnerabilities
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
- Three parties and an agreement
- Transparent, UUPS and beacon proxies
- Storage layout: the silent breaking change
- Selector clashes
- UUPS: the upgrade function lives in the code
- Upgrades that change meaning, not layout
- The upgrade path is the attack surface
- Checking an upgrade before it ships
- Conclusions
- Recommendations
- Bibliography
Smart contracts cannot be edited after deployment, so upgradeable systems separate the address users interact with from the code that runs. A proxy holds the state and forwards every call to an implementation contract with delegatecall, which executes the implementation’s code against the proxy’s storage. Replacing the implementation’s address replaces the logic while keeping the state and the address.
The pattern trades immutability for maintainability, and introduces failure modes that do not exist in ordinary contracts. This primer covers them by layer: storage, function dispatch, the upgrade mechanism itself, and the governance of upgrades. It builds on our primer on initializers, which covers the construction step.
Three parties and an agreement
An upgradeable system involves the proxy, the current implementation, and whoever is allowed to change the implementation. Between them is an implicit agreement: every implementation will interpret the proxy’s storage the same way the previous one did, will not expose functions that collide with the proxy’s own, and can only be replaced by the authorised party through the intended path. Each section below is one way that agreement breaks.
Transparent, UUPS and beacon proxies
| Pattern | Upgrade logic lives in | Main strength | Main risk |
|---|---|---|---|
| Transparent (ERC-1967 slots) | The proxy, callable only by an admin | Admin and user calls cannot collide | Admin key or admin contract compromise |
| UUPS (ERC-1822, ERC-1967 slots) | The implementation | Cheaper proxy; upgrade logic can be removed to freeze | An implementation without a working upgrade function bricks the proxy |
| Beacon | A beacon contract shared by many proxies | One upgrade updates every proxy | One mistake, or one compromised key, affects every proxy at once |
| Diamond (ERC-2535) | A facet registry in the proxy | Many implementation contracts behind one address | Complex selector and storage management |
ERC-1967 standardises where a proxy stores the implementation and admin addresses: slots derived from a hash, chosen so that they cannot collide with variables declared by the implementation. Tools and block explorers rely on those slots to show which implementation a proxy runs.
Storage layout: the silent breaking change
Solidity assigns state variables to storage slots in declaration order, starting at slot 0, and derives the location of mapping and dynamic array data from the slot of the variable that declares them. An implementation therefore encodes the meaning of every slot through the order of its declarations. An upgrade that inserts, removes, reorders or retypes a variable changes that meaning while the data in storage stays where it was.
In the example, version 2 inserts feeBps in slot 1. It now reads whatever value totalSupply held, totalSupply reads the slot that anchored the balances mapping, and so on. Nothing reverts. The contract simply computes with wrong values, which is why layout errors are among the most damaging upgrade bugs.
Inheritance makes the problem harder to see: the layout includes every parent contract’s variables in linearisation order, so adding a variable to a base contract shifts every child. Two approaches contain this. Storage gaps, an unused fixed-size array at the end of each base contract that later versions shrink as they add variables, were the established convention. ERC-7201 namespaced storage, used by OpenZeppelin Contracts 5.x, places each contract’s state in a struct at a slot derived from a namespace identifier.
// ERC-7201 namespaced storage: state lives in a struct at a slot derived from an ID,// so adding fields to one namespace cannot shift another contract's variables.contract VaultV2 is Initializable, UUPSUpgradeable, OwnableUpgradeable { /// @custom:storage-location erc7201:quantix.example.vault struct VaultStorage { uint256 totalShares; mapping(address => uint256) shares; bool paused; uint16 feeBps; // new in v2: appended at the end of the struct } // keccak256(abi.encode(uint256(keccak256("quantix.example.vault")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant VAULT_STORAGE = 0x79c7bc95c365684ee406f14398f2609f09277b04ac4b0bc4ced75994c5efc400; function _vault() private pure returns (VaultStorage storage $) { assembly { $.slot := VAULT_STORAGE } } function _authorizeUpgrade(address) internal override onlyOwner {}}Selector clashes
A call is dispatched by the first four bytes of the hash of the function signature. Two different functions can share a selector. If the proxy defines a function, for example an admin function, whose selector matches one in the implementation, calls intended for the implementation run the proxy’s function instead. The transparent proxy pattern resolves this by routing calls from the admin to the proxy’s functions and all other calls to the implementation, at the cost of the admin never being able to use the application through the proxy. Diamond proxies must check every selector they register for collisions.
UUPS: the upgrade function lives in the code
In the UUPS pattern the function that changes the implementation is part of the implementation. Two risks follow. First, every new implementation must itself contain a working, access-controlled upgrade function; deploying one that lacks it leaves the proxy permanently on that version. OpenZeppelin’s UUPSUpgradeable guards against this by checking that the new implementation reports the expected ERC-1967 slot through proxiableUUID(). Second, the authorisation hook, _authorizeUpgrade, is written by the developer. An implementation that overrides it without a restriction lets anyone upgrade the proxy to code of their choosing.
The implementation contract also exists on its own, at its own address, with its own storage. If it can be initialised or called directly in ways that matter, for example a delegatecall or selfdestruct path, that is a separate attack surface; the initializers primer covers the defence of disabling initialisers in the implementation’s constructor.
Upgrades that change meaning, not layout
A storage layout can be identical across versions while the meaning of the data changes. A variable that held an amount in token units now holds it in shares; a fee once expressed in basis points is now read as a percentage; a timestamp field is reused as a block number. Layout tools report nothing, because nothing moved, and every stored value is silently misread.
Behaviour that other contracts depend on is part of the agreement too. Removing or renaming an external function, changing what a view returns, changing the rounding direction of a conversion, or emitting different events can break integrators and off-chain systems that were never part of the upgrade review. For protocols whose tokens or views are consumed elsewhere, the external interface deserves the same compatibility discipline as the storage layout.
Where data must change, it should be migrated explicitly: a versioned initialiser, such as OpenZeppelin’s reinitializer(n), that converts the stored values exactly once, guarded by access control and covered by a test that compares the state before and after on a fork. Migrations that run lazily, the first time each user interacts, need a test of the mixed state in which some records have been converted and others have not.
The upgrade path is the attack surface
Upgradeability means that whoever controls the upgrade controls the protocol. A compromised admin key can replace the implementation with one that transfers every asset to the attacker, and the change is valid by the system’s own rules. The strength of an upgradeable system is therefore the strength of its upgrade governance: who holds the admin role, how many independent parties must agree, and how long users have to see a pending upgrade and leave.
The compromised upgrade administrator case study and the Obsidian Rollup research assessment examine this path in a modelled system: a multisig admin behind a timelock, monitoring of the queue, and the response when an unexpected upgrade appears.
Checking an upgrade before it ships
Most upgrade bugs are detectable mechanically before deployment. Storage layouts can be extracted from the compiler and compared, and the OpenZeppelin upgrades plugins perform this check, along with others, as part of deployment.
# Compare storage layouts of the deployed and the candidate implementation.forge inspect src/VaultV1.sol:VaultV1 storageLayout > v1.jsonforge inspect src/VaultV2.sol:VaultV2 storageLayout > v2.jsondiff <(jq '.storage[] | {label, slot, offset, type}' v1.json) \ <(jq '.storage[] | {label, slot, offset, type}' v2.json) # The OpenZeppelin upgrades plugins run the same class of check automatically# (storage layout, constructors, selfdestruct and delegatecall use) before deployment.Beyond layout, a pre-upgrade review should run the full test suite against a fork with the upgrade applied, confirm that the new implementation’s upgrade function and its authorisation work, verify that the deployed bytecode matches the reviewed source, and check that any initialiser or migration for the new version can be called only once and only by the intended party.
Conclusions
Upgradeability rests on an agreement between implementations about storage, dispatch and authority, and on the integrity of whoever can change the implementation. Storage layout changes are silent and severe, selector clashes are rare but subtle, UUPS moves upgrade safety into application code, and the upgrade path itself is often the largest single point of failure.
Each of these is checkable: layouts by diff, selectors by enumeration, upgrade functions by test, and governance by threshold, delay and monitoring.
Recommendations
Developers
- Use namespaced storage (ERC-7201) for new contracts, and only ever append to a layout.
- In UUPS implementations, restrict
_authorizeUpgradeand test an upgrade from every release to the next. - Automate storage layout comparison in continuous integration.
Governance and operations
- Hold upgrade authority in a multisig of independent signers behind a timelock long enough for users to react.
- Monitor the ERC-1967 implementation and admin slots, and alert on any change that was not announced.
Reviewers
- Diff the layouts of the deployed and candidate implementations, including inherited contracts.
- Confirm the new implementation cannot be initialised or upgraded by anyone other than the intended party.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1Proxy upgrade pattern(opens in a new tab)OpenZeppelin Upgrades documentation
- 2EIP-1967: Proxy storage slots(opens in a new tab)Ethereum Improvement Proposals
- 3EIP-1822: Universal upgradeable proxy standard (UUPS)(opens in a new tab)Ethereum Improvement Proposals
- 4ERC-7201: Namespaced storage layout(opens in a new tab)Ethereum Improvement Proposals
- 5ERC-2535: Diamonds, multi-facet proxy(opens in a new tab)Ethereum Improvement Proposals
- 6Layout of state variables in storage(opens in a new tab)Solidity documentation
Applied on this site
Assessments, case studies and tools that apply this publication.







