Access Control Vulnerabilities: The $1B+ Weakness
The two most expensive access control failures in Ethereum history share a structural pattern that is invisible at the function level but obvious at the opcode level: a publicly callable state-changing function with no CALLER comparison on the execution path leading to a privileged operation.
Parity’s WalletLibrary lost $150M because initWallet() was public and kill() used selfdestruct. Nomad lost $190M because committedRoot was never set after a faulty upgrade, making the acceptance check 0x00 == 0x00. Different codebases, different teams, same root: a path from external calldata to a privileged operation with no authentication gate.
This post focuses on proxy patterns specifically, because that is where the failure mode is most counterintuitive and where the damage is structural rather than just financial.
Why Proxy Contracts Break Access Control Intuitions
In a non-proxy contract, the constructor runs once and sets the owner. That slot is initialized. There is no way to re-initialize it.
In a proxy pattern, the implementation contract’s constructor never runs in the context that matters — it runs on the implementation address, not on the proxy. The proxy’s storage starts uninitialized. If initialize() is not called on the proxy during deployment, any slot that initialize() would have set remains at its zero value.
That is the Nomad bug. The upgrade procedure ran the initialize() call incorrectly, leaving committedRoot at 0x00. The acceptance function:
function acceptableRoot(bytes32 _root) public view returns (bool) {
return committedRoot == _root || confirmAt[_root] != 0;
}
…evaluates to true for any message where _root == 0x00. An attacker sends _root = 0x00. The check passes. The message executes. The attack was then copied hundreds of times by observers watching the mempool — the entire bridge drained over the following hours.
The check was not missing. The comparison was not wrong. The state it was comparing against was zero because nobody called initialize().
The Implementation Contract Problem
There is a second proxy vulnerability class that is conceptually separate: attacks on the implementation contract itself.
The implementation contract has a storage layout, an initialize() function, and often a selfdestruct. Since the proxy uses DELEGATECALL, all execution happens in the proxy’s storage context. The implementation contract’s own storage is irrelevant to normal operation.
But the implementation is still a deployed contract. It can be called directly. When you call initialize() on the implementation address rather than the proxy, you initialize the implementation’s own storage — you become its owner.
The Parity attack worked exactly this way:
contract WalletLibrary {
address public owner;
function initWallet(address _owner) public {
owner = _owner;
}
function kill(address _to) public {
require(msg.sender == owner);
selfdestruct(_to);
}
}
initWallet() is public. No one had called it on the library contract. The attacker called it, became owner, then called kill(). The library self-destructed. Every proxy that delegated to this library became permanently frozen — the code they pointed to no longer existed. 587 wallets, $150M locked forever.
The fix is two lines in the constructor:
constructor() {
_disableInitializers();
}
OpenZeppelin’s _disableInitializers() sets a storage flag in a well-known EIP-1967 slot that causes every initializer-modified function to revert when called. The implementation can never be initialized directly.
The tx.origin Problem
A related but distinct class of authentication failure uses tx.origin instead of msg.sender. This is not a proxy problem — it appears in any contract that checks who initiated the outermost transaction rather than who called the current function.
function withdraw(uint amount) external {
require(tx.origin == owner, "Not owner");
payable(tx.origin).transfer(amount);
}
The attack is social engineering. The attacker deploys a contract and convinces the owner to call a function on it — perhaps through a phishing UI or a malicious token airdrop claim. When the owner calls that contract, tx.origin is the owner. The malicious contract calls withdraw(). The tx.origin == owner check passes even though msg.sender is the attacker’s contract.
The fix is msg.sender. The EVM opcode CALLER returns the immediate caller. ORIGIN returns the transaction signer. Authentication should always use CALLER.
What Detection Looks Like at the Bytecode Level
These patterns have distinct bytecode signatures.
For missing authentication on privileged operations: the detector looks for SELFDESTRUCT, DELEGATECALL, or CALL with non-zero value on execution paths that have no CALLER comparison (i.e., no EQ or comparison opcode consuming the output of CALLER). If you can reach SELFDESTRUCT from any function entry point without hitting a CALLER/EQ/JUMPI sequence, that function lacks access control.
For user-controlled delegatecall targets: the detector runs taint analysis from CALLDATALOAD through the computation graph to the second operand of DELEGATECALL. If user-provided data reaches the call target unfiltered, the contract can be directed to execute arbitrary code in its own storage context.
For arbitrary storage writes: CALLDATALOAD → computation → SSTORE first operand. If the slot being written is derived from user input rather than a fixed hash or mapping key computed from a known key type, the user controls which storage slot gets overwritten. Overwriting slot 0 on a standard contract sets the owner.
For unprotected initialization: the detector identifies initialize-pattern functions (state-setting, not view, named or structured to run once) and checks whether a _locked/_initialized guard slot is loaded and checked before the state writes. If the only protection is an application-level require(!initialized) without the _disableInitializers() pattern, the implementation contract itself is vulnerable.
Mitigations That Actually Work
For proxy implementations: call _disableInitializers() in the constructor. Use OpenZeppelin’s Initializable base contract. The initializer modifier ensures the function runs once; the constructor call ensures it can never run on the implementation address.
For upgrade functions: gate upgradeTo() and upgradeToAndCall() behind an onlyOwner or role check. The EIP-1967 implementation slot (0x360894...) should only be writable by an authorized address. Transparent proxies enforce this in the proxy itself; UUPS proxies enforce it in _authorizeUpgrade().
For delegatecall targets: whitelist. Never accept a target address from calldata and pass it directly to DELEGATECALL. Maintain an explicit mapping of allowed implementation addresses and validate against it.
For administrative operations: multi-sig plus timelock. A timelock gives stakeholders time to observe a malicious governance action before it executes. The minimum reasonable delay for a production protocol is 24-48 hours; many use 7 days for operations that affect core parameters.
A Note on Confidence Scoring
Not all access control findings carry the same confidence. Missing authentication on selfdestruct in a non-library contract with no constructor argument is high confidence — there is very little legitimate reason for a public unguarded selfdestruct. Missing authentication on an ETH transfer function that appears in a contract implementing EIP-2612 permit logic is lower confidence — the authentication may be indirect.
The highest-confidence findings come from taint chains: CALLDATALOAD → DELEGATECALL target, or CALLDATALOAD → SSTORE slot. These are almost always vulnerabilities because legitimate contracts compute their own call targets from whitelisted storage, not from caller-provided data.