The OWASP Smart Contract Top 10 for 2026 added a brand-new category that should terrify every protocol running upgradeable contracts: SC10 — Proxy & Upgradeability Vulnerabilities. This isn't a theoretical concern. In 2025–2026, proxy-related exploits have drained over $200M from DeFi protocols, and automated scanning campaigns now hunt uninitialized proxies across every EVM chain within minutes of deployment.
Here's what's breaking, why it's breaking, and the 7-layer defense architecture that stops it.
Why OWASP Created SC10
Before 2026, proxy vulnerabilities were scattered across other categories — access control, logic errors, reentrancy. But three trends forced OWASP to create a dedicated category:
- 54.2% of active Ethereum contracts are now proxies (PROXION study, 2025)
- Automated proxy-hunting bots scan for uninitialized ERC-1967 proxies across all EVM chains
- Storage collision exploits have graduated from CTF challenges to production attacks
The Audius governance hack ($6M, 2022) was the canary. The 2025 campaign targeting uninitialized proxies across multiple chains was the alarm. The Balancer v2 catastrophe ($128M, late 2025) — where access control failures in proxy-managed functions enabled the kill shot — was the earthquake.
The 5 Proxy Vulnerability Classes
Class 1: Storage Collision — The Silent Corruptor
When a proxy uses DELEGATECALL, the implementation's code runs against the proxy's storage. If storage layouts don't match perfectly, variables overwrite each other silently.
// ❌ VULNERABLE: V1 → V2 upgrade with storage collision
// V1 Implementation
contract LendingPoolV1 {
address public owner; // slot 0
uint256 public totalDeposits; // slot 1
mapping(address => uint256) public balances; // slot 2
}
// V2 Implementation — developer adds a variable at the wrong position
contract LendingPoolV2 {
address public owner; // slot 0
address public feeCollector; // slot 1 ← COLLISION: overwrites totalDeposits!
uint256 public totalDeposits; // slot 2 ← COLLISION: reads from balances mapping slot
mapping(address => uint256) public balances; // slot 3
}
After upgrade, totalDeposits reads garbage from the balances mapping slot. Liquidation logic breaks. Funds become unrecoverable.
Real impact: The CRUSH analysis tool found that 12% of upgraded contracts on Ethereum mainnet have at least one storage slot misalignment.
Class 2: Uninitialized Proxy — The Front-Running Race
UUPS and Transparent proxies use initialize() instead of constructors. If there's any gap between deployment and initialization, attackers claim ownership.
// ❌ VULNERABLE: Separate deploy + initialize transactions
// Transaction 1: Deploy proxy
proxy = new ERC1967Proxy(implementation, "");
// Transaction 2: Initialize (can be front-run!)
ILendingPool(proxy).initialize(msg.sender, oracle, treasury);
// ✅ SAFE: Atomic deploy + initialize
proxy = new ERC1967Proxy(
implementation,
abi.encodeCall(LendingPool.initialize, (msg.sender, oracle, treasury))
);
In 2025, automated bots scanned the mempool for proxy deployments and front-ran initialization on 340+ contracts across Ethereum, Arbitrum, Base, and BNB Chain.
Class 3: Implementation Self-Destruct — The Logic Bomb
If the implementation contract itself can be destroyed or taken over, every proxy pointing to it breaks permanently.
// ❌ VULNERABLE: Implementation not locked
contract LendingPoolImpl is Initializable, UUPSUpgradeable {
function initialize(address admin) public initializer {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
}
// Attacker calls initialize() directly on the implementation (not through proxy)
// Then calls upgradeToAndCall() to self-destruct or replace logic
Defense: Lock the implementation in the constructor:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
Class 4: Function Selector Clash — The Shadow Function
Transparent proxies route calls based on whether msg.sender == admin. But if the implementation has a function with the same 4-byte selector as a proxy admin function, non-admin calls get routed to the wrong logic.
// Proxy admin function selector: 0x3659cfe6 (upgradeTo(address))
// If implementation accidentally has a function with selector 0x3659cfe6,
// non-admin users calling it get the implementation version — not the proxy's
// This is rare but catastrophic when it happens
// OpenZeppelin's TransparentUpgradeableProxy mitigates this by routing
// ALL admin-selector calls to the proxy, regardless of caller
Class 5: Unauthorized Upgrade — The Governance Bypass
The most common class. If the _authorizeUpgrade() function lacks proper access control, anyone can upgrade the implementation to a malicious contract.
// ❌ VULNERABLE: Missing access control
function _authorizeUpgrade(address newImplementation) internal override {
// No check — anyone can upgrade!
}
// ✅ SAFE: Proper authorization with timelock
function _authorizeUpgrade(address newImplementation) internal override {
require(msg.sender == address(timelock), "Only timelock");
require(
IUpgradeRegistry(registry).isApproved(newImplementation),
"Implementation not approved"
);
}
The 7-Layer Proxy Security Architecture
Layer 1: Storage Layout Verification (Automated)
#!/bin/bash
# CI/CD storage layout check using OpenZeppelin Upgrades plugin
# Run before every upgrade deployment
npx hardhat run scripts/validate-upgrade.ts --network mainnet
# scripts/validate-upgrade.ts
# import { validateUpgrade } from '@openzeppelin/hardhat-upgrades';
# await validateUpgrade(PROXY_ADDRESS, LendingPoolV2, { kind: 'uups' });
Integrate slither-check-upgradeability for deeper analysis:
slither-check-upgradeability proxy.sol LendingPoolV1 \
--new-contract-name LendingPoolV2 \
--new-contract-filename proxy_v2.sol
Layer 2: Atomic Deploy-Initialize Pattern
Never deploy and initialize in separate transactions:
// Deploy script — always atomic
function deploy() public {
LendingPoolV1 impl = new LendingPoolV1();
// Lock the implementation
// (constructor already calls _disableInitializers)
// Deploy proxy with initialization in one tx
bytes memory initData = abi.encodeCall(
LendingPoolV1.initialize,
(admin, oracle, treasury)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
// Verify initialization succeeded
require(LendingPoolV1(address(proxy)).owner() == admin, "Init failed");
}
Layer 3: Implementation Registry with Hash Verification
Don't trust addresses alone — verify bytecode hashes:
contract UpgradeRegistry {
mapping(bytes32 => bool) public approvedCodeHashes;
mapping(address => uint256) public approvalTimestamp;
uint256 public constant TIMELOCK_DELAY = 48 hours;
function approveImplementation(address impl) external onlyGovernance {
bytes32 codeHash;
assembly { codeHash := extcodehash(impl) }
approvedCodeHashes[codeHash] = true;
approvalTimestamp[impl] = block.timestamp;
}
function isReadyForUpgrade(address impl) external view returns (bool) {
bytes32 codeHash;
assembly { codeHash := extcodehash(impl) }
return approvedCodeHashes[codeHash]
&& block.timestamp >= approvalTimestamp[impl] + TIMELOCK_DELAY;
}
}
Layer 4: Storage Gap Discipline
Every upgradeable base contract must reserve storage:
abstract contract LendingPoolStorageV1 {
address public owner;
uint256 public totalDeposits;
mapping(address => uint256) public balances;
// Reserve 47 slots for future V1 variables
// Total slots used: 3 + 47 = 50
uint256[47] private __gap;
}
// V2 can safely add variables by consuming gap slots
abstract contract LendingPoolStorageV2 is LendingPoolStorageV1 {
address public feeCollector; // Consumes 1 gap slot
uint256 public feeRate; // Consumes 1 gap slot
// Updated gap: 47 - 2 = 45
uint256[45] private __gap;
}
Layer 5: Upgrade Monitoring and Circuit Breakers
# Real-time proxy upgrade monitor
from web3 import Web3
# ERC-1967 implementation slot
IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
def monitor_upgrades(proxy_address: str, w3: Web3):
"""Alert on any implementation change"""
current_impl = w3.eth.get_storage_at(proxy_address, IMPL_SLOT)
def handle_block(block):
new_impl = w3.eth.get_storage_at(proxy_address, IMPL_SLOT)
if new_impl != current_impl:
# CRITICAL: Implementation changed!
# 1. Verify new implementation is in approved registry
# 2. Check if upgrade went through timelock
# 3. Alert security team
# 4. If unauthorized, trigger emergency pause
alert_security_team(proxy_address, current_impl, new_impl)
return handle_block
Layer 6: Solana Equivalent — Program Upgrade Authority
Solana doesn't use proxy patterns, but program upgradeability has the same risks:
use anchor_lang::prelude::*;
// Defense: Verify upgrade authority before any privileged operation
#[derive(Accounts)]
pub struct SecureUpgrade<'info> {
#[account(
constraint = program.programdata_address()? == Some(program_data.key()),
)]
pub program: Program<'info, MyProtocol>,
#[account(
constraint = program_data.upgrade_authority_address == Some(multisig.key())
@ ErrorCode::UnauthorizedUpgrade,
)]
pub program_data: Account<'info, ProgramData>,
pub multisig: Signer<'info>,
}
// Best practice: Transfer upgrade authority to a multisig or DAO
// After audited deployment, consider revoking upgrade authority entirely:
// solana program set-upgrade-authority <PROGRAM_ID> --final
Layer 7: Pre-Upgrade Invariant Testing
// Run before every upgrade — verify critical state survives
contract UpgradeInvariantTest is Test {
function test_upgradePreservesState() public {
// Snapshot pre-upgrade state
uint256 preTotalDeposits = pool.totalDeposits();
uint256 preUserBalance = pool.balances(alice);
address preOwner = pool.owner();
// Perform upgrade
pool.upgradeToAndCall(address(newImpl), "");
// Verify all state preserved
assertEq(pool.totalDeposits(), preTotalDeposits, "totalDeposits corrupted");
assertEq(pool.balances(alice), preUserBalance, "user balance corrupted");
assertEq(pool.owner(), preOwner, "owner changed during upgrade");
// Verify new functionality works
assertTrue(LendingPoolV2(address(pool)).feeCollector() == address(0));
}
function test_storageLayoutCompatibility() public {
// Verify no storage slot collisions using layout hash
bytes32 v1Layout = keccak256(abi.encode(
pool.owner(),
pool.totalDeposits()
));
pool.upgradeToAndCall(address(newImpl), "");
bytes32 v2Layout = keccak256(abi.encode(
pool.owner(),
pool.totalDeposits()
));
assertEq(v1Layout, v2Layout, "Storage layout changed!");
}
}
The 10-Point Proxy Security Checklist
| # | Check | Tool |
|---|---|---|
| 1 | Implementation constructor calls _disableInitializers()
|
Manual review |
| 2 | Proxy deploys with atomic initialization | Deployment script |
| 3 | Storage layout validated between versions | slither-check-upgradeability |
| 4 | Storage gaps in all base contracts (50 slots each) | OpenZeppelin Upgrades |
| 5 |
_authorizeUpgrade() has proper access control |
Slither, manual |
| 6 | Upgrade requires timelock (≥48h for mainnet) | Governance config |
| 7 | Implementation registry verifies bytecode hash | Custom registry |
| 8 | No selfdestruct or delegatecall in implementation |
Slither detector |
| 9 | Upgrade monitoring with real-time alerts | Custom monitor |
| 10 | Pre-upgrade invariant tests pass on fork | Foundry/Hardhat |
When to Freeze: The Nuclear Option
Some protocols reach a point where upgradeability is more risk than benefit. Consider revoking upgrade authority when:
- The contract has been battle-tested for 12+ months without changes
- TVL exceeds $100M (the incentive for upgrade exploits becomes extreme)
- All planned features are shipped
- A comprehensive bug bounty program is in place
On Solana: solana program set-upgrade-authority <PROGRAM_ID> --final
On EVM: Transfer proxy admin to address(0) or a contract that can never call upgrade()
The most secure upgradeable contract is one that no longer needs to upgrade.
The proxy upgrade mechanism that makes DeFi flexible is the same mechanism that makes it exploitable. OWASP SC10 exists because the industry learned this lesson the hard way — at a cost of hundreds of millions. Build your upgrade architecture like it's the most attacked surface in your protocol, because it is.
Top comments (0)