VRF7
HomeGuides › delegatecall Vulnerabilities and Proxy Storage Collisions

delegatecall Vulnerabilities and Proxy Storage Collisions

Updated 2026-06-18 · VRF7 Security Guides

The delegatecall opcode is one of the most powerful and most dangerous primitives in the EVM. It lets a contract execute another contract's bytecode while keeping the caller's storage, msg.sender, and msg.value intact. Every major proxy pattern — transparent proxy, UUPS, Beacon — depends on it. So does a surprising share of high-severity exploits. Understanding exactly how delegatecall shares context, where storage layouts can collide, and how uninitialized proxies get hijacked is essential knowledge for any team shipping upgradeable contracts.

How delegatecall Works at the EVM Level

A normal call from contract A to contract B runs B's code in B's storage context. delegatecall runs B's code in A's storage context. The EVM uses the calling contract's storage slots directly; the implementation contract's own storage is never touched during the delegated execution.

This means every SSTORE and SLOAD inside the implementation operates on the proxy's storage. The implementation's state variables are really just a layout description — a set of named offsets into the proxy's slot array. If the proxy and implementation disagree on what lives at a given slot, they silently corrupt each other's data.

Storage Layout Collisions in Proxy Contracts

Storage collisions are the most common class of delegatecall vulnerability. They occur whenever a proxy contract and its implementation reserve the same storage slot for different purposes.

Naive proxy collision

Consider a minimalist proxy that stores the implementation address in slot 0:

// VULNERABLE — naive proxy
contract NaiveProxy {
    address public implementation; // slot 0

    fallback() external payable {
        address impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

// VULNERABLE — implementation also uses slot 0
contract TokenV1 {
    address public owner; // slot 0 — collides with implementation!
    // ...
}

When TokenV1 writes to owner, it overwrites the proxy's implementation pointer. An attacker who can influence the owner value can redirect all future delegatecalls to arbitrary bytecode.

EIP-1967 unstructured storage

The standard fix is EIP-1967, which places the implementation address at a pseudo-random slot derived from a well-known string:

// SAFE — EIP-1967 storage slot
bytes32 private constant IMPL_SLOT =
    bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

function _getImplementation() internal view returns (address impl) {
    assembly { impl := sload(IMPL_SLOT) }
}

function _setImplementation(address newImpl) internal {
    assembly { sstore(IMPL_SLOT, newImpl) }
}

The slot chosen is astronomically unlikely to match any sequentially allocated variable in an implementation contract. OpenZeppelin's ERC1967Proxy, TransparentUpgradeableProxy, and UUPS all follow this convention. If you are rolling a custom proxy, using anything other than EIP-1967 slots for proxy-level admin variables is asking for trouble. See our deeper treatment in Security of Upgradeable Smart Contracts (Proxies) for layout-compatibility rules across upgrades.

Layout drift across upgrades

Even when the proxy itself is safe, upgrading from V1 to V2 can introduce a collision if the developer inserts a new state variable in the middle of the layout instead of appending it:

// TokenV1 layout
contract TokenV1 {
    address public owner;   // slot 0
    uint256 public supply;  // slot 1
}

// BROKEN TokenV2 — inserted variable shifts supply
contract TokenV2 {
    address public owner;   // slot 0
    address public minter;  // slot 1  <-- new, collides with old supply!
    uint256 public supply;  // slot 2  <-- now reads stale data
}

// CORRECT TokenV2 — append only
contract TokenV2 {
    address public owner;   // slot 0
    uint256 public supply;  // slot 1
    address public minter;  // slot 2  <-- safe, appended
}

OpenZeppelin's storage gap pattern (uint256[50] private __gap;) and, more robustly, EIP-7201 namespaced storage are both mitigations. Static analysis tools catch some of these regressions automatically — tools like Slither include a dedicated storage-layout compatibility check.

Uninitialized Proxy and Implementation Takeover

A subtler but critical delegatecall vulnerability arises from uninitialized proxies and implementation contracts. Because proxies bypass constructors (constructors run in the implementation's own context, not the proxy's), upgradeable contracts must use initializer functions instead. Failing to call or protect that initializer creates a takeover vector.

Uninitialized proxy

If the proxy's initialize() is never called, or if the deployment script has a bug that skips it, the owner slot is zero. Anyone can call initialize first, set themselves as owner, then call privileged functions — or, in UUPS, upgrade the implementation to a self-destruct contract that bricks the proxy permanently.

Unprotected implementation contract

The implementation contract itself is usually deployed to a real address. If it has an initialize function with no guard against being called a second time, or if it was never initialized at all, an attacker can initialize the implementation directly and call selfdestruct on it:

// VULNERABLE implementation — no initializer guard
contract VaultImplV1 {
    address public owner;

    function initialize(address _owner) external {
        owner = _owner; // can be called by anyone, any number of times
    }

    function upgradeToAndCall(address newImpl, bytes calldata data)
        external onlyOwner { /* ... */ }
}

An attacker calls initialize on the bare implementation, becomes owner, then calls upgradeToAndCall pointing at a contract with a selfdestruct in its fallback. Because UUPS delegates upgrade logic to the implementation, the proxy then loses all logic and all funds. This is not theoretical — it matches the Wormhole and several smaller protocol incidents.

// SAFE — OpenZeppelin Initializable pattern
contract VaultImplV1 is Initializable {
    address public owner;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() { _disableInitializers(); }

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

_disableInitializers() in the constructor permanently locks the implementation so it can never be initialized directly. The initializer modifier on the proxy-side call ensures it runs exactly once.

Function Selector Clashes

Transparent proxies route calls either to the proxy's own admin functions or to the implementation, based on msg.sender. This prevents a selector clash where a function in the implementation shares the first four bytes of its keccak signature with a proxy admin function. UUPS moves upgrade logic into the implementation instead, eliminating the routing problem but requiring that the implementation always include upgrade authorization logic — failing to do so permanently freezes upgradeability. Access control around upgrade functions is discussed in detail in Access Control Vulnerabilities in Smart Contracts.

Safe Patterns and Mitigations

What Automated Scanning Catches

Many delegatecall vulnerability patterns have deterministic signatures that static and dynamic analysis tools can surface. Slither's controlled-delegatecall detector flags cases where the target of a delegatecall is influenced by a function parameter. Its suicidal and uninitialized-local detectors catch implementation self-destruct paths. Aderyn and Semgrep can match initializer guard omissions and missing access control on upgrade functions. If your team is starting a proxy architecture or reviewing an existing one, you can run an automated scan with VRF7 to get findings from multiple tools in parallel, each labeled by source, before you move to a manual review phase.

Automated scanning does not replace a thorough manual audit — complex cross-contract storage interactions and multi-transaction exploit sequences often require human reasoning — but it efficiently eliminates known patterns and lets auditors focus their time on novel logic.

Summary

delegatecall is the foundation of every upgradeable proxy pattern and remains one of the highest-leverage attack surfaces in Solidity. The core risks reduce to three categories: storage slot collisions between proxy and implementation, uninitialized proxies or implementation contracts that can be seized by an attacker, and unguarded upgrade paths. Each has well-established mitigations — EIP-1967 slots, _disableInitializers, append-only layouts, and explicit access control — that cost little to apply and prevent catastrophic loss of funds.

Scan your contract before you ship

Run an automated, transparent security scan — seven industry tools in parallel, every finding labeled with its source tool. It is not a substitute for a full manual audit, but it is a fast first line of defense.

Scan a contract

Frequently asked questions

What is a delegatecall vulnerability?

A delegatecall vulnerability occurs when the EVM's delegatecall opcode is used in a way that allows unintended writes to a contract's storage, grants an attacker control over execution flow, or enables takeover of a proxy through an uninitialized or colliding storage slot. Because delegatecall runs external bytecode inside the calling contract's storage context, any mismatch between the proxy's and implementation's expected storage layout — or any failure to restrict who can initialize or upgrade the implementation — becomes a direct security risk.

How do storage collisions happen in proxy contracts?

Storage collisions occur when a proxy contract and its implementation contract assign different meanings to the same storage slot number. In a naive proxy, if the proxy stores its implementation address in slot 0 and the implementation also has a state variable at slot 0 (such as an owner address), any write to the owner overwrites the implementation pointer. EIP-1967 mitigates this by placing proxy-level variables at pseudo-random slots derived from keccak256 hashes, far outside the range of sequentially allocated slots.

Why must implementation contracts be initialized even if they are never used directly?

An implementation contract deployed to a real address can be called directly by anyone. If it contains an unguarded initializer, an attacker can call that function, take ownership, then invoke privileged operations such as selfdestruct or upgrade. This can destroy the logic contract that the proxy depends on, permanently bricking the proxy and locking all associated funds. Calling _disableInitializers() in the implementation's constructor prevents this by making the contract impossible to initialize directly.

What is the difference between a transparent proxy and a UUPS proxy from a security standpoint?

A transparent proxy keeps upgrade and admin logic inside the proxy itself and routes calls based on msg.sender, which avoids function selector clashes but adds complexity and gas cost. A UUPS proxy delegates upgrade logic to the implementation, which is leaner but places the burden of access control on the implementation developer — if the _authorizeUpgrade function lacks a proper guard, anyone can upgrade the contract. Both patterns share the same storage collision and initialization risks; the choice affects where upgrade authorization must be enforced.

Can automated tools reliably detect delegatecall vulnerabilities?

Automated tools can reliably detect several well-defined delegatecall vulnerability patterns: delegatecall to user-controlled addresses, missing initializer guards, unprotected selfdestruct in an implementation, and some storage layout issues. Tools like Slither, Aderyn, and Semgrep cover these cases. However, complex multi-transaction attack paths, subtle cross-contract storage interactions, and logic-level authorization flaws often require manual human review. Automated scanning is best used as a first pass to eliminate known patterns before a deeper manual audit.