Access Control and the Parity Multisig
The previous page was about arithmetic that silently did the wrong thing. This page is about a subtler and more expensive failure: code that did exactly what it was told — it just never checked who was doing the telling. Reentrancy and overflow are bugs of sequence and math. The bug class on this page is a bug of authority: a function that changes state or moves money but never asks “are you allowed to call me?”
We call it broken access control, and on Ethereum it is the single most consequential category of smart-contract loss. The reason is the rule this book established back in Two Kinds of Account: any address can call any function on a deployed contract. There is no firewall, no login, no private network. A public function is public to the entire world — to every EOA and every contract, forever. If a function that can seize ownership or destroy a contract is reachable and unguarded, then it is reachable and unguarded by the attacker too. The Parity multisig wallet learned this twice in 2017, and the second lesson froze roughly half a million ether that has never moved since. This page dissects both incidents from first principles.
The bug class: a world-callable state change
Section titled “The bug class: a world-callable state change”Start with the mechanism, not the story. Every non-view function on a contract is an
entry point the whole network can invoke. When such a function changes state — sets an
owner, transfers funds, upgrades logic, kills the contract — it needs a guard: a check
that the caller is authorized. In Solidity the caller is msg.sender, and the guard is a
comparison against a stored owner or role:
address public owner;
modifier onlyOwner() { require(msg.sender == owner, "not authorized"); _; // the _ is where the guarded body runs}
function withdraw(uint amount) external onlyOwner { // guard runs FIRST payable(owner).transfer(amount);}The whole security of withdraw rests on one line: require(msg.sender == owner, ...).
Delete that line — or, just as fatally, forget to add it in the first place — and
withdraw becomes a function anyone on Earth can call to drain the balance. The bug is not
a typo in the logic; the logic works perfectly. The bug is an absent question.
A public function that moves state:
function doThing() { ... changes state ... } ▲ │ │ callable by ANY address │ so ANY address gets the effect └──────────────────────────┘
The ONLY thing standing between "the world" and "the effect" is an explicit check: require(msg.sender == owner)Broken access control comes in a few flavors that share a shape: a missing guard (the
check was never written — the July 2017 Parity bug), a wrong guard (it compares against
the wrong variable or a role that was never set, so it passes for everyone), and a
reachable dangerous primitive (a powerful opcode like selfdestruct or delegatecall
exposed through a function whose guard is missing or bypassable — the November 2017 bug).
Every one of these turns a privileged operation into a public one. To understand how a multisig wallet — a contract whose entire purpose is to require several signatures before moving funds — fell to this, we first need the architecture it was built on.
The Parity wallet architecture: a library and many thin wallets
Section titled “The Parity wallet architecture: a library and many thin wallets”Parity’s multisig was clever in a way that mattered. A multisig wallet holds funds and
releases them only when M of N owners approve. The obvious way to build one is: deploy a
full contract per wallet, each carrying all the multisig logic in its own code field. But
that logic is large, and deploying large bytecode is expensive — you pay gas per byte of
code deployed. If ten thousand users each deploy the same few kilobytes of logic, the
network stores ten thousand identical copies and each user pays for the whole thing.
Parity’s answer was shared library code plus delegatecall. Deploy the heavy logic
once as a single shared library contract. Then each user deploys a tiny “wallet” contract
that holds only their funds and their owner list, and forwards every call it doesn’t
recognize to the shared library:
wallet A (thin) wallet B (thin) wallet C (thin) ┌───────────┐ ┌───────────┐ ┌───────────┐ │ owners[] │ │ owners[] │ │ owners[] │ │ balance │ │ balance │ │ balance │ │ fallback ─┼──┐ │ fallback ─┼──┐ │ fallback ─┼──┐ └───────────┘ │ └───────────┘ │ └───────────┘ │ │ │ │ └──── delegatecall ──┴──── delegatecall ──┘ │ ▼ ┌──────────────────────────┐ │ SHARED LIBRARY (once) │ │ all the multisig logic │ │ deployed ONE time │ └──────────────────────────┘The forwarding is done with delegatecall, and delegatecall is the pivot of this
entire page. It is worth stating its rule precisely, because it is exactly what turns a
library bug into every wallet’s bug.
Under the hood — what delegatecall actually does
Section titled “Under the hood — what delegatecall actually does”A normal call runs the target’s code against the target’s storage, as its own
msg.sender. A delegatecall is different in one enormous way:
delegatecallruns the target’s code against the caller’s storage, keeping the caller’smsg.senderand the caller’saddress(this).
Read that twice. When wallet A delegatecalls the library, the library’s code executes,
but every SSTORE it performs writes into wallet A’s storage, and inside that code
address(this) is wallet A, and msg.sender is whoever called wallet A. The library is
just a code donor. It is as if wallet A borrowed the library’s instructions and ran them
on its own memory.
plain call: code = target, storage = target, this = target delegatecall: code = target, storage = SELF, this = SELF ^^^^^^^^^^^^^^^ ^^^^^^^^^^^ the caller's the caller'sThis is the substrate of nearly every upgradeable contract on Ethereum: a permanent thin
“proxy” holds the state, and delegatecalls into a swappable “implementation” that holds
the logic. Parity’s wallets were an early, hand-rolled version of exactly this proxy
pattern. The power is real — code reuse, upgradeability, cheap wallets. The danger is
equally real: the caller is trusting the target’s code to run against its own storage.
If that shared code has a function anyone can call, then anyone can call it in the context
of your storage.
July 2017: an unprotected initWallet seizes ownership
Section titled “July 2017: an unprotected initWallet seizes ownership”Now the first incident. The shared library needed a way to initialize a new wallet — to
write the initial owner list and daily-spend limit into a freshly deployed thin wallet.
Parity exposed this as an initWallet function in the library. Because wallets called the
library by delegatecall, initWallet ran against the wallet’s storage, writing the
owners into the wallet where they belonged. Structurally reasonable.
The fatal detail: initWallet had no access-control guard. It was meant to be called
once, at construction, by the wallet’s own setup. But it was a plain public function on the
library, reachable by delegatecall from any wallet — and nothing checked that the caller
was allowed to (re)initialize. So an attacker could send a transaction to someone else’s
wallet that forwarded to the library’s initWallet, and set themselves as the sole owner:
// Simplified sketch of the vulnerable shape.function initWallet(address[] _owners, uint _required, uint _daylimit) { // ← NO onlyUninitialized / onlyOwner check here. That is the whole bug. initMultiowned(_owners, _required); initDaylimit(_daylimit);} attacker ─── tx ──► victim wallet ─ delegatecall ─► library.initWallet( _owners = [attacker], _required = 1 ) │ ▼ runs against VICTIM's storage (delegatecall!) victim.owners = [attacker] ← attacker now owns the wallet │ ▼ second tx: attacker (now owner) calls execute() drains the wallet's entire balanceTwo transactions. The first calls the unguarded initWallet and overwrites the wallet’s
owner list with the attacker’s address. The second — now passing every ownership check,
because the attacker is the owner — withdraws everything. On 19 July 2017, an attacker
did this across several high-value Parity multisig wallets and took roughly 150,000 ETH
(on the order of tens of millions of USD at the time). A group of white-hat hackers
scrambled to drain the remaining vulnerable wallets first, using the same bug defensively,
and later returned those funds to their owners — a rescue that only worked because the bug
was symmetric: whoever called initWallet first, won.
The root cause, stated plainly: a state-changing function that set ownership was
world-callable because it lacked an authorization check. Everything about delegatecall
and shared libraries made the blast radius bigger, but the seed was one missing require.
November 2017: selfdestruct freezes the library — and every wallet
Section titled “November 2017: selfdestruct freezes the library — and every wallet”The July fix moved the initialization logic into the library and guarded it so it could only
be run once per wallet. But the library contract itself was deployed as an ordinary
account on-chain — and, critically, it had never been initialized as a wallet. Its own
owner slot was empty. Nobody thought that mattered, because the library was “just a code
donor”; users only ever delegatecalled into it. Nobody was supposed to interact with the
library directly.
But the library was a real, callable contract. And because its own owner had never been set,
its guards — the checks meant to protect a wallet — evaluated against an uninitialized
owner. On 6 November 2017, a user (by their account, exploring) called the library
directly, invoked its initializer to become the library’s owner, and then called the
library’s kill function — which executed selfdestruct.
selfdestruct deletes a contract’s code from the state, refunding its balance to a target
address. And here is where the architecture turned a single mistake into a catastrophe.
Every Parity wallet held no logic of its own — each was a thin proxy that delegatecalled
the library for everything. When the library’s code was destroyed, every wallet’s calls
now forwarded to an address with no code. A delegatecall to a codeless account does not
run any logic; it succeeds trivially and does nothing. So every function on every dependent
wallet — including withdraw — became a no-op. The funds were still recorded as belonging
to those wallets, but there was no longer any code in existence that could move them.
BEFORE (6 Nov): AFTER selfdestruct:
wallet ─ delegatecall ─► LIBRARY wallet ─ delegatecall ─► (no code) (logic) │ │ ▼ ▼ call succeeds, does NOTHING withdraw() works withdraw() is a no-op foreverRoughly 513,000 ETH across hundreds of wallets was frozen this way — not stolen, simply unspendable, because the only code that knew how to spend it had been deleted from the chain. There is no attacker balance to trace and no rescue transaction to send; the funds sit in their accounts to this day, reachable by no one. Multiple proposals to recover them via a protocol change were debated and ultimately not adopted — the community declined to rewrite state, treating the immutability of the ledger as more valuable than any one loss.
By the numbers
Section titled “By the numbers”The defenses: guard everything that changes state
Section titled “The defenses: guard everything that changes state”None of this required exotic tooling to prevent. The defenses are ordinary engineering discipline, applied without exception.
1. An explicit guard on every state-changing function
Section titled “1. An explicit guard on every state-changing function”Default to deny. Every function that mutates state, moves value, changes ownership, or alters logic must begin with an authorization check. Do not reason “no one would call this” — on a public chain, someone will. Use a named modifier so the guard is visible and hard to forget:
mapping(address => bool) public isOwner;
modifier onlyOwner() { require(isOwner[msg.sender], "not an owner"); _;}
function execute(address to, uint value) external onlyOwner { payable(to).transfer(value);}
function selfDestruct() external onlyOwner { // even the dangerous ones — ESPECIALLY these selfdestruct(payable(owner));}Role-based checks (an owner, an admin role, a multisig threshold) generalize the same
idea: read a stored authority, compare msg.sender against it, revert if it doesn’t match.
The check is cheap; its absence is unbounded.
2. Initialize safely — and treat “uninitialized” as dangerous
Section titled “2. Initialize safely — and treat “uninitialized” as dangerous”An uninitialized contract is a contract whose guards compare against zero. Both Parity incidents turned on this: a wallet that could be *re-*initialized (July), and a library that was never initialized (November). Two disciplines follow:
- Initialize exactly once, and enforce it. A one-shot initializer must set a flag and
refuse to run again:
require(!initialized); initialized = true;. Modern libraries encode this as aninitializermodifier. - Never leave a callable contract in an initializable state. If a contract can be
initialized, initialize it at deployment — including implementation/library contracts
that “aren’t meant to be called directly,” because anyone can call them directly. Modern
proxy libraries call
_disableInitializers()in the implementation’s constructor for exactly this reason: lock the logic contract so no stranger can seize it.
3. Be deliberate about delegatecall and selfdestruct
Section titled “3. Be deliberate about delegatecall and selfdestruct”These two opcodes are power tools, and both were load-bearing in the freeze.
delegatecallexpands your trusted surface to the target’s entire code. When youdelegatecallinto a library or implementation, you are letting its code write your storage. Treat the target as part of your own trusted computing base: pin it, review it, and control who can change which address you delegate to.selfdestructshould almost never exist in a contract that other contracts depend on. Destroying shared code destroys everyone who delegated to it. If akill/selfdestructpath exists at all, it must be behind the strongest guard you have — and think hard about whether it should exist. (Ethereum has since moved to deprecate and neuterselfdestructin later network upgrades — as of 2024 it no longer deletes contract code except within the same transaction that created the contract — precisely because “code can vanish out from under its dependents” proved to be a footgun the whole ecosystem tripped on.)
4. Prefer audited, battle-tested access-control libraries
Section titled “4. Prefer audited, battle-tested access-control libraries”You are not the first to need “only the owner may call this.” Standard implementations
(Ownable, AccessControl, and the initializer patterns in widely-audited contract
libraries) encode these guards correctly, including the one-shot-initialization discipline
above. Reaching for a reviewed component is refusing to re-derive a security primitive that
has already cost the ecosystem nine figures to get wrong.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Access control exists because on a public chain every function is callable by every address. Without an explicit guard, a privileged operation is a public one — so authorization has to be re-established in the contract itself, on every state-changing entry point.
- What problem does it solve? It restores the notion of authority to a system that has
no logins, no sessions, and no network perimeter.
require(msg.sender == owner)is how a contract answers the question “are you allowed to do this?” — the question the DAO, overflow, and Parity bugs all failed to ask in one way or another. - What are the trade-offs? The powerful patterns that make contracts cheap and
upgradeable — shared libraries,
delegatecallproxies — expand the surface that must be guarded. Reuse and upgradeability buy you flexibility at the cost of a larger trusted base and more places an authorization check can be missing, wrong, or bypassable. - When should I avoid it? You never avoid access control itself. What you should avoid is
unnecessary power: a contract with no
selfdestruct, no upgrade path, and no re-runnable initializer has far fewer dangerous functions to guard. Minimize privileged operations before you try to secure them. - What breaks if I remove it? Remove the guards and every privileged function becomes a public giveaway — ownership seizure, unlimited withdrawal, self-destruction, all one transaction away for anyone. Parity is the demonstration: remove one guard and 150,000 ETH walks; leave one contract unguarded-and-uninitialized and 513,000 ETH freezes forever.
The through-line of this book is how untrusting strangers agree on the state of a shared world computer. Access control is where that trust gets scoped: the ledger will faithfully execute whatever a function permits, so the contract’s guards are the only thing that decides which strangers may change which state. Next we turn to attacks that don’t break a single contract but manipulate the data contracts depend on: Oracle Manipulation and Flash Loans.
Check your understanding
Section titled “Check your understanding”- Define the “broken access control” bug class in one sentence, and name the specific Solidity value a guard typically checks to enforce it.
- Explain what
delegatecalldoes differently from a normalcall, using the words code and storage. Why did this make a library bug into an every-wallet bug? - Walk through the July 2017 attack in two transactions. What was the single missing element
in
initWallet, and why did being able to call it let the attacker drain funds? - In the November 2017 incident, no ether was stolen, yet ~513,000 ETH was lost. What happened to the library, and why did destroying one contract freeze hundreds of separate wallets?
- You are reviewing a contract with an
upgradeTo(address newImpl)function and akill()function. Name three specific defenses from this page you would insist on before it ships.
Show answers
- Broken access control is when a state-changing or fund-moving function is callable by
anyone because it fails to check whether the caller is authorized — leaving a privileged
operation world-callable. The guard typically compares
msg.senderagainst a stored owner or role (e.g.require(msg.sender == owner)). - A normal
callruns the target’s code against the target’s storage; adelegatecallruns the target’s code against the caller’s storage (keeping the caller’smsg.senderandaddress(this)). Because every thin Parity walletdelegatecalled one shared library for all its logic, a flaw in that library executed in the context of each wallet’s own storage — so one library bug was reachable through, and affected, every dependent wallet. - Tx 1: the attacker sends a transaction to a victim wallet that forwards (via
delegatecall) to the library’s unguardedinitWallet, overwriting the wallet’s owner list with the attacker’s address. Tx 2: now the legitimate owner, the attacker calls the withdraw/execute path — every ownership check passes — and drains the balance. The missing element was any authorization check oninitWallet(it could be called, and re-called, by anyone), which let the attacker install themselves as owner. - A user called the shared library contract directly, claimed ownership because the
library’s own owner had never been initialized, and triggered its
selfdestruct, deleting the library’s code from the chain. Every wallet held no logic of its own anddelegatecalled that library for everything — so with the library’s code gone, every wallet’s functions (includingwithdraw) forwarded to a codeless address and became permanent no-ops, freezing all their funds. - Any three of: (a) put an explicit
onlyOwner/role guard on bothupgradeToandkill— especially the dangerous ones; (b) ensure the contract (and any implementation it delegates to) is initialized exactly once and cannot be re-initialized, and that logic/implementation contracts are locked (_disableInitializers()) so no stranger can seize them; (c) question whetherkill()/selfdestructshould exist at all on a contract others depend on, since destroying it can freeze every dependent; (d) treat thedelegatecalltarget as part of the trusted computing base — pin and control which addressupgradeTocan point to; (e) prefer audited access-control/initializer libraries over hand-rolled checks.