Parity Multisig Self-Destruct (2017)
Smart contract security audit by TierZero · 2023-01-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit. This report analyzes a publicly documented historical incident for educational purposes; TierZero was not involved in the original code or its review.
What the protocol did
Parity Technologies shipped a multisignature wallet for Ethereum that split logic across two contracts to save deployment gas. A single WalletLibrary contract held all the actual code — ownership management, transaction execution, the initWallet constructor logic. Individual user wallets were thin proxies that held funds and delegatecall'd into the shared library for every operation. This was a common 2016–2017 pattern before EIP-1167 minimal proxies and OpenZeppelin's upgradeable-contracts patterns standardized it. By mid-2017 several hundred wallets — including funds belonging to Web3 Foundation, Polkadot's ICO proceeds, and Parity's own team — pointed at one library instance at a fixed address.
This is the second Parity multisig incident. A separate bug in July 2017 let an attacker directly drain ~$30M from three wallets via an unprotected initWallet call. The one covered here, from November 2017, didn't steal a single wei — it permanently froze roughly 513,000 ETH by destroying the shared library itself.
The vulnerability
The root cause is two compounding design errors: (1) the library contract was deployed as an ordinary contract, not disabled or initialized on deployment, and (2) it exposed owner-gated functions, including one that called selfdestruct, without distinguishing "I am the library" from "I am a proxy borrowing your code."
// WalletLibrary.sol (simplified to the load-bearing parts)
contract WalletLibrary {
address[] public m_owners;
modifier onlyowner {
require(isOwner(msg.sender));
_;
}
// Meant to run once, at wallet-creation time, via delegatecall
// from a proxy. Nothing stops it being called directly on the
// library address itself, where m_owners is still empty.
function initWallet(address[] _owners, uint _required) {
require(m_owners.length == 0);
m_owners = _owners;
}
function kill(address _to) onlyowner {
selfdestruct(_to);
}
}
Because the library was never initialized as itself, m_owners on the library's own storage was empty. initWallet's only guard was "owners array is currently empty" — not "this call is a legitimate delegatecall from a fresh proxy." Anyone could call initWallet directly on the library address and become its sole owner, in the same sense that any uninitialized upgradeable-proxy implementation contract is fair game if it isn't locked down independently of the proxies that use it — a class of bug we still see in modern upgradeable Solidity systems and cover from the proxy-implementation angle in our piece on how Solana's own BPF loader and program-upgrade authority create equivalent centralization and takeover surface.
How the exploit worked
- On 6 November 2017, a user (publicly known by the GitHub handle devops199) interacting with the library contract called
initWallet, passing themselves as the sole owner. The call succeeded because the library's own owner list was empty — nothing marked the library as "not a wallet, do not initialize." - As the new recorded owner of the library, the same address called
kill, which passed theonlyownercheck. killexecutedselfdestructdirectly against the library contract's own address and storage — this call was a plain external call, not a delegatecall through a proxy, so the destruction hit the shared library itself rather than any single wallet.- Every proxy wallet still pointed its
delegatecallat that now-empty address. With no code left to execute, every function on every one of those wallets began reverting. The ETH held by each proxy remained in the proxy's own balance — provably present, permanently unreachable, since the logic needed to move it no longer existed anywhere. - Roughly 587 wallets were affected; estimates at the time put frozen value near 513,774 ETH (worth roughly $150M then). No hard fork followed, unlike the 2016 DAO response, and the funds are still locked years later.
How an audit catches this
This is exactly the class of finding our smart contract audit reviews are built to surface before deployment, not after a headline. Concretely, we check for:
- Delegatecall trust boundaries. Any contract designed to be
delegatecall-executed by others gets audited as if it could also be called directly, withaddress(this)equal to the library's own address. We ask: what happens if every "internal" function is invoked with the library as the executing context? - Initialization as an access-controlled event, not a state check.
require(owners.length == 0)is not equivalent torequire(msg.sender == deployer)or a one-time initializer flag set in the constructor. We flag any pattern where "uninitialized" and "anyone may configure me" are the same condition. - Self-destruct and other irreversible admin functions get isolated review. Any reachable
selfdestruct,delegatecallto arbitrary targets, or storage-clearing function is traced back to every possible caller context, including the implementation/library contract's own address, not just the intended proxy. - Constructor-vs-initializer semantics in proxy patterns. We test that implementation contracts are either non-upgradeable, immediately self-initialized and locked at deploy time, or explicitly guarded so
initialize()cannot be called on the implementation itself — the same class of check we apply when reviewing upgrade-authority handling more broadly, discussed further in our code review process for access-control-heavy codebases.
Remediation
The fix Parity and the wider ecosystem converged on has three parts. First, implementation/library contracts must be initialized (or explicitly disabled) at deployment — OpenZeppelin's later Initializable pattern locks the implementation's own initializer with a constructor-set flag so it can never be called on that address again. Second, privileged destructive functions (selfdestruct, arbitrary delegatecall, upgrade-authority changes) need access control that is invariant-checked, not just state-checked — ownership should be set once, atomically, at construction, never derived from "is this variable still at its zero value." Third, shared-logic architectures should default to well-audited proxy standards (transparent, UUPS, beacon) rather than hand-rolled delegatecall wiring, precisely because the failure mode here — one contract's destruction bricking hundreds of independent accounts — is a systemic risk unique to shared mutable logic. The same "one broken permission model breaks every dependent account" pattern shows up whenever approval or session-key logic is over-trusted, which is worth keeping in mind alongside gasless-approval mechanisms like Permit2 on EVM chains.
If your protocol uses proxies, shared libraries, or delegatecall anywhere in its architecture, get it in front of a smart contract audit before mainnet, not after.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit