upgradeable-proxy

star 4.3k

Proxy upgrade patterns and their bugs — uninitialized implementation, storage slot collisions, selector clashes, unprotected upgrade auth.

PurpleAILAB By PurpleAILAB schedule Updated 6/2/2026

name: upgradeable-proxy description: Proxy upgrade patterns and their bugs — uninitialized implementation, storage slot collisions, selector clashes, unprotected upgrade auth. metadata: subdomain: smart-contracts when_to_use: "upgradeable proxy uups eip-1967 storage collision" mitre_attack: - T1190 - T1574


Upgradeable Proxy Playbook

Proxies separate storage (proxy) from logic (implementation). The proxy delegatecalls into the implementation, executing logic in the proxy's storage context. Three common patterns:

  1. Transparent Proxy (OZ TransparentUpgradeableProxy)
  2. UUPS (OZ UUPSUpgradeable, OZ 4.1+)
  3. Diamond / EIP-2535 (multi-facet, much more complex)

Each has distinct bug classes.

Audit checklist

1. Uninitialized implementation

Implementations should be initialized at deployment to prevent third-party initialization:

// Vulnerable — anyone can call initialize() on the implementation
// directly (not via proxy) and become its owner
contract VulnImpl {
    function initialize(address owner_) external {
        owner = owner_;
    }
}

// Safe — disable initializers on impl deployment
contract SafeImpl is Initializable {
    constructor() { _disableInitializers(); }

    function initialize(address owner_) external initializer {
        owner = owner_;
    }
}

Attack: attacker calls initialize on the impl contract → becomes "owner" of the impl. While this doesn't immediately drain the proxy, it CAN matter if _authorizeUpgrade reads impl-side state, or if selfdestruct is exposed and impl auth is on owner. Past incident: Parity multisig 2017 — $280M frozen.

# Find unprotected impl
grep -rn 'initialize\|_disableInitializers' src/

2. Storage slot collisions

Proxy and impl share storage. New impl versions must lay storage identical to old:

// V1
contract LogicV1 {
    address public owner;     // slot 0
    uint256 public total;     // slot 1
}

// V2 — BUG: inserted new var at top
contract LogicV2 {
    uint256 public newVar;    // slot 0 — overwrites owner!
    address public owner;     // slot 1
    uint256 public total;     // slot 2
}

After upgrade, owner is read from where total was; arbitrary value.

Check:

  • New vars only appended at the end
  • Inheritance order unchanged
  • OZ __gap reserved slots used in upgradeable libs
# OZ provides a tool
slither-check-upgradeability V1.sol V2.sol --proxy-name MyProxy

3. Function selector collisions (Transparent Proxy specifically)

Transparent proxy: if caller is admin → proxy admin functions; else → delegatecall to impl. If impl has function with selector matching a proxy admin selector → admin sees impl, user sees impl, admin can never call admin functions. Or worse, ABI-trickery.

Common collisions:

  • upgradeTo(address): selector 0x3659cfe6 — what if impl defines a transferOwnership(address) that hashes to the same selector?

Slither auto-detects:

slither --print human-summary src/ | grep -i selector

4. _authorizeUpgrade empty / wrong

UUPS pattern: implementation contains the upgrade authorization. Default OZ _authorizeUpgrade is internal virtual {} — empty body that reverts, but the function itself is empty. Devs override it:

// VULNERABLE — empty body, no actual auth check
function _authorizeUpgrade(address newImplementation) internal override {}

// SAFE
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}

Attack: anyone calls upgradeTo(maliciousImpl) → all storage now controlled by malicious code.

grep -rn '_authorizeUpgrade' src/

5. delegatecall to attacker-controlled address

If the impl contains a function that performs delegatecall(arbitrary_address), attackers can pass their own contract → execute arbitrary code in proxy storage context, including selfdestruct.

grep -rn 'delegatecall' src/

6. Constructor logic in upgradeable contracts

Constructors run on the implementation only — proxy storage is untouched. Storage initialization must happen in initialize(). If critical state is set in the constructor, the proxy will read zero values:

// VULNERABLE — admin set in constructor, proxy reads 0x0
contract Impl {
    address public admin;
    constructor() { admin = msg.sender; }  // sets admin on IMPL only
}

7. Diamond / EIP-2535 specific bugs

  • Facet selector collisions — two facets define same selector
  • Diamond storage position not unique → cross-facet storage corruption
  • diamondCut access control — must be gated, often forgotten
# Inspect facet selectors
slither . --print function-summary | grep -A1 'Facet'

8. Storage gap for inheritance

OZ upgradeable libs use uint256[N] private __gap; to reserve slots for future vars. Custom contracts inheriting them MUST preserve this:

contract MyToken is ERC20Upgradeable {
    uint256 public myVar1;
    uint256 public myVar2;
    uint256[48] private __gap;  // reserve 48 slots for future
}

Without __gap, future versions can't add fields without colliding with parent contracts that also added fields.

PoC template (Foundry)

function test_unauth_upgrade() public {
    address attacker = address(0xBEEF);
    address malicious = address(new MaliciousImpl());

    vm.prank(attacker);
    proxy.upgradeTo(malicious);  // _authorizeUpgrade is empty

    // Now any call to proxy executes malicious code
    proxy.balanceOf(attacker);  // returns whatever malicious wants
}

function test_impl_init() public {
    address attacker = address(0xBEEF);
    address impl = address(proxy.implementation());

    vm.prank(attacker);
    IImpl(impl).initialize(attacker);

    // Attacker is "owner" of impl
    // Whether that matters depends on impl logic
    assertEq(IImpl(impl).owner(), attacker);
}

function test_storage_collision() public {
    // Set state in V1
    proxy.setOwner(address(this));
    assertEq(proxy.owner(), address(this));

    // Upgrade to V2 which inserted a new var at slot 0
    proxy.upgradeTo(address(new LogicV2()));

    // owner now reads from where total was — likely garbage
    assertNotEq(proxy.owner(), address(this));
}

CVSS

  • Empty _authorizeUpgrade: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H = 10.0 (Critical)
  • Uninitialized impl + selfdestruct path: 10.0
  • Storage collision discovered post-deployment: 9.0 (already-deployed bug, can't fix in place)
  • Selector collision: 8.0 (DoS or value manipulation)
  • Diamond facet collision: 8.0

Defender remediation

// UUPS template
contract MyContract is UUPSUpgradeable, OwnableUpgradeable {
    constructor() { _disableInitializers(); }

    function initialize(address owner_) external initializer {
        __Ownable_init(owner_);
        __UUPSUpgradeable_init();
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

// Storage layout discipline
contract MyToken is ERC20Upgradeable, OwnableUpgradeable {
    // V1 fields
    uint256 public param1;
    address public param2;

    // Reserved gap — DECREMENT this when adding fields
    uint256[48] private __gap;
}

// Verify upgrade safety in CI
forge test --match-contract Test_Upgrade
# Plus run `slither-check-upgradeability V1 V2` in CI gate

Known exemplars

  • Parity multisig (Jul + Nov 2017): $30M stolen, $280M frozen — both proxy/impl bugs
  • Audius (Jul 2022): $6M — unauth initializer on governance proxy
  • dForce (Apr 2020): $25M — proxy upgrade w/ broken token logic (ERC777 reentrancy via upgrade)
  • KILT Protocol (Mar 2022): bricked w/ storage collision after a botched upgrade
  • Compound (Sep 2021): $80M; not a proxy bug but a governance-deployed bug — upgrade triggered the issue
  • Beanstalk (Apr 2022): governance + flash-loan + immediate upgrade execution = drain
  • Curve (Jul 2023): $61M; Vyper compiler reentrancy in a proxy-deployed contract
Install via CLI
npx skills add https://github.com/PurpleAILAB/Decepticon --skill upgradeable-proxy
Repository Details
star Stars 4,323
call_split Forks 860
navigation Branch main
article Path SKILL.md
More from Creator