Proxy, UUPS & Storage Collision Bugs for Auditors
UUPS proxy storage collision is a layout bug with an admin bug sitting next to it. The proxy holds the storage; the implementation holds the code; delegatecall makes them share one state. This Andrax tutorial — by Syed Zada Abrar — covers ~60% theory, ~40% forge inspect storage-layout lab.
Prerequisites: Solidity Essentials; Foundry + Slither Setup; Smart Contract Access Control Bugs.
Also useful: Hub; How to Start Web3 Bug Hunting in 2026; Solidity Reentrancy Explained.
Ethics fence: every contract below is ILLUSTRATIVE / local lab only. Use Foundry, Anvil, or Remix VM. Do not initialize, upgrade, or brick live proxies. Follow Immunefi rules — mainnet and public-testnet testing of in-scope code is prohibited.
What a proxy actually stores
A delegating proxy keeps persistent storage and balance, and runs another contract’s code with delegatecall. The logic address has to live somewhere that the compiler will not assign to an ordinary variable. ERC-1967 standardizes that:
| Slot role | Storage slot |
|---|---|
| Implementation | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc |
| Admin | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 |
OpenZeppelin’s proxy utilities use these ERC-1967 slots so the implementation’s own variables do not overwrite the logic address (Proxy docs). EIP-1967’s security note is the collision thesis: if the proxy kept the logic address in slot 0, the implementation’s first variable would be slot 0 too, and a normal write through delegatecall could replace the implementation.
Selector clashes are the other half. A public function on the proxy whose 4-byte selector matches the implementation is executed on the proxy and never delegated. EIP-1967 cites that as a backdoor shape. Transparent proxies and UUPS answer it differently.
Transparent vs UUPS
OpenZeppelin still ships both. The current recommendation leans UUPS: lighter calls, upgrade logic lives in the implementation and can later be removed. The name comes from ERC-1822 (Transparent vs UUPS).
| Transparent | UUPS | |
|---|---|---|
| Where upgrade lives | In the proxy, via ProxyAdmin | In the implementation (UUPSUpgradeable) |
| Who may upgrade | The proxy admin only | Whoever _authorizeUpgrade allows |
| Non-admin call | Always delegated, even if the selector matches upgradeToAndCall | Delegated; upgrade function is on the logic contract |
| Admin calling the implementation | Reverts (ProxyDeniedAdminAccess) — admin is for upgrades only | No separate proxy admin on the hot path |
| Foot-gun | Heavier deploy; admin must not be a day-to-day caller | Upgrade to a non-UUPS implementation can brick future upgrades |
UUPSUpgradeable requires you to override _authorizeUpgrade. OpenZeppelin’s documented shape is an access-control check, typically onlyOwner:
function _authorizeUpgrade(address) internal override onlyOwner {}
An empty override authorizes everyone. That is an access-control bug on the upgrade path, not a storage bug.
Two OZ warnings auditors should copy into notes:
- By default,
UUPSUpgradeablerefuses an upgrade to an implementation that does not expose the ERC-1822 proxiable UUID. That stops you locking the proxy forever. The check can still be bypassed by upgrading first to an implementation that keeps an upgrade function but drops the check, then to a non-upgradeable contract (UUPSUpgradeable). - A UUPS implementation behind a
TransparentUpgradeableProxycan let non-admins upgrade, because both patterns share the ERC-1967 implementation slot and the upgrade code is then reachable through delegation. Do not mix them.
upgradeToAndCall is also onlyProxy: it must run via delegatecall, and the proxy’s ERC-1967 implementation must point at this contract. Calling upgrade on the logic contract directly is not the same as upgrading every proxy.
Uninitialized implementation, and a second initialize
Constructors do not run in the proxy’s storage. Setup moves to initialize, and a normal function can run more than once unless you stop it. OpenZeppelin’s initializer modifier consumes a version so that step cannot be repeated; reinitializer(n) is for later modules (Initializable, writing upgradeable contracts).
Two different bugs share the word “initialize”:
A. Second initialize on proxy storage. If initialize has no guard, anyone can call it again through the proxy and reset owner. Because the call is delegated, the write hits proxy storage. That is the dangerous second initialize.
B. First initialize on the implementation contract itself. The logic contract is a real address. If its constructor does not call _disableInitializers(), an attacker can call initialize on that address and become owner of the logic contract’s own storage. OpenZeppelin is explicit: do not leave an implementation uninitialized; an attacker can take it over, which may impact the proxy. The prescribed lock is:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
Do not conflate A and B. OpenZeppelin also states that ordinary storage writes on the logic contract do not change proxy instances — the proxy never reads the logic contract’s storage. The documented exception is destructive: selfdestruct on the logic contract, or a delegatecall from the logic contract into something that destroys it, was the path that deleted code every proxy delegated to (Potentially unsafe operations). EIP-6780 changed that opcode: outside the same transaction that created the contract, SELFDESTRUCT no longer deletes code or storage. Do not describe a live implementation as “brickable by selfdestruct” on a post-Cancun chain. Still forbid those operations in review — OZ does — and still lock initializers. This tutorial does not include a destruction payload.
Parent initializers must be called once, from the child, under onlyInitializing. Field initializers (uint256 x = 1) run only in the constructor, so upgradeable instances never see them. Use @openzeppelin/contracts-upgradeable for library parents that otherwise set name and symbol in a constructor.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE / LOCAL LAB ONLY — second initialize resets owner
contract UnsafeInitBox {
address public owner;
function initialize(address o) external {
owner = o; // no initializer guard
}
}
/// ILLUSTRATIVE — one-shot guard, but implementation still not locked
contract OnceInitBox {
address public owner;
bool private _done;
function initialize(address o) external {
require(!_done, "already");
_done = true;
owner = o;
}
}
OnceInitBox models “call it on the implementation”: the first caller becomes owner of that deployment. A proxy deployment would still need its own initialize through the proxy, and the logic contract should have been locked in the constructor so this path reverts. UnsafeInitBox models the second call: owner flips every time.
Storage collisions auditors can see
Solidity assigns slots in linearized inheritance order. OpenZeppelin’s upgrade rules, which apply to any proxy, not only their plugin (Modifying your contracts):
- Do not change a variable’s type or order
- Do not insert a variable before existing ones
- Do not remove a variable from the middle
- Append new variables at the end
- Renames keep the old value
- Deleting the last variable does not clear the slot; the next append reads the leftover
- Changing base-contract order, or adding a variable to a parent that already has children with their own variables, shifts the child
Storage gaps (uint256[50] private __gap) reserve slots in a base contract so a later version can consume them without moving the child. ERC-7201 namespaced storage — structs annotated @custom:storage-location erc7201:<id> — is what OpenZeppelin Contracts upgradeable uses from v5, and it avoids inheritance-order surprises. The Upgrades plugins validate namespaced layouts only on Solidity 0.8.20+.
EIP-1967 slots sit at hashed locations minus one so the compiler’s sequential allocator will not pick them, and so a mapping write cannot be aimed at a known preimage.
Lab — forge inspect layout, then a second initialize
Goal: on a local Foundry project, diff two layouts and show a second initialize changing owner. No network.
Setup
- Reuse the Foundry lab repo or
forge init. - Add
UnsafeInitBox,OnceInitBox, and the two layout toys below. - Foundry’s inspect command accepts a
storage/storage-layoutfield (forge inspect).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE layout pair — do not upgrade from V1 to V2
contract CollideV1 {
address public owner; // expect slot 0
uint256 public value; // expect slot 1
}
contract CollideV2 {
uint256 public value; // now slot 0 — collision if this replaced V1 behind a proxy
address public owner; // now slot 1
}
forge inspect src/CollideV1.sol:CollideV1 storage-layout
forge inspect src/CollideV2.sol:CollideV2 storage-layout
You are looking for slot and offset, not a gas number. V1’s owner at slot 0 and V2’s value at slot 0 is the collision. Append-only V2 (owner, value, then a new uint256 extra) keeps the first two slots stable. If you use OpenZeppelin Upgrades for Foundry, validateUpgrade is the automated form of this diff — still local.
Second initialize (local test)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
/// LOCAL LAB ONLY
contract InitLabTest is Test {
function test_secondInitializeResetsOwner() public {
UnsafeInitBox box = new UnsafeInitBox();
address alice = makeAddr("alice");
address bob = makeAddr("bob");
box.initialize(alice);
box.initialize(bob); // no guard
assertEq(box.owner(), bob);
}
function test_implementationCanBeClaimedOnce() public {
OnceInitBox logic = new OnceInitBox();
address attacker = makeAddr("attacker");
logic.initialize(attacker); // logic contract was not locked
assertEq(logic.owner(), attacker);
vm.expectRevert(bytes("already"));
logic.initialize(makeAddr("other"));
}
}
UnsafeInitBox / OnceInitBox / CollideV1 / CollideV2 / InitLabTest are all in this file’s first code blocks — paste them into the lab repo. The second test is the implementation takeover of that contract’s storage. Write one sentence in your notes on why that does not, by itself, rewrite a proxy's slots, and why EIP-6780 means a later selfdestruct does not delete an already deployed implementation. Do not build that path.
Optional third note, no extra code: an empty _authorizeUpgrade is an access-control finding. The fix is the onlyOwner override in the OZ docs, plus _disableInitializers() in the implementation constructor.
Lab write-up (five sentences): which pattern (unguarded re-init vs unlocked implementation vs slot reorder), whose storage changed, what a proxy would have shown, the mitigation (initializer, _disableInitializers, append-only or ERC-7201), and why you did not touch a live proxy.
How to report proxy findings
- Name the pattern: unguarded
initialize, unlocked implementation, empty_authorizeUpgrade, mixed transparent/UUPS, or a layout diff with slots - Say whose storage is written (proxy via
delegatecall, or the logic contract directly) - Attach the
forge inspectdiff or the local test, not a mainnet upgrade transaction - Propose the OZ control that matches:
initializer/reinitializer, constructor_disableInitializers(),onlyOwneron_authorizeUpgrade, gap or ERC-7201, plugin validation - Map severity to what an authorized program can actually lose — “upgradeable” is not automatically Critical
Common hunter traps
| Trap | Better habit |
|---|---|
| Assuming logic-contract storage is proxy storage | Only delegatecall shares proxy slots |
Ignoring _disableInitializers | Read the implementation constructor |
Empty _authorizeUpgrade waved through | It is missing access control |
| Mixing UUPS logic with a transparent proxy | OZ warns non-admins may upgrade |
| Reordering variables and calling it “a rename” | Diff storage-layout |
Shipping a selfdestruct “PoC” against a live implementation | Local layout and initialize labs only |
Checklist before you call it a proxy bug
- Call path identified: through proxy vs direct to implementation
- ERC-1967 slots not used as ordinary variables
-
initializeis one-shot on proxy storage; implementation constructor locks initializers -
_authorizeUpgradehas a real access check - Upgrade does not drop the UUPS check on the way to a non-upgradeable implementation unless that brick is intentional
-
forge inspect(or the Upgrades plugin) shows append-only or namespaced layout - Immunefi / program rules respected — no live upgrade
Next steps
- Prev (live): Oracle Manipulation in Smart Contracts · Smart Contract Access Control Bugs · Foundry + Slither
- Live: Hub
- Next (live): EIP-712 & Signature Replay: What Bug Hunters Miss · ERC-4626 Vault Security: Share Inflation & Donation Attacks
Educational content by Syed Zada Abrar for Andrax (andraxpentester.in). Authorized research only. All vulnerable contracts ILLUSTRATIVE / local lab only.