At a glance
- What delegatecall does
- Runs another contract's code inside your contract's storage. The logic is theirs; the state it changes is yours
- Why it exists
- It is how upgradeable contracts work: a thin proxy holds the storage and forwards calls to a logic contract you can swap
- The bug
- If the proxy and the logic disagree about what lives in which storage slot, a write meant for one variable lands on another
- The worst case
- A logic contract with a
selfdestructor a settable address, called by delegatecall, can brick or hijack the proxy - The habit
- Keep storage layouts identical across upgrades, use a standard proxy pattern, and never delegatecall code you do not fully control
Whose code, whose storage
A normal call runs the callee's code in the callee's storage. delegatecall is the strange one: it runs the callee's code in your storage, with your msg.sender and your balance. It is borrowing a function and pointing it at your own state. That is exactly what you want for upgrades: a small permanent proxy holds all the data, and forwards every call by delegatecall to a logic contract that you can replace with a new version. The address stays the same, the balance stays put, and the behaviour is upgraded.
The power is also the hazard. Because the borrowed code writes to your storage by slot number, everything depends on both contracts agreeing about which variable lives in which slot. When they disagree, a function that means to update one variable silently overwrites another, and in a proxy the variable it overwrites might be the address of the logic itself.
Storage collision
Solidity lays out state variables in the order you declare them: the first is slot 0, the next slot 1, and so on. A delegatecall does not know the names of your variables; it only knows slots. So if the proxy declares its variables in one order and the logic declares its in another, a write to "the variable in slot 0" hits whatever each contract happens to keep there.
// Proxy keeps the logic address in slot 0.
contract Proxy {
address public implementation; // slot 0
// ... fallback delegatecalls to implementation ...
}
// Logic keeps an owner in slot 0.
contract Logic {
address public owner; // slot 0 <- same slot!
function setOwner(address o) external { owner = o; }
}
Call setOwner through the proxy and the delegatecall writes to slot 0 of the proxy's storage, which is implementation, not owner. You have just repointed the proxy's logic to an address the caller chose. The next call runs the attacker's code in your storage. A field that looked like an ownership setter turned out to be a "replace the whole contract" button.
The fix: standard slots and disciplined layout
The problem is solved, and you should use the solution rather than inventing your own. The proxy standard (EIP-1967) puts the implementation address at a deliberately weird, hashed slot that no ordinary variable will ever occupy, so a collision is effectively impossible. Battle-tested libraries implement this for you.
// keccak256("eip1967.proxy.implementation") - 1
bytes32 internal constant SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
The two rules that keep upgrades safe: use a maintained proxy (OpenZeppelin's transparent or UUPS proxies, or a diamond if you need it) so the implementation slot is out of the way, and never reorder, remove or change the type of existing state variables between versions. Add new variables only at the end. A new version that inserts a variable in the middle shifts every slot after it, and every stored value now reads as the wrong thing.
The logic contract that can destroy itself
There is a sharper version of this bug. If the logic contract contains a selfdestruct, or a function that does a delegatecall to an address a caller supplies, then anyone who can reach that function through the proxy can point it at code that self-destructs. Because delegatecall runs in the proxy's context, the destruct can take the proxy with it, freezing every asset it held. This is not hypothetical: a widely used library was bricked exactly this way, and the funds behind hundreds of wallets that depended on it were frozen for good. Keep selfdestruct out of anything a delegatecall can reach, and never delegatecall to an address that came from outside.
Upgradeability is a trust choice, state it plainly. A contract you can upgrade is a contract whose rules the upgrader can change after you have deposited. That may be the right trade for a young protocol, but users deserve to know the key that controls the upgrade, and it belongs behind a timelock and a multisig so a change cannot happen silently or instantly.
The short version. delegatecall runs someone else's code in your storage, which is how upgrades work and why they are dangerous. The state is addressed by slot, so the proxy and the logic must agree on the layout or a write lands on the wrong variable, and in a proxy that wrong variable can be the logic address itself. Use a standard proxy that hides the implementation slot, never reorder existing storage between versions, keep selfdestruct away from anything reachable by delegatecall, and put the upgrade key behind a timelock.
References & further reading
- OpenZeppelin, Proxies. Transparent and UUPS proxies, and the storage rules for safe upgrades.
- Ethereum Improvement Proposals, EIP-1967: Standard Proxy Storage Slots. Why the implementation address lives at a hashed slot.
- Solidity documentation, Layout of State Variables in Storage. How slots are assigned, which is the whole basis of the collision.
- OpenZeppelin, Writing Upgradeable Contracts. The do-not-reorder-storage discipline in practice.