
Preventing `delegatecall` Storage Corruption in Solidity
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 variablesare 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
delegatecallmakes 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:
Proxyhasimplin slot 2LogicV1does not know aboutimpl- if
LogicV1is 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.
| Pattern | When to use | Main benefit | Main risk |
|---|---|---|---|
| Transparent proxy | Standard upgradeable apps | Well-understood layout discipline | Requires strict storage compatibility |
| UUPS proxy | Upgradeable apps with upgrade logic in implementation | Smaller proxy surface | Implementation must protect upgrade entrypoints |
| Diamond pattern | Large systems with many modules | Modular function routing | Complex storage management |
| Direct calls | Non-upgradeable contracts | No storage sharing across contracts | Less 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:
- Design storage first
- define a dedicated storage contract
- reserve gaps for future growth
- Lock the layout
- avoid changing variable order after deployment
- document every storage-affecting change
- Automate layout checks
- compare compiler storage layout output between versions
- fail builds on incompatible changes
- Test upgrade scenarios
- deploy V1, write state, upgrade to V2, and verify all values remain intact
- include tests for inherited variables and packed storage
- 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.
