delegatecall is the most dangerous opcode in Solidity. It does not execute foreign code in that foreign contract's context — it executes it in yours. Every storage write, every selfdestruct, every state mutation happens inside the calling contract. When that foreign code is attacker-controlled, the result is total contract takeover.
Six vulnerability classes, each with vulnerable code, the attack path, and a fixed version. Three caused nine-figure losses in production; the others are less known but equally exploitable.
How delegatecall Differs From call
Regular call: Caller ──call──► Callee
(runs in Callee's storage, Callee's msg.sender)
delegatecall: Caller ──delegatecall──► Implementation
(runs in Caller's storage, Caller's msg.sender is preserved)
The implementation's bytecode executes, but reads and writes the caller's storage slots, with msg.sender preserved. This powers proxy patterns — and makes every delegatecall to untrusted code catastrophic.
1. Owner Takeover via Delegatecall to Attacker-Controlled Address
The most direct exploit: a contract that accepts a user-supplied address as the delegatecall target.
// VULNERABLE: user controls the delegatecall target
contract MultiSigWallet {
address public owner; // slot 0
uint256 public dailyLimit; // slot 1
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
// Intended to forward calls to trusted library functions
function execute(
address target,
bytes calldata data
) external onlyOwner returns (bytes memory) {
(bool success, bytes memory result) = target.delegatecall(data);
require(success, "delegatecall failed");
return result;
}
}
The attack. An attacker deploys this contract:
contract TakeoverPayload {
// Must match MultiSigWallet storage layout exactly
address public owner; // slot 0
function overwriteOwner(address attacker) external {
// Writes to slot 0 — which is 'owner' in the calling contract
owner = attacker;
}
}
The attacker calls execute(takeoverPayloadAddress, abi.encodeWithSignature("overwriteOwner(address)", attackerAddress)). TakeoverPayload bytecode runs inside MultiSigWallet's storage context, and slot 0 — owner — is overwritten with the attacker's address.
Historical context. The Parity Multisig hack of July 2017 drained $31M via this mechanism. The wallet's execute delegatecalled to a user-supplied library address; the attacker supplied their own contract and overwrote owner.
Fix: never accept user-supplied delegatecall targets.
contract SafeMultiSig {
address public owner;
address public immutable LIBRARY; // fixed at deploy time
constructor(address lib) {
owner = msg.sender;
LIBRARY = lib;
}
function execute(bytes calldata data) external onlyOwner returns (bytes memory) {
(bool success, bytes memory result) = LIBRARY.delegatecall(data);
require(success, "delegatecall failed");
return result;
}
}
If dynamic dispatch is required, use an on-chain whitelist with timelock governance — never a raw parameter.
2. Library Selfdestructed or Redeployed (Parity Library Freeze)
A subtler class: the library is a deployed contract with no access controls on its initializer — anyone can claim ownership and then destroy it.
// VULNERABLE: library contract deployed as a standalone address
contract WalletLibrary {
address public owner; // slot 0
// Intended to be called only via delegatecall from a proxy wallet
// But the library IS a contract — anyone can call it directly
function initWallet(address _owner) external {
require(owner == address(0), "already initialized");
owner = _owner;
}
function kill() external {
require(msg.sender == owner, "not owner");
selfdestruct(payable(owner));
}
}
// Proxy wallet — delegates everything to WalletLibrary
contract Wallet {
address public owner; // slot 0 — mirrors WalletLibrary layout
fallback() external payable {
address lib = 0xabcd...; // hardcoded WalletLibrary address
(bool ok,) = lib.delegatecall(msg.data);
require(ok);
}
}
The attack. Because WalletLibrary is a real deployed contract, anyone can call initWallet directly on it — not through a proxy. An attacker calls WalletLibrary.initWallet(attackerAddress), becomes the library's owner, then calls kill(). The library selfdestructs. Every Wallet proxy delegatecalling to that address now calls dead bytecode — permanently frozen.
Historical context. Second Parity hack, November 2017: a researcher accidentally called initWallet on the deployed library, then called kill(), freezing 587 wallets holding $152M in ETH.
Fix: libraries must not have initializers or ownership.
// Safe: Solidity library keyword — cannot be deployed standalone or called directly
library SafeWalletLib {
struct WalletStorage {
address owner;
uint256 dailyLimit;
}
function getStorage() internal pure returns (WalletStorage storage ws) {
bytes32 slot = keccak256("wallet.storage.v1"); // EIP-1967 pattern
assembly { ws.slot := slot }
}
function initialize(address _owner) internal {
WalletStorage storage ws = getStorage();
require(ws.owner == address(0), "already initialized");
ws.owner = _owner;
}
}
Using Solidity library (not contract) prevents direct deployment and direct calls. For proxy patterns, use EIP-1967 storage slots and OpenZeppelin's Initializable modifier.
3. Storage Slot Mismatch Between Proxy and Implementation
Proxy contracts delegatecall to implementations so logic runs against the proxy's storage. If variable declaration order differs between the two, reads and writes target the wrong slots.
// Proxy contract storage layout
contract ERC20Proxy {
address public owner; // slot 0
uint256 public balance; // slot 1
address public implementation; // slot 2
}
// Implementation — DIFFERENT variable order
contract ERC20Implementation {
uint256 public totalSupply; // slot 0 ← COLLISION with proxy's 'owner'
address public admin; // slot 1 ← COLLISION with proxy's 'balance'
function mint(address to, uint256 amount) external {
require(msg.sender == admin, "not admin");
totalSupply += amount;
// ...
}
function setTotalSupply(uint256 supply) external {
totalSupply = supply; // writes slot 0 — overwrites proxy's 'owner'!
}
}
The attack path. When setTotalSupply(1000) executes via delegatecall, the implementation writes 1000 to slot 0 — but in the proxy's storage, slot 0 is owner. Ownership is overwritten with address(1000), a nonsense address no one controls. Because admin in the implementation sits at slot 1 (proxy's balance), an attacker who controls balance can set themselves as admin and pass the mint access check.
Fix: share a storage base contract so slots stay aligned.
// Both proxy and implementation inherit this — slots are identical
contract StorageLayout {
address public owner; // slot 0
uint256 public balance; // slot 1
address public implementation; // slot 2
uint256 public totalSupply; // slot 3
address public admin; // slot 4
}
contract ERC20ProxyV2 is StorageLayout {
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()) }
}
}
}
In production, use EIP-1967 unstructured storage slots and run OpenZeppelin's upgrade safety checks before each new implementation.
4. Delegatecall in Constructor (Frozen Immutable Bug)
Solidity immutables are embedded into bytecode at constructor completion. A delegatecall from a constructor executes in the called contract's context — immutables set there never land in the deploying contract's bytecode.
// VULNERABLE: delegatecall during construction
contract UpgradeableToken {
address public immutable FACTORY; // immutable — set in constructor
address public owner;
constructor(address factory, address initLogic, bytes memory initData) {
FACTORY = factory; // correctly set — in this constructor's context
// delegatecall runs initLogic's bytecode in UpgradeableToken's context
// but immutables set inside initLogic don't write to UpgradeableToken's bytecode
(bool ok,) = initLogic.delegatecall(initData);
require(ok, "init failed");
}
}
contract InitLogic {
address public immutable OWNER; // set here has no effect on UpgradeableToken
function initialize(address _owner) external {
// silently does nothing for UpgradeableToken's OWNER immutable
}
}
The failure mode. The delegatecall succeeds (returns true) but the immutable state is never set in UpgradeableToken's bytecode. The contract deploys without error but reads zero or garbage for those values. No revert, no event — wrong state silently baked into a live contract.
Fix: use initialize() functions, not constructors, for upgradeable contracts.
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
contract UpgradeableTokenV2 is Initializable {
address public owner;
uint256 public totalSupply;
// Proxy calls initialize() via normal call after deployment — no constructor delegatecall
function initialize(address _owner, uint256 _supply) external initializer {
owner = _owner;
totalSupply = _supply;
}
}
The initializer modifier ensures initialize() runs exactly once. The proxy calls it via a normal call after deployment — no constructor delegatecall, so state is set correctly.
5. Return Value of Delegatecall Not Checked
delegatecall returns (bool success, bytes memory returnData). Ignoring success means a failed library call goes undetected — execution continues with corrupted or unchanged state.
// VULNERABLE: delegatecall return value ignored
contract VaultRouter {
address public lib;
mapping(address => uint256) public shares;
function deposit(uint256 amount) external {
// Calls library to compute shares — but ignores whether it succeeded
(bool success, bytes memory result) = lib.delegatecall(
abi.encodeWithSignature("computeShares(uint256)", amount)
);
// 'success' is discarded — if lib has a bug or wrong ABI, result is garbage
uint256 computed = abi.decode(result, (uint256));
shares[msg.sender] += computed;
}
}
The failure scenario. If lib is upgraded with a different ABI, the delegatecall dispatches to the fallback or reverts internally — success is false. Since the caller ignores it, result is empty bytes. abi.decode either reverts by luck or decodes as zero, producing a silent no-op. If a storage layout change accompanies the ABI change, the call succeeds but corrupts state with no indication.
Fix: always check success.
contract SafeVaultRouter {
address public lib;
mapping(address => uint256) public shares;
function deposit(uint256 amount) external {
(bool success, bytes memory result) = lib.delegatecall(
abi.encodeWithSignature("computeShares(uint256)", amount)
);
require(success, "library call failed");
uint256 computed = abi.decode(result, (uint256));
require(computed > 0, "invalid share computation");
shares[msg.sender] += computed;
}
}
call, staticcall, and delegatecall all return a success boolean — always check it before using return data.
6. Reentrancy via Delegatecall (Cross-Function Reentrancy)
A reentrancy guard in the caller's storage protects direct functions — but if the caller delegatecalls to a library that makes an external call, the callee can reenter the caller with the guard in an unexpected state.
// VULNERABLE: library makes external call, opening reentrancy window
contract LiquidityPool {
bool private _locked; // slot 0 — reentrancy guard
uint256 public reserve; // slot 1
modifier nonReentrant() {
require(!_locked, "reentrant");
_locked = true;
_;
_locked = false;
}
function withdraw(uint256 amount) external nonReentrant {
require(reserve >= amount, "insufficient");
reserve -= amount;
payable(msg.sender).transfer(amount);
}
// Delegates to a "trusted" library for complex withdrawal logic
function complexWithdraw(uint256 amount, bytes calldata params) external nonReentrant {
// _locked is TRUE here — guard is set
(bool ok,) = address(withdrawLib).delegatecall(
abi.encodeWithSelector(IWithdrawLib.execute.selector, amount, params)
);
require(ok);
}
}
// Library — runs in LiquidityPool's storage context
contract WithdrawLib {
bool private _someFlag; // slot 0 in this contract = _locked in LiquidityPool!
function execute(uint256 amount, bytes calldata params) external {
_someFlag = false; // clears LiquidityPool._locked (slot 0)
payable(msg.sender).transfer(amount); // attacker's receive() reenters
}
}
The exploit path. The library runs in LiquidityPool's storage context. If it writes to its own slot 0 for any reason, it overwrites _locked in the proxy's storage. With the guard cleared before the external transfer, the attacker's receive() reenters LiquidityPool.withdraw() — _locked is false, nonReentrant passes, the pool is drained. Even without explicit slot corruption, a library that makes external calls creates a reentrancy surface: the callee can invoke other pool functions that share state.
Fix: delegate computation only — not external calls.
contract SafeLiquidityPool {
bool private _locked;
uint256 public reserve;
modifier nonReentrant() {
require(!_locked, "reentrant");
_locked = true;
_;
_locked = false;
}
function complexWithdraw(uint256 amount, bytes calldata params) external nonReentrant {
// Library computes only — no external calls inside
(bool ok, bytes memory result) = address(computeLib).delegatecall(
abi.encodeWithSelector(IComputeLib.compute.selector, amount, params)
);
require(ok, "compute failed");
uint256 finalAmount = abi.decode(result, (uint256));
require(reserve >= finalAmount, "insufficient");
reserve -= finalAmount;
payable(msg.sender).transfer(finalAmount); // external call last
}
}
Libraries invoked via delegatecall should compute values and update state only — never make external calls that can trigger callbacks.
What ContractScan Detects
ContractScan performs both static pattern analysis and AI-driven semantic analysis across all six vulnerability classes:
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Owner takeover via user-controlled delegatecall target | Static: taint analysis on delegatecall target | Critical |
| Library selfdestruct / unprotected initializer | Static: initializer + ownership pattern matching | Critical |
| Storage slot mismatch between proxy and implementation | AI: cross-contract slot layout comparison | High |
| Delegatecall in constructor — immutable initialization failure | Static: constructor delegatecall pattern | High |
| Unchecked delegatecall return value | Static: return value flow analysis | Medium |
| Reentrancy via delegatecall with external callback | AI: cross-function reentrancy with delegatecall path | High |
Storage slot mismatch and cross-function reentrancy require reasoning across multiple contracts — static pattern matching cannot catch them. ContractScan's AI analysis compares proxy and implementation layouts and traces delegatecall paths through external calls.
Related Posts
- Uninitialized Proxy Vulnerabilities: The initialize() Function Attack
- Storage Layout Collision in Proxy Contracts
Scan your contracts at contractscan.io before deployment. delegatecall bugs are silent — no revert, no event, undetected until an attacker finds them.
This post is for educational purposes only. It does not constitute financial, legal, or investment advice. Always conduct a professional audit before deploying smart contracts to production.
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.