Audit of Upgradeable Smart Contracts
Upgradeable contracts are a compromise. On one hand, ability to fix bugs and add functionality without migration. On the other — complete rejection of immutability guarantees that make blockchain secure. Any upgrade is a potential attack point: if attacker gains control over upgrade mechanism, they can replace logic with arbitrary code and withdraw all funds. That's why auditing upgradeable contracts is more complex and longer than auditing regular ones.
Compound, Aave, Uniswap V3 use some form of upgradeability. But they invested hundreds of thousands of dollars in auditing exactly the upgrade mechanisms.
Upgradeability Patterns and Their Vulnerabilities
Transparent Proxy (EIP-1967)
Most common pattern from OpenZeppelin. Proxy stores implementation address in deterministic storage slot. Calls are delegated to implementation through delegatecall.
User → Proxy (storage) → delegatecall → Implementation (logic)
Critical vulnerability: storage collision. Proxy uses slot 0x360894... for implementation address. If implementation accidentally uses same slot (or admin slot, or initializer flag) — rewrite during upgrade can break entire contract.
// Dangerous pattern: variables in Implementation
contract VulnerableImpl {
// Slot 0 in storage
address public owner; // COLLISION with proxy admin slot!
// If Proxy stores admin in slot 0 — owner in implementation
// will read/write proxy admin address
}
// Correct pattern: use namespaced storage (EIP-7201)
// or ensure impl starts with correct offset
contract SafeImpl {
// When using OpenZeppelin TransparentUpgradeableProxy
// implementation MUST NOT have variables in slots
// reserved for proxy (0x360894..., 0xb53127...)
// OpenZeppelin Upgrades Hardhat plugin checks this automatically
}
UUPS (Universal Upgradeable Proxy Standard, EIP-1822)
Upgrade logic is in implementation, not proxy. Proxy is simpler and cheaper to deploy.
UUPS vulnerability: selfdestruct in uninitialized implementation. If implementation contract is deployed and not initialized, attacker can call initialize directly on implementation (not through proxy), become owner, and call upgradeTo(maliciousContract) with selfdestruct. After selfdestruct, implementation — proxy becomes non-functional forever.
This happened with several protocols until OpenZeppelin added _disableInitializers() in implementation constructor:
contract MyContractV1 is UUPSUpgradeable, OwnableUpgradeable {
constructor() {
_disableInitializers(); // MANDATORY in every implementation
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address newImplementation)
internal override onlyOwner {}
}
Beacon Proxy
One Beacon stores implementation address, multiple Proxies point to this Beacon. One upgrade updates all proxies simultaneously. Used when need to deploy hundreds of identical contracts (e.g., vault per user).
Beacon vulnerability: centralization. Whoever controls Beacon controls all proxies. If Beacon owner is EOA or weakly protected multisig — catastrophic single point of failure.
What We Check During Audit
Initialization
Most common error in upgradeable contracts — incorrect initialization:
// WRONG: constructor in upgradeable contract doesn't work
contract BrokenUpgradeable {
address public owner;
constructor() {
owner = msg.sender; // NOT called when deploying through proxy!
}
}
// CORRECT: initializer function
contract CorrectUpgradeable is Initializable, OwnableUpgradeable {
function initialize() public initializer {
__Ownable_init(msg.sender);
}
}
Initialization checklist:
- All parent contracts initialized through
__Parent_init()pattern -
initializermodifier present (prevents repeated call) -
_disableInitializers()in constructor - All state variables that should be initialized on deploy are covered
Storage Layout Compatibility
During upgrade, new implementation must have exactly same storage layout starting from slot 0. Adding new variables — only at end. Reordering, deletion, type changes — storage collision and data corruption.
// V1
contract MyContractV1 {
uint256 public value; // slot 0
address public owner; // slot 1
}
// V2 CORRECT: add at end
contract MyContractV2 {
uint256 public value; // slot 0 — unchanged
address public owner; // slot 1 — unchanged
uint256 public newValue; // slot 2 — new
}
// V2 WRONG: insert in middle
contract MyContractV2_BROKEN {
uint256 public newValue; // slot 0 — COLLISION with value!
uint256 public value; // slot 1 — was in slot 0!
address public owner; // slot 2 — was in slot 1!
}
Tools: @openzeppelin/upgrades-core checks storage layout compatibility automatically. In audit — manual verification through forge inspect ContractName storage-layout.
Access Control of Upgrade Functions
Who can call upgrade? This is central audit question:
- EOA: unacceptable for production. One compromised key = loss of everything.
- Gnosis Safe multisig: acceptable, requires M-of-N signatures.
- Timelock: best practice. Upgrade queued for 48-72 hours, community can react.
- Governor + Timelock: maximum protection through on-chain voting.
// Audit checks: is there timelock before upgrade?
function _authorizeUpgrade(address newImplementation)
internal override
{
// BAD: only onlyOwner without timelock
// require(msg.sender == owner, "Not owner");
// GOOD: only through timelock (= through governance voting)
require(msg.sender == address(timelock), "Only timelock");
}
Invariant Preservation
After each upgrade, check: are contract invariants violated? If before upgrade totalSupply == sum(balances), this must be true after upgrade.
// V1 → V2 compatibility test
contract UpgradeTest is Test {
function testUpgradePreservesState() public {
// Deploy V1 through proxy, set state
MyContractV1 v1 = deployV1();
v1.setValue(42);
v1.deposit{value: 1 ether}();
// Upgrade to V2
upgradeTo(address(new MyContractV2()));
// Check state preserved
MyContractV2 v2 = MyContractV2(address(proxy));
assertEq(v2.value(), 42);
assertEq(address(proxy).balance, 1 ether);
// Check new V2 functionality
v2.newFunction();
}
}
Functional Changes on Upgrade
New implementation can change behavior of existing functions. Audit checks: are there changes that break existing user expectations or bypass protective mechanisms?
Example: V1 has limit on withdraw (max 10 ETH per transaction). V2 removes this limit — possibly intentional, but auditor must explicitly confirm.
Specific Attack Vectors
Upgrade + Reentrancy
During upgrade transaction execution (especially if upgrade logic has external calls), reentrancy is possible. Contract in intermediate state (between old and new logic) is vulnerable.
Delegatecall Injection
If implementation contains function with arbitrary delegatecall to external address — attacker can use this to execute code in proxy context (and its storage).
// CRITICALLY DANGEROUS in upgradeable contract
function execute(address target, bytes calldata data) external onlyOwner {
target.delegatecall(data); // Attacker owner = full control over proxy storage
}
Selfdestruct in Implementation (EIP-6780 Partially Mitigates)
EIP-6780 (Dencun upgrade, March 2024) changed selfdestruct behavior: now selfdestruct in same transaction as CREATE works as before, but called in separate transaction — only transfers ETH, doesn't delete code. For UUPS vulnerability this is partial mitigation, not complete solution.
Audit Tools
| Tool | Purpose |
|---|---|
@openzeppelin/hardhat-upgrades |
Automatic storage layout verification |
forge inspect ... storage-layout |
Manual slot analysis |
| Slither | Static analysis including upgrade patterns |
| Echidna / Medusa | Fuzzing on invariant preservation |
| Foundry fork tests | Upgrade tests on mainnet fork |
| Tenderly | Upgrade transaction simulation |
Audit Process
Phase 1: Static Analysis (3-5 days).
Slither + manual proxy pattern analysis. Map storage slots V1, check _disableInitializers, upgrade function access control.
Phase 2: Storage Compatibility (2-3 days). Compare storage layouts V1 and V2. For each variable: slot, type, offset. Special attention to mappings and dynamic arrays (their layout is non-trivial).
Phase 3: Functional Changes (3-5 days). Build diff between V1 and V2 at function level. For each change: intentional? doesn't violate invariants? no new attack vectors?
Phase 4: Upgrade Mechanism (2-3 days). Full upgrade lifecycle: who initiates → timelock → execution → rollback possibility?
Phase 5: Testing (5-7 days). Fork tests on Foundry. Fuzzing of critical functions. Manual tests of edge cases.
Report and Remediation (3-5 days). Document findings, severity classification (Critical/High/Medium/Low/Informational), recommendations.
Full upgradeable contract audit mid-size: 3-5 weeks. Cost — after code volume and upgrade mechanism complexity assessment.







