Why delegatecall is risky

Unlike a normal external call, delegatecall runs the target contract’s code in the context of the calling contract. The code reads and writes the caller’s storage slots as if it were the caller itself.

That means:

  • the implementation contract’s state variables are only a blueprint
  • storage slot ordering must match exactly
  • even a small layout change can overwrite critical data
  • a malicious or incompatible implementation can seize control of the caller

This is why delegatecall is central to proxy-based upgradeability, but also why it must be treated as a security boundary.

A simple mental model

Think of storage as a numbered array of slots:

  • slot 0, slot 1, slot 2, and so on
  • Solidity assigns variables to slots in declaration order
  • delegatecall makes the callee write into the caller’s slots

If the callee thinks slot 0 is an address owner, but the caller stores a uint256 totalSupply there, the write will not fail. It will silently overwrite the wrong data.


A minimal example of storage corruption

Consider a contract that uses delegatecall to execute logic from a separate implementation.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Proxy {
    address public owner;      // slot 0
    uint256 public balance;    // slot 1
    address public impl;       // slot 2

    constructor(address _impl) {
        owner = msg.sender;
        impl = _impl;
    }

    function execute(bytes calldata data) external returns (bytes memory) {
        (bool ok, bytes memory ret) = impl.delegatecall(data);
        require(ok, "delegatecall failed");
        return ret;
    }
}

contract LogicV1 {
    address public owner;      // slot 0
    uint256 public balance;    // slot 1

    function setBalance(uint256 newBalance) external {
        balance = newBalance;
    }
}

At first glance this looks harmless. LogicV1 and Proxy appear to share the same layout for the first two variables. But the design is fragile:

  • Proxy has impl in slot 2
  • LogicV1 does not know about impl
  • if LogicV1 is upgraded or replaced with a different layout, corruption begins immediately

Now imagine a second implementation:

contract LogicV2 {
    uint256 public balance;    // slot 0
    address public owner;      // slot 1

    function setOwner(address newOwner) external {
        owner = newOwner;
    }
}

If Proxy.execute() delegates to LogicV2, setOwner() writes to slot 1, which in Proxy is balance, not owner. The owner remains unchanged, and the balance slot is overwritten with an address value. That is silent state corruption.


Common ways this bug appears in production

1. Upgradeable contracts with changed variable order

A contract upgrade that inserts, removes, or reorders variables can shift every subsequent slot. This is the most common cause of storage corruption in proxy systems.

2. Mixing inherited contracts without planning layout

Inheritance affects storage order. If a new base contract is added later, the layout may shift in ways that are not obvious from the child contract alone.

3. Using multiple implementation versions with the same proxy

If a proxy can point to different logic contracts over time, every version must preserve the exact layout of all previous versions.

4. Delegating to untrusted or user-selected contracts

If users can choose the target of a delegatecall, they may execute code that writes arbitrary values into your storage and potentially take over the contract.


What can go wrong

Storage corruption is not just a bookkeeping issue. It can lead to:

  • ownership takeover
  • broken accounting
  • lost funds
  • disabled access control
  • uninitialized or overwritten implementation pointers
  • permanent contract bricking

A particularly dangerous pattern is overwriting the slot that stores the implementation address itself. If an attacker can change that slot, they can redirect future calls to malicious code.


Safer design patterns

The best defense is to minimize direct delegatecall usage and constrain it when it is necessary.

PatternWhen to useMain benefitMain risk
Transparent proxyStandard upgradeable appsWell-understood layout disciplineRequires strict storage compatibility
UUPS proxyUpgradeable apps with upgrade logic in implementationSmaller proxy surfaceImplementation must protect upgrade entrypoints
Diamond patternLarge systems with many modulesModular function routingComplex storage management
Direct callsNon-upgradeable contractsNo storage sharing across contractsLess flexible

If you do not need shared storage, prefer a normal external call over delegatecall.


Best practices for preventing storage corruption

1. Freeze the storage layout

Once a contract is deployed behind a proxy, treat the storage layout as immutable. You may append new variables, but you should not reorder or remove existing ones.

Safe changes:

  • append new variables at the end
  • use reserved storage gaps
  • keep inheritance order stable

Unsafe changes:

  • reorder variables
  • change variable types
  • insert variables in the middle
  • remove inherited base contracts

2. Use storage gaps in upgradeable contracts

A storage gap reserves unused slots for future variables without shifting existing layout.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract VaultV1 {
    address public owner;
    uint256 public totalDeposits;

    uint256[48] private __gap;
}

Later versions can consume part of the gap without disturbing earlier slots.

3. Keep implementation contracts aligned with the proxy

The proxy and implementation must agree on the exact layout of all variables that are accessed through delegatecall. This includes inherited storage.

A good practice is to define a shared base contract for storage and inherit it from every implementation version.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

abstract contract VaultStorage {
    address internal owner;
    uint256 internal totalDeposits;
    mapping(address => uint256) internal balances;
}

contract VaultV1 is VaultStorage {
    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }
}

This makes the storage contract the single source of truth.

4. Avoid user-controlled delegatecall

Never allow arbitrary addresses to be passed into delegatecall unless the security model explicitly assumes full trust in that address. If a user can choose the target, they can often write to your storage directly.

If plugin-style execution is required, isolate state carefully and use allowlists.

5. Validate implementation upgrades

Before changing the implementation address in a proxy, verify that:

  • the new contract uses the same storage layout
  • the new contract preserves initialization assumptions
  • the new contract does not introduce conflicting base contracts
  • the upgrade function is access-controlled

Use automated storage layout checks in CI whenever possible.


Example: a safer proxy upgrade flow

The following example shows a simple upgradeable proxy with a dedicated storage contract and an owner-only upgrade function.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

abstract contract VaultStorage {
    address internal owner;
    address internal implementation;
    uint256 internal totalDeposits;
    mapping(address => uint256) internal balances;

    uint256[44] private __gap;
}

contract VaultProxy is VaultStorage {
    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor(address initialImplementation) {
        owner = msg.sender;
        implementation = initialImplementation;
    }

    function upgradeTo(address newImplementation) external onlyOwner {
        require(newImplementation.code.length > 0, "not a contract");
        implementation = newImplementation;
    }

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

    receive() external payable {}
}

This design is still only as safe as the implementation contracts it delegates to, but it reduces the chance of accidental layout drift.


How to review a contract for storage safety

When auditing a delegatecall-based system, check the following:

Storage layout consistency

Compare the variable order and types across all versions. Pay special attention to:

  • inherited contracts
  • packed variables
  • mappings and dynamic arrays
  • newly inserted state variables

Upgrade authorization

Verify that only trusted roles can change the implementation address.

Initialization behavior

Ensure that the implementation cannot be initialized in a way that breaks proxy assumptions or overwrites critical slots.

External delegation boundaries

Look for any function that accepts a target address for delegatecall. Ask whether the target can be influenced by an attacker.

Slot collision with proxy metadata

Confirm that implementation, admin, and beacon-related metadata are stored in reserved or standardized slots, not in ordinary application storage.


Practical development workflow

A reliable workflow for delegatecall-based projects should include:

  1. Design storage first
  • define a dedicated storage contract
  • reserve gaps for future growth
  1. Lock the layout
  • avoid changing variable order after deployment
  • document every storage-affecting change
  1. Automate layout checks
  • compare compiler storage layout output between versions
  • fail builds on incompatible changes
  1. Test upgrade scenarios
  • deploy V1, write state, upgrade to V2, and verify all values remain intact
  • include tests for inherited variables and packed storage
  1. Restrict delegation
  • only delegate to trusted implementations
  • never delegate to arbitrary user input unless that is the explicit design

When delegatecall is appropriate

delegatecall is not inherently bad. It is appropriate when you need:

  • upgradeable proxies
  • shared execution logic across many instances
  • modular contract systems with a fixed storage model

It is usually not appropriate when you only need to reuse code. In many cases, a library call, internal function, or standard external call is safer and easier to reason about.

A useful rule is this: if the contract does not need to share storage with the callee, do not use delegatecall.


Summary

delegatecall gives Solidity contracts powerful extensibility, but it also removes the safety boundary between contracts and their storage. The core risk is simple: if the callee’s layout does not exactly match the caller’s expectations, state corruption occurs silently.

To use delegatecall safely:

  • keep storage layouts stable
  • append, do not reorder
  • use dedicated storage base contracts
  • reserve gaps for future variables
  • restrict upgrade authority
  • avoid user-controlled delegate targets
  • test upgrades against real stored state

If you treat storage layout as part of your contract’s public interface, you will avoid one of the most expensive classes of upgradeable-contract bugs.

Learn more with useful resources