3.10.2 Parity Multi-Sig (July + November 2017)
The two Parity Multi-Sig incidents in 2017 are technically distinct but share an architecture and a root cause. The July 19 incident saw an attacker drain ~153,000 ETH (worth ~$30M at the time) from three high-profile multi-sig wallets through a missing modifier on the initialization function. The November 6 incident saw a single user accidentally trigger a related bug in the fixed library, then selfdestruct the library, permanently freezing ~514,000 ETH (worth ~$280M at the time) across 587 wallets.
Combined, the two incidents account for one of the largest losses in Ethereum history — and unlike many later exploits, much of the loss has never been recovered. The frozen funds from November 2017 remain frozen as of this writing. The Parity incidents are the canonical case study for the dangers of upgradeable contract design, unprotected initializers, and selfdestruct in shared libraries. Section 3.8.4 (Access Control Failures), Section 3.8.9 (Storage & Delegatecall), and Section 3.7.3 (Access & Authorization Patterns) all draw their core defensive patterns from this case.
Context
Parity Technologies (now Polkadot's developer) shipped an Ethereum client written in Rust. Alongside the client, they released a "Parity Multi-Sig Wallet" — a smart contract package for managing pooled funds with M-of-N signature requirements. The Parity wallet was widely adopted, especially among ICO projects holding raised funds and developer teams managing project treasuries.
The design choice that made both incidents possible: rather than deploy a full wallet contract per user, Parity deployed a single shared WalletLibrary contract and let each individual wallet be a small "stub" that forwarded all calls to the library via delegatecall. This saved substantial deployment gas — the heavy code lived in one shared library; each individual wallet was tiny. The wallets all delegated to the library, so the library's code executed against each wallet's own storage.
This architecture was theoretically sound. The execution was where the bugs lived.
The Architecture
// Simplified Parity wallet stub (each user's wallet)
contract Wallet {
address constant LIBRARY = 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4;
constructor(address[] memory _owners, uint256 _required) {
// Forward construction to the library via delegatecall
(bool ok, ) = LIBRARY.delegatecall(
abi.encodeWithSignature("initWallet(address[],uint256,uint256)",
_owners, _required, 50 ether)
);
require(ok);
}
fallback() external payable {
// Forward all other calls to the library via delegatecall
(bool ok, ) = LIBRARY.delegatecall(msg.data);
require(ok);
}
}
Each user's Wallet is small — a constant address pointing to the library, a constructor that initializes via delegatecall, and a fallback that delegates everything. The library at 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4 contained the actual logic: initWallet, execute, confirm, kill, and dozens of other functions that the stub forwarded.
The library's code, when called via the stub's delegatecall, ran against the stub's storage — modifying the stub's owners, balances, and confirmation state. This is the classic library pattern, and it would have worked correctly if the library's functions had been protected appropriately.
Vulnerable Code (July 19 Incident)
The bug was in the library's initWallet function:
// From the actual WalletLibrary contract (simplified)
contract WalletLibrary {
address[] m_owners;
uint256 m_required;
// ... other state
// BUG: no access control
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
function initMultiowned(address[] _owners, uint _required) {
m_required = _required;
m_owners.push(msg.sender);
for (uint i = 0; i < _owners.length; ++i) {
m_owners.push(_owners[i]);
}
}
function execute(address _to, uint256 _value, bytes _data) onlymanyowners(...) returns (bool) {
// ... transfer logic
}
}
Two compounding bugs:
1. initWallet has no access control. No onlyOwner, no onlyUninitialized, no initializer modifier. The function was intended to be called only once, during construction. It's only callable once if no one calls it again — and the public visibility means anyone can call it again.
2. The wallet's fallback blindly delegates all calls to the library. Any function present in the library — including initWallet — can be invoked on the wallet by anyone, simply by encoding the function call into a transaction targeting the wallet.
These two facts combined: an attacker could send a transaction to anyone's Parity multi-sig wallet, calling initWallet(<attacker_address>, 1, ...). The wallet's fallback would delegate this to the library; the library's initWallet would run against the wallet's storage; the attacker would become the sole owner with a 1-of-1 threshold. The attacker could then call execute and drain the wallet.
The July 19 Attack
The attacker performed two transactions per victim wallet:
Transaction 1 — Become the owner:
target: <victim wallet>
data: initWallet([<attacker>], 1, 50 ether)
The wallet's fallback forwarded this to the library via delegatecall. The library's initWallet ran in the wallet's storage context, overwriting m_owners with [attacker] and m_required with 1.
Transaction 2 — Drain the funds:
target: <victim wallet>
data: execute(<attacker>, <full balance>, "")
The wallet's fallback forwarded this to the library. The library's execute checked that the caller (msg.sender = attacker) was an authorized owner — which they now were, having just made themselves the sole owner. The execute transferred the full balance to the attacker's address.
The attacker hit three high-profile wallets in quick succession:
- Edgeless Casino: 26,793 ETH (~$5.7M)
- Swarm City: 44,055 ETH (~$9.4M)
- æternity: 82,189 ETH (~$17.5M)
Total stolen: 153,037 ETH ($30M).
The White Hat Response
Within hours, a group calling themselves the "White Hat Group" identified the vulnerability and noticed that every Parity multi-sig wallet was vulnerable, not just the three the original attacker had hit. They executed the same exploit against approximately 500 other vulnerable wallets, draining funds into white-hat-controlled addresses for safekeeping. The total rescued was approximately $166M in ETH and tokens.
The White Hat Group later returned the rescued funds to their rightful owners through new, non-vulnerable wallets. This counter-exploit was one of the earliest cases of "whitehat hacking as defense" in DeFi — a pattern that has since been repeated multiple times in subsequent exploits.
The Fix
Parity deployed a new WalletLibrary contract on July 20, 2017. The fix added access control to the initialization functions:
modifier only_uninitialized { if (m_numOwners > 0) revert(); _; }
function initWallet(address[] _owners, uint _required, uint _daylimit)
only_uninitialized
{
// ... same as before
}
The modifier checks if the wallet has already been initialized (m_numOwners > 0). If so, the call reverts. The fix appeared comprehensive: existing wallets had m_numOwners > 0 after their initial construction, so further initWallet calls would fail.
But the fix was incomplete in a subtle way. The library itself — the contract at 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4 — had never been initialized as a wallet. The library's own m_numOwners was zero. The library could be initialized by anyone.
Vulnerable Code (November 6 Incident)
The November bug was structurally the same as the July bug, applied to a different target: the library itself, rather than wallets that delegated to it.
contract WalletLibrary {
address[] m_owners;
uint256 m_required;
uint256 m_numOwners;
// ... other state
modifier only_uninitialized { if (m_numOwners > 0) revert(); _; }
function initWallet(address[] _owners, uint _required, uint _daylimit)
only_uninitialized // protects against re-initialization
{
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// BUG: no protection at the library level
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
suicide(_to);
}
}
The library was deployed normally. Its m_numOwners was zero. The only_uninitialized modifier on initWallet was true. An attacker (or a careless user) could:
-
Call
initWalleton the library directly — not on a wallet that delegates to the library, but on the library's own address. Theonly_uninitializedmodifier passes (numOwners is 0). The library's own state gets initialized with the caller as owner. -
Call
killon the library directly — now the caller is the sole owner;onlymanyownerspasses; the library callssuicide(attacker), which transfers the library's balance (effectively zero) to the attacker and destroys the library's code.
The library at address 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4 no longer existed after this. Its bytecode was removed from the chain.
The Consequences
Every Parity multi-sig wallet deployed after July 20, 2017 had its fallback hardcoded to delegate calls to 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4. With no code at that address, every delegatecall:
- Did not revert (calls to addresses with no code succeed by default)
- Did not execute any logic
- Returned successfully with empty return data
The wallets continued to "function" in the sense that transactions to them didn't revert. But the actual wallet logic — execute, confirm, anything that moved funds — silently did nothing. The funds were locked in the wallet contracts, with no executable path to ever extract them.
The damage:
- 587 wallets affected
- ~514,000 ETH frozen (worth ~$280M at the time; well over $1.5B at recent prices)
- No path to recovery short of an Ethereum hard fork
The User Behind It
The November incident was triggered by a single user with the GitHub handle devops199, who posted shortly afterward: "I accidentally killed it." They had apparently been exploring the July vulnerability in the deployed library (which they did not realize had been fixed in a new version), accidentally initialized the library to themselves, then attempted to undo their mistake by calling kill — which destroyed the library.
The user was not malicious in intent. They were a researcher who did not fully understand the consequences of the operations they were testing. But the consequences were the same as if a malicious actor had done it deliberately, because the library's destructive functions were callable by whoever could become the library's owner — and no protection ensured the library could never have an owner in the first place.
Root Cause
Both Parity incidents share a single underlying root cause: the library was an active contract with public functions, not a passive code library, but it was designed and operated as if it were a passive code library.
The compounding causes:
1. initWallet had no access control in v1 (Section 3.8.4). A function that should run exactly once at construction was callable at any time by anyone. The standard developer assumption "no one will call this twice because why would they?" is not a security defense.
2. Library contained selfdestruct (Section 3.8.9). The kill function existed because a wallet might legitimately want to self-destruct (return funds and clear state). But the library contained this code; if the library could be initialized as a wallet and kill called on it, the library itself would be destroyed.
3. Wallet stubs hardcoded the library address as effectively immutable. When the library was destroyed, the wallets had no fallback, no recovery mechanism, no way to point at a replacement library. The constant address made the library a single point of failure.
4. The library could be initialized as a wallet. This is the deepest architectural flaw. The library should never have been able to function as a wallet at all. Its initWallet should have been impossible to call on the library itself — by being marked as only callable via delegatecall (which requires runtime checks since the EVM doesn't directly expose this), or by the library's constructor permanently disabling initialization.
5. Solidity language limitations of the era. The patterns that would have prevented these bugs — Initializable with initializer modifiers, _disableInitializers() in implementation constructors, EIP-1967 standard slots — did not yet exist as standard library components. The Parity wallet's authors were writing in a language and ecosystem that hadn't yet developed the conventions their architecture required.
Lessons
The Parity incidents produced many of the conventions that modern upgradeable-contract development takes for granted:
1. initializer modifier as a non-negotiable pattern. OpenZeppelin's Initializable library — which provides the initializer modifier — was developed in direct response to the Parity incidents. Section 3.8.1 covers it as the standard solution to the constructor-vs-initializer trap. Any function that initializes upgradeable state must be marked initializer (or its multi-version variants).
2. _disableInitializers() in implementation constructors (Section 3.8.9). The November incident's deepest lesson: the implementation contract itself must be uninitializable. Modern OpenZeppelin upgradeable contracts have a constructor that calls _disableInitializers() to ensure the implementation can never be used as a working contract independent of the proxy.
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
contract WalletImplementation is Initializable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address[] memory _owners, uint256 _required) external initializer {
// ...
}
}
This pattern is now so standard that omitting it is considered a critical bug. The Wormhole bridge's Ethereum-side contracts had a closely-related issue in early 2022 — an unprotected initializer on an implementation contract — which was discovered and reported by white-hat researcher samczsun and patched before exploitation. The pattern of "missing initializer protection on a critical infrastructure contract" recurred across the industry for years after Parity; Wormhole's was one of many near-misses that did not become an incident only because someone responsible found it first.
3. selfdestruct is dangerous and should be avoided. The library contained selfdestruct because the design assumed it would only be invoked on a fully-owned wallet. The November incident showed that any path that could lead to selfdestruct on shared infrastructure is a path to catastrophic failure. Modern practice: do not include selfdestruct in upgradeable or shared contracts at all. EIP-6780 (March 2024) further blunts selfdestruct by making it only destroy contracts created in the same transaction — but this does not retroactively fix Parity, and the principle "don't write code that might destroy something important" stands.
4. Library / implementation contracts must be passive. A shared library or implementation contract should not be usable as a standalone contract. The constructor should disable initialization; no privileged operations should be callable on the library directly; the only legitimate use of the library is via delegatecall from approved proxies.
5. Critical address hardcoding is fragile. The Parity wallets hardcoded the library's address. When the library was destroyed, there was no recovery path. Modern proxy patterns (EIP-1967, Transparent Proxy, UUPS) all allow the implementation address to be upgraded by a privileged operation. This adds risk — the upgrade authority becomes a target — but it adds the option of recovery, which the Parity wallets fundamentally lacked.
6. The "no hard fork" precedent was established here. Unlike The DAO (Section 3.10.1), the Parity multi-sig freeze was not reversed via hard fork. Multiple proposals — EIP-156, EIP-867, EIP-999 — would have unfrozen the funds, but none reached consensus. The community concluded that the Parity bugs were the result of code errors and operational decisions made by Parity Technologies and the affected wallet owners, not the result of a protocol-level flaw. Funds frozen by code bugs in user-deployed contracts would, going forward, stay frozen. This precedent has held across every subsequent large loss in Ethereum.
7. Cross-version testing of upgradeable systems. The July fix addressed the wallet-level bug but introduced (or failed to fix) the library-level bug. Modern upgrade safety — including tools like the OpenZeppelin Upgrades Plugin — checks for these cross-version issues. The plugin compares storage layouts, checks for unsafe patterns, and refuses unsafe upgrades. Section 3.5 covers the modern upgrade workflow.
Modern Reproduction
A minimal reproduction of the July 2017 pattern in modern Solidity:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Library — vulnerable in the July 2017 way
contract VulnerableLibrary {
address public owner;
// BUG: no initializer modifier; no access control
function initialize(address _owner) external {
owner = _owner;
}
function execute(address payable _to, uint256 _amount) external {
require(msg.sender == owner, "not owner");
_to.transfer(_amount);
}
}
// Wallet — delegates to library
contract VulnerableWallet {
address public immutable library;
constructor(address _library, address _owner) {
library = _library;
(bool ok, ) = library.delegatecall(
abi.encodeWithSignature("initialize(address)", _owner)
);
require(ok);
}
fallback() external payable {
// BUG: delegates everything, including initialize
(bool ok, ) = library.delegatecall(msg.data);
require(ok);
}
receive() external payable {}
}
Foundry test demonstrating the takeover:
function test_ParityJulyPattern() public {
VulnerableLibrary lib = new VulnerableLibrary();
// Alice deploys her wallet, properly initialized as owner
address alice = makeAddr("alice");
VulnerableWallet wallet = new VulnerableWallet(address(lib), alice);
vm.deal(address(wallet), 10 ether);
// Attacker re-initializes by sending to wallet's fallback
address attacker = makeAddr("attacker");
vm.prank(attacker);
(bool ok, ) = address(wallet).call(
abi.encodeWithSignature("initialize(address)", attacker)
);
require(ok);
// Attacker is now the owner and can drain
vm.prank(attacker);
(bool ok2, ) = address(wallet).call(
abi.encodeWithSignature("execute(address,uint256)", attacker, 10 ether)
);
require(ok2);
assertEq(attacker.balance, 10 ether);
assertEq(address(wallet).balance, 0);
}
The fix — adding an initializer modifier with the Initializable pattern from OpenZeppelin — would have prevented both incidents:
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
contract SafeLibrary is Initializable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // prevents the November bug
}
function initialize(address _owner) external initializer { // prevents the July bug
owner = _owner;
}
}
Two lines (the constructor + the initializer modifier) close both vulnerabilities. The cost of writing these lines in 2017 would have been one hour of developer time. The cost of not writing them was $310M and the permanent freezing of hundreds of multi-sig wallets.
Cross-References
- Access control failures — Section 3.8.4 covers the unprotected-initializer pattern as a standalone vulnerability class
- Storage & delegatecall — Section 3.8.9 covers the delegatecall + selfdestruct interaction that made the November incident catastrophic
- Constructor vs initializer — Section 3.8.1 covers the language-level distinction that motivated the Initializable pattern
- Access control patterns — Section 3.7.3 covers modern access control including initializers
- Upgradeability — Section 3.5 covers proxy patterns (Transparent, UUPS, Diamond) and the lessons that emerged from Parity
- Related industry pattern — Section 3.10.7 (Wormhole) covers a different bridge exploit; the same broader pattern of "trust-without-verification on infrastructure contracts" recurred in multiple incidents 2017-2023 even though the specific bug class differed
- Hard fork precedent — Section 3.10.1 covers The DAO's hard fork and its non-applicability to subsequent incidents