Delegatecall to Untrusted Code: The Total Takeover Bug (SWC-112)
One unguarded delegatecall is all it takes to lose every wei in your contract. The 2017 Parity wallet hack drained ~$30M because a library's init function was callable by anyone — and ran in the wallet's storage context. If you're writing proxies, plugins, or 'modular' anything, this is the bug…
The Bug in One Sentence
`delegatecall` runs someone else's code with your contract's storage, balance, and `msg.sender`. If you don't control who that someone is — or what their code does to slot 0 — you don't control your contract anymore.
That's SWC-112. It's not subtle. It's not a gas-griefing edge case. It's a full takeover primitive, and it's been live-fire tested twice on Parity's multisig wallet for a combined loss north of $180M (the $30M July 2017 hack plus the $150M November 2017 freeze, both rooted in the same delegatecall pattern).
Why Delegatecall Is Dangerous
A normal `call` executes the target's code in the target's storage. A `delegatecall` executes the target's code **in your storage**. From the EVM's perspective, you've handed the target a master key to every slot you own.
Three things break simultaneously when delegatecall goes wrong:
1. **Storage collisions** — slot 0 in the library overwrites slot 0 in your contract (often `owner`).
2. **msg.sender preservation** — the library sees the caller as your contract's caller, so `onlyOwner` checks in the library are evaluated against the wrong context.
3. **selfdestruct propagation** — if the delegated code runs `SELFDESTRUCT`, *your* contract dies. (Post-Cancun this is neutered to balance-transfer-only, but storage clobbering still works.)
The Vulnerable Pattern
Here's a stripped-down version of the bug class — and morally, the Parity bug:
```solidity
contract Proxy {
address public owner;
address public implementation;
constructor(address _impl) {
owner = msg.sender;
implementation = _impl;
}
fallback() external payable {
(bool ok, ) = implementation.delegatecall(msg.data);
require(ok);
}
function setImplementation(address _impl) external {
// No access control — anyone can point this at malicious code
implementation = _impl;
}
}
```
Two problems:
`setImplementation` is unguarded. An attacker swaps in a contract whose first function overwrites `owner` via storage slot 0.
- Even if `setImplementation` were guarded, if the *implementation* exposes an unprotected `initialize()` or a public `kill()` that calls `selfdestruct`, anyone can invoke it through the fallback.
That second point is exactly what killed Parity. The library had an `initWallet` function that was supposed to be called once at construction. It wasn't protected. An attacker called it through delegatecall, became the owner of every wallet using that library, and walked out with ~$30M in ETH.
The Fix
Three non-negotiables:
1. **Whitelist your callees.** Never delegatecall to a user-supplied address.
2. **Protect initializers.** Use OpenZeppelin's `Initializable` pattern or a manual `initialized` flag.
3. **Audit storage layout.** The implementation's storage layout must match the proxy's, slot for slot. Use unstructured storage (EIP-1967) for the proxy's own admin slots so they can't collide.
```solidity
contract SafeProxy {
// EIP-1967 admin slot — won't collide with implementation storage
bytes32 private constant _ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
bytes32 private constant _IMPL_SLOT =
0x360894a13ba1a3210667c828492db98dcef42278b9b3a16cebd2c0e0b4c14d8e5;
modifier onlyAdmin() {
require(msg.sender == _admin(), "not admin");
_;
}
constructor(address impl, address admin) {
require(impl.code.length > 0, "impl not contract");
_setSlot(_IMPL_SLOT, impl);
_setSlot(_ADMIN_SLOT, admin);
}
function upgrade(address newImpl) external onlyAdmin {
require(newImpl.code.length > 0, "impl not contract");
_setSlot(_IMPL_SLOT, newImpl);
}
fallback() external payable {
address impl = _impl();
assembly {
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
function _impl() internal view returns (address a) {
bytes32 slot = _IMPL_SLOT;
assembly { a := sload(slot) }
}
function _admin() internal view returns (address a) {
bytes32 slot = _ADMIN_SLOT;
assembly { a := sload(slot) }
}
function _setSlot(bytes32 slot, address v) internal {
assembly { sstore(slot, v) }
}
}
```
And on the implementation side, the initializer must be one-shot:
```solidity
contract Logic {
bool private _initialized;
address public owner;
function initialize(address _owner) external {
require(!_initialized, "already initialized");
_initialized = true;
owner = _owner;
}
}
```
Better: inherit `Initializable` from OpenZeppelin and use `initializer` / `reinitializer(n)` modifiers. They handle the edge cases (constructor-time initialization, multi-inheritance, upgrade-time re-init) you will inevitably get wrong on your own.
The Parity Postmortem in 30 Seconds
Parity's multisig wallets were thin proxies. Each wallet delegated all logic to a shared library at a fixed address. The library exposed `initWallet(address[] _owners, uint _required, uint _daylimit)` with no guard. The attacker called it through any wallet's fallback, became sole owner, and called `execute` to drain ETH. Three contracts hit. ~150,000 ETH gone.
Four months later, a different user called the same `initWallet` directly on the *library itself* — taking ownership of the library — and then called `kill()`, which ran `selfdestruct`. Every wallet that depended on that library was bricked. ~513,000 ETH frozen forever.
Same bug class. Twice. Six-figure ETH each time.
Detection Checklist
Grep your codebase for `delegatecall`. For every hit, ask:
Is the target address constant, or user-controllable?
- If user-controllable, is it whitelisted by a trusted admin?
- Does the target's storage layout match this contract's, slot for slot?
- Are all public/external functions on the target safe to invoke through a fallback by an arbitrary caller?
- Can the target `selfdestruct` (still relevant on chains that haven't adopted EIP-6780 semantics fully)?
- Are initializers protected?
If you can't answer all six confidently, you have a finding. Run our free [AI-powered audit](https://www.cryptohawking.com/audit) to catch the obvious cases automatically, or book a [manual audit](https://www.cryptohawking.com/audit/manual) ($5,000, three business days, paid in ETH/SOL/USDT) when the stakes — or the proxy graph — get serious.
TL;DR
Delegatecall is a loaded gun pointed at your storage. Only ever point it at code you wrote, audited, and locked behind an admin gate. Protect initializers like they're private keys, because functionally, they are.
FAQ
Is delegatecall always dangerous?
No. Delegatecall is the foundation of every upgradeable proxy on Ethereum, and used correctly it's safe. The danger is delegating to code you don't fully control: user-supplied addresses, unguarded implementations with public initializers, or libraries with `selfdestruct`. The rule is simple — if the target is a constant or behind an admin-only upgrade path, and the implementation has no unprotected state-changing entry points, you're fine. Otherwise, you've handed an attacker a write primitive over every storage slot you own.
Does EIP-6780 make this bug go away?
Partially. EIP-6780 (Cancun, March 2024) neutered `SELFDESTRUCT` so it no longer deletes contract code outside of the same-transaction-as-creation case — meaning the second Parity-style freeze is much harder to reproduce on mainnet. But storage clobbering through delegatecall still works exactly as before. An attacker can still overwrite your `owner` slot and drain funds. EIP-6780 closes one footgun, not the class.
How do I prevent storage collisions between proxy and implementation?
Use EIP-1967 unstructured storage for the proxy's own admin and implementation slots — they're stored at hashed slot positions that no normal Solidity layout will ever touch. For the implementation, reserve a storage gap (`uint256[50] private __gap;`) at the end of each upgradeable contract so future versions can add fields without shifting existing ones. And run `forge inspect <Contract> storage` (or hardhat-storage-layout) on every upgrade to diff layouts before deployment.
What's the difference between SWC-112 and a typical proxy bug?
SWC-112 is the umbrella class: delegatecall to untrusted code. Typical proxy bugs are specific instances — uninitialized implementations, unprotected `upgradeTo`, storage layout drift across upgrades, function selector clashes between proxy and impl. They all reduce to the same root: code you didn't fully audit is running in your storage context. Fix the root by treating every implementation as untrusted-by-default and gating it with admin controls, initializer protection, and layout assertions.
Can I detect this with static analysis?
The obvious cases, yes. Slither flags `controlled-delegatecall` and `delegatecall-loop`, and any decent linter will warn on unprotected `delegatecall(address)`. What static tools miss is the semantic stuff: whether your implementation's `initialize` is actually reachable, whether storage layouts drift across upgrades, whether a selector clash lets an attacker hit an admin function through the fallback. Those need either a careful manual review or symbolic execution. Start with the free scan, then escalate to manual review when proxies are in the picture.