Unsafe Delegatecall Remediation
Overview
delegatecall runs the target’s code in the caller’s storage context while preserving msg.sender and msg.value. If the target address comes from calldata, or from a storage slot that any caller can change, an attacker supplies their own contract and gains write access to every storage slot — including the owner slot — and can drain funds or self-destruct the contract. The fix is to remove attacker control over the target: the implementation a contract delegates to must be fixed at construction or changeable only by an authorized admin.
Related Detector: Unsafe Delegatecall
Recommended Fix
Before (Vulnerable)
function execute(address target, bytes calldata data) external {
// Caller chooses the code that runs against this contract's storage
(bool ok, ) = target.delegatecall(data);
require(ok);
}
After (Fixed)
// The implementation is fixed at construction and cannot be redirected
address private immutable _implementation;
constructor(address impl) {
require(impl.code.length > 0, "Implementation has no code");
_implementation = impl;
}
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()) }
}
}
An immutable target cannot be changed after deployment, so there is no attacker-reachable path to substitute the code.
Alternative Mitigations
Upgradeable proxies legitimately need a mutable implementation. Store it in the standard EIP-1967 slot and gate every change behind an admin role, so only the authorized account can call upgradeTo. Use an audited proxy implementation (for example, OpenZeppelin’s ERC1967Proxy / UUPS) rather than hand-rolling the upgrade path.
Allowlist — if a contract must delegate to one of several known modules, store the permitted targets in an admin-controlled mapping and check membership before the delegatecall. Never delegate to an address taken directly from calldata.
Common Mistakes
Validating the target with extcodesize only — a non-empty code check does not make an attacker-supplied target safe; the attacker’s contract has code too. The target must be trusted, not merely deployed.
Authorizing the caller but not the target — onlyOwner on a delegatecall(target, ...) where target is still caller-supplied does not help once the owner key is the attacker, and it does nothing for a compromised privileged role. Constrain the target itself.