← Back to Blog

Proxy Pattern Vulnerabilities: UUPS, Transparent, and Diamond

2026-04-09 solidity smart contract security proxy pattern upgradeable contracts UUPS transparent proxy diamond proxy EIP-2535 audit vulnerability

Upgradeability is one of the most requested features in smart contract development and one of the most dangerous to implement. The proxy pattern separates logic from storage, enabling contract upgrades — but the gap between proxy and implementation is where attackers find their foothold.

This post breaks down real vulnerability classes across the three dominant proxy patterns: UUPS, Transparent Proxy, and Diamond (EIP-2535). Each pattern introduces different attack surfaces. Each has caused real losses.


Why Proxies Are Risky by Design

A proxy contract stores state but delegates execution to an implementation contract via delegatecall. The implementation runs in the proxy's storage context — meaning state variables, msg.sender, and msg.value all belong to the proxy, not the implementation.

This creates an invariant that must hold: the storage layout of the implementation and the proxy must be compatible. Breaking that invariant is not a compile-time error. It's a silent, runtime catastrophe.


UUPS Proxies

UUPS (Universal Upgradeable Proxy Standard, EIP-1822) moves upgrade logic into the implementation contract itself. The proxy is minimal — it just does the delegatecall. Upgrades are triggered by calling upgradeTo() on the implementation.

Vulnerability 1: Uninitialized Implementation

The most common UUPS bug. OpenZeppelin's Initializable pattern replaces constructors for upgradeable contracts. If the implementation is deployed without calling initialize(), anyone can call it and set themselves as owner.

// Vulnerable: no initializer protection
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
    function initialize(address owner) public {
        __Ownable_init(owner); // must be guarded
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

The fix: use initializer modifier from OpenZeppelin, and additionally call _disableInitializers() in the constructor to lock the implementation contract itself:

constructor() {
    _disableInitializers(); // lock the bare implementation
}

This is now standard in OZ v4.9+, but countless deployed contracts predate this fix.

Vulnerability 2: Missing _authorizeUpgrade Guard

UUPS requires the implementation to define _authorizeUpgrade. If it's empty or missing the access check, anyone can upgrade to a malicious implementation:

// Critical bug: missing access control
function _authorizeUpgrade(address newImplementation) internal override {
    // empty — any caller can upgrade
}

An attacker calls upgradeTo(maliciousImpl), replaces logic, and drains funds. This has happened. Always gate _authorizeUpgrade with onlyOwner or a multisig role check.

Vulnerability 3: Implementation Self-Destruct

If the unprotected implementation is called directly (not via proxy) and contains a selfdestruct path, the implementation contract can be destroyed. The proxy still exists, but every delegatecall now hits dead code — effectively bricking the proxy permanently.

// If this implementation is called directly and selfdestructs...
function destroy() external onlyOwner {
    selfdestruct(payable(msg.sender));
}

Mitigation: never call selfdestruct in implementation contracts. The _disableInitializers() pattern also helps prevent direct exploitation of the implementation.


Transparent Proxy Pattern

In the Transparent Proxy pattern (OpenZeppelin's classic approach), the proxy distinguishes callers: the admin gets proxy-management functions (like upgradeTo), everyone else gets their call forwarded to the implementation.

Vulnerability 4: Storage Collision

The proxy stores admin state (admin address, implementation address) in specific storage slots. If an implementation's state variables land on the same slots, reads and writes corrupt each other.

// Implementation
contract Token {
    address public owner; // slot 0
    uint256 public totalSupply; // slot 1
}

// If the proxy also uses slot 0 for admin address — collision

OpenZeppelin solves this with pseudo-random storage slots derived from keccak256:

bytes32 internal constant IMPLEMENTATION_SLOT =
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

The risk is real when teams write custom proxies without following EIP-1967. Static analysis tools catch obvious pattern violations; storage layout diffs between upgrade versions need manual review.

Vulnerability 5: Admin Confusion / Selector Clash

If the admin calls a function whose selector happens to match a proxy admin function, the transparent proxy intercepts it. If a regular user calls the same selector on the proxy, it's forwarded to the implementation. This asymmetry has caused operational confusion and, in some setups, enabled unexpected access escalation.

Always verify selector collision between proxy admin functions and implementation functions before deploying.


Diamond Proxy (EIP-2535)

The Diamond standard extends proxy upgrades to a facet-based architecture: multiple implementation contracts (facets), each handling different function selectors. A DiamondCut operation adds, removes, or replaces facets.

Vulnerability 6: Storage Collision Between Facets

Each facet can define its own state variables. If two facets write to overlapping storage slots, reads from one facet corrupt state written by another.

The mitigation is Diamond Storage: each facet uses a unique storage struct at a deterministic slot:

library LibVault {
    bytes32 constant STORAGE_SLOT =
        keccak256("diamond.vault.storage.v1");

    struct Storage {
        mapping(address => uint256) balances;
        uint256 totalDeposits;
    }

    function get() internal pure returns (Storage storage s) {
        bytes32 slot = STORAGE_SLOT;
        assembly {
            s.slot := slot
        }
    }
}

Facets that access shared state through their own storage.slot variable instead of position-based slots avoid cross-facet collisions.

Vulnerability 7: Unsafe DiamondCut Access Control

DiamondCut is the most powerful function in the system — it can replace any facet logic. If diamondCut is callable by more than a trusted owner or timelock, the entire contract is compromisable.

// Missing access control on diamondCut
function diamondCut(
    FacetCut[] calldata _diamondCut,
    address _init,
    bytes calldata _calldata
) external {
    // no onlyOwner or role check — anyone can replace facets
    LibDiamond.diamondCut(_diamondCut, _init, _calldata);
}

Always lock diamondCut behind a multisig or DAO timelock. Treat it with the same caution you'd apply to upgradeTo.

Vulnerability 8: Function Selector Clashes Across Facets

The Diamond maps function selectors to facets. Adding a new facet with a selector that already exists silently overwrites the previous mapping — the old facet function is now unreachable without an explicit diamondCut to restore it.

// Before: selector 0xabcd1234 → FacetA.withdraw()
// DiamondCut adds FacetB with 0xabcd1234 → FacetB.emergencyStop()
// Now: withdraw() is unreachable — replaced silently

Tooling helps here: the Diamond standard's reference implementation includes selector deduplication checks, but custom implementations may skip them. Always enumerate selectors before and after any cut.


Detection and Tooling

Static analyzers catch several proxy bugs automatically:

Bug Class Slither Semgrep Manual Review
Uninitialized implementation Partial Custom rules Essential
Missing _authorizeUpgrade guard Yes Yes
Storage layout collision Partial Essential
DiamondCut access control Partial Custom rules Essential
Selector clash Essential

Slither's uninitialized-local and suicidal detectors catch some of the UUPS issues. The storage layout checks require comparing layouts between versions — a diff that automated scanners can assist but not fully replace.

When using ContractScan on an upgradeable contract, the AI engine is specifically prompted to check for proxy-related vulnerability patterns, including initialization gaps and access control on upgrade functions.


Upgrade Checklist

Before any proxy upgrade:


The Bottom Line

Proxy patterns trade immutability for flexibility, but that flexibility comes with a novel class of bugs that don't exist in non-upgradeable contracts. The storage layout invariant must hold across every upgrade. Access to upgrade functions must be strictly controlled. Initialization must be locked at both proxy and implementation level.

None of these checks are optional for contracts holding real value. A missed _authorizeUpgrade guard or an uninitialized implementation can hand full control of your contract to an attacker in a single transaction.


ContractScan is a multi-engine smart contract security scanner. QuickScan is free and unlimited — no sign-up required. Try it now.

Scan your contract now
Slither + AI analysis — Unlimited QuickScans, no signup required
Try Free Scan →