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 {}
}
If the implementation contract's initialize() was never called directly on the implementation address, an attacker can:
- Call
initialize(attackerAddress)on the raw implementation - Become the owner of the implementation
- Call
upgradeTo(maliciousContract)on the implementation -
selfdestructthe 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"
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(".")
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));
}
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
}
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
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
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
}
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 ──┘
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
}
}
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");
}
}
}
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
}
}
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]
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
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;
}
}
}
}
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>
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
closeinstruction 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)