Reproduced Exploit
Parity Multisig First Hack (July 2017) — Unprotected `initWallet` Re-initialization
The Parity multisig wallet was a thin proxy (Wallet) that held the ETH and forwarded every unrecognized call, via delegatecall, to a single shared logic contract (WalletLibrary). The intent was that the proxy's constructor would call initWallet(...) once to set up owners and the daily limit.
Loss
82,189.93 ETH drained from a single victim wallet in the PoC
Chain
Ethereum
Category
Access Control
Date
Jul 2017
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2017-07-Parity_first_hack_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Parity_first_hack_exp.sol.
Vulnerability classes: vuln/access-control/missing-modifier · vuln/access-control/uninitialized-owner
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the main DeFiHackLabs repo contains several unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: WalletLibrary.sol, proxy wallet: Wallet.sol.
Key info#
| Loss (this wallet) | 82,189.93 ETH drained from a single victim wallet in the PoC |
| Loss (incident total) | |
| Vulnerable contract | WalletLibrary (shared delegatecall logic) — 0xa657491C1e7F16AdB39B9b60E87BbB8d93988bc3 |
| Victim contract | Wallet proxy — 0xBEc591De75b8699A3Ba52F073428822d0Bfc0D7e |
| Attacker EOA | 0xB3764761E297D6f121e79C32A65829Cd1dDb4D32 |
| Attacker contract | N/A (called directly from the EOA) |
| Attack tx | 0x9dbf0326a03a2a3719c27be4fa69aacc9857fd231a8d9dcaede4bb083def75ec (re-init) / 0xeef10fc5170f669b86c4cd0444882a96087221325f8bf2f55d6188633aa7be7c (drain) |
| Chain / block / date | Ethereum mainnet / fork block 4,043,799 / July 19, 2017 |
| Compiler | Solidity v0.4.9+commit.364da425, optimizer on, 200 runs |
| Bug class | Missing access control on an "initializer" — unprotected re-initialization → owner takeover |
TL;DR#
The Parity multisig wallet was a thin proxy (Wallet) that held the ETH and forwarded every
unrecognized call, via delegatecall, to a single shared logic contract (WalletLibrary). The
intent was that the proxy's constructor would call initWallet(...) once to set up owners and
the daily limit.
But initWallet — and the initMultiowned / initDaylimit functions it calls — were plain
public functions with no access control and no "already initialized" guard
(WalletLibrary.sol:216-219,
:107-117). Because the proxy's
fallback blindly delegatecalls any calldata into the library
(Wallet.sol:428-429), anyone could send
initWallet([attacker], 0, ...) to a fully-funded, already-live wallet. That call re-ran
initialization in the proxy's own storage, overwriting the real owners with the attacker and
setting m_required = 0.
With the attacker now the sole owner and a daily limit set to the entire balance, a single
execute(attacker, balance, "") call passed the onlyowner and underLimit checks and wired the
whole balance to the attacker. In the PoC the drained wallet held 82,189.93 ETH; the broader
July 2017 incident drained ~153,037 ETH total.
Background — Parity's proxy-library architecture#
To save deployment gas, each user's Parity multisig was a tiny Wallet proxy contract whose only
real logic was a fallback that delegated to one shared WalletLibrary deployed once on mainnet.
// Wallet.sol — the proxy
address constant _walletLibrary = 0xa657491c1e7f16adb39b9b60e87bbb8d93988bc3;
// gets called when no other function matches
function() payable {
if (msg.value > 0)
Deposit(msg.sender, msg.value);
else if (msg.data.length > 0)
_walletLibrary.delegatecall(msg.data); // ← forwards ARBITRARY calldata
}
Because the call is a delegatecall, the library's code executes against the proxy's storage.
The proxy and library declare the same leading storage layout
(m_required, m_numOwners, m_dailyLimit, m_spentToday, m_lastDay, uint[256] m_owners, …),
so any state-changing library function the attacker reaches edits the proxy's owner set and limits
directly.
The proxy was supposed to be configured exactly once, at deployment, by its constructor:
function Wallet(address[] _owners, uint _required, uint _daylimit) {
bytes4 sig = bytes4(sha3("initWallet(address[],uint256,uint256)"));
address target = _walletLibrary;
...
assembly { ... delegatecall(sub(gas, 10000), target, 0x0, add(argsize, 0x4), 0x0, 0x0) }
}
The fatal assumption was that initWallet would only ever be reached through this constructor.
Nothing in the library enforced that.
The vulnerable code#
1. initWallet is public and unguarded#
// constructor - just pass on the owner array to the multiowned and the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
There is no onlyowner, no onlymanyowners, and no if (m_numOwners > 0) throw;
re-init guard. It is a normal public function (the default visibility in Solidity 0.4.9). The
naming and comment call it a "constructor," but to the EVM it is simply a callable function.
2. The setup functions it calls clobber owner state#
function initMultiowned(address[] _owners, uint _required) {
m_numOwners = _owners.length + 1;
m_owners[1] = uint(msg.sender); // ← msg.sender becomes owner #1
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; // ← attacker chooses required = 0
}
function initDaylimit(uint _limit) {
m_dailyLimit = _limit; // ← attacker sets limit = whole balance
m_lastDay = today();
}
Re-calling these overwrites the legitimate owner list rather than appending to it, makes the
caller msg.sender an owner, and lets the caller pick m_required and m_dailyLimit.
3. execute then trusts the freshly-rewritten state#
function execute(address _to, uint _value, bytes _data) external onlyowner returns (bytes32 o_hash) {
if ((_data.length == 0 && underLimit(_value)) || m_required == 1) {
...
if (!_to.call.value(_value)(_data)) throw; // ← sends _value to _to
SingleTransact(msg.sender, _value, _to, _data, created);
} else { ... /* multisig path */ }
}
onlyowner now passes (attacker is owner #1). The first branch fires because _data.length == 0
and underLimit(_value) returns true (the daily limit was set to the full balance), so the wallet
performs an immediate single-transaction send of the entire balance to the attacker — no
co-signers required.
function underLimit(uint _value) internal onlyowner returns (bool) {
if (today() > m_lastDay) { m_spentToday = 0; m_lastDay = today(); }
if (m_spentToday + _value >= m_spentToday && m_spentToday + _value <= m_dailyLimit) {
m_spentToday += _value;
return true; // ← passes: limit == balance
}
return false;
}
Root cause — why it was possible#
The vulnerability is a textbook uninitialized/ re-initializable proxy: an initializer function that behaves like a constructor but is a plain public function with no guard, sitting behind a proxy that forwards arbitrary calldata to it.
Four design facts compose into total loss:
- No access control on
initWallet. It can be called by anyone, any number of times, at any point in the wallet's life. (WalletLibrary.sol:216) - No "already initialized" latch.
initMultiowneddoes not checkm_numOwners == 0before overwriting the owner table, so a second call replaces (not augments) the real owners. (WalletLibrary.sol:107-117) - The proxy forwards everything via
delegatecall.Wallet's fallback delegatecalls any non-empty calldata into the library, so the attacker'sinitWallet(...)runs against the funded proxy's storage. (Wallet.sol:428-429) - Owner-set, required-sigs, and daily-limit are all attacker-chosen during init. By passing
_required = 0and_daylimit = balance, the attacker simultaneously becomes the sole owner, removes any multisig requirement, and raises the spend limit to the whole balance — turning a 3-of-5 multisig into a 1-of-1 wallet they control.
The "constructor" comment in the source (WalletLibrary.sol:214, :200, :105) reflects the intended lifecycle, not the enforced one — the EVM has no concept of "this is a constructor, only callable once."
Preconditions#
- The victim is a live, funded Parity
Walletproxy pointing at the sharedWalletLibrary. - The proxy fallback forwards arbitrary calldata into the library via
delegatecall(always true for this wallet design). (Wallet.sol:428-429) initWalletcarries no re-init guard and no access control (always true in this library version). (WalletLibrary.sol:216)- No working capital, flash loan, market manipulation, or special timing required — the attacker simply sends two ordinary transactions.
Step-by-step attack walkthrough#
All figures are read directly from the -vvvvv trace in output.txt. The PoC reproduces
the attack against a single wallet holding 82,189.93 ETH (82,189,932,605,820,062,911,880 wei).
| # | Step | Call | Storage / balance effect |
|---|---|---|---|
| 0 | Baseline | VICTIM_WALLET.balance | 82,189.93 ETH; isOwner(attacker) == false; m_required = 3, m_numOwners = 5 (output.txt:18-23) |
| 1 | Re-initialize | initWallet([attacker], 0, 82189.93e18) (hits proxy fallback → delegatecall into library) | slot 0 m_required: 3 → 0; slot 1 m_numOwners: 5 → 2; slot 2 m_dailyLimit: 1.0 ETH → 82,189.93 ETH; owner slots 6/7 overwritten with attacker; m_ownerIndex[attacker] → 2 (output.txt:28-39) |
| 2 | Confirm takeover | isOwner(attacker) | now returns true (output.txt:40-45) |
| 3 | Drain | execute(attacker, 82189.93e18, "") | onlyowner ✓, underLimit ✓ → attacker.call.value(82189.93e18)(); emits SingleTransact(attacker, 82189.93e18, attacker, 0x, 0x0); m_spentToday set to balance (output.txt:46-54) |
| 4 | Settle | assertions | VICTIM_WALLET.balance == 0; attacker.balance increased by exactly 82,189.93 ETH (output.txt:57-60) |
The decoded storage diff from step 1 (raw slots in output.txt:30-37):
| Slot | Field | Before | After | Meaning |
|---|---|---|---|---|
0 | m_required | 3 | 0 | multisig threshold removed |
1 | m_numOwners | 5 | 2 | owner count reset to attacker + 1 |
2 | m_dailyLimit | 0x…0de0b6b3a7640000 = 1.0 ETH | 0x…11678671d4114063cd88 = 82,189.93 ETH | spend cap raised to entire balance |
6,7 | m_owners[...] | real owners | 0xb376…4d32 (attacker) | owner table overwritten |
0xe484…fa26 | m_ownerIndex[attacker] | 0 | 2 | attacker registered as owner |
4 | m_lastDay | 17315 | 17366 | today() refreshed by initDaylimit |
Profit / loss accounting#
| ETH | |
|---|---|
| Victim wallet balance before | 82,189.93 |
| Victim wallet balance after | 0.00 |
| Attacker balance gained | +82,189.93 |
| Attacker capital risked | 0 (only gas) |
The attacker walks off with 100% of the wallet's balance for the cost of two transactions. In the real-world July 2017 campaign this same flaw was used against multiple wallets for an aggregate of ~153,037 ETH.
Diagrams#
Sequence of the attack#
Why re-initialization is takeover#
Ownership state before vs. after initWallet#
Remediation#
- Guard the initializer against re-entry. Add an explicit one-time latch, e.g. the
pre-OpenZeppelin pattern
if (m_numOwners > 0) throw;at the top ofinitMultiowned(or aninitializedboolean /initializermodifier in modern code). Re-running setup on a live wallet must be impossible. - Restrict who may initialize. Even with a latch, initialization should be callable only by a trusted deployer/factory, not via the proxy's arbitrary-calldata fallback. Treat any "constructor look-alike" function as security-critical.
- Do not let the proxy forward arbitrary calldata to setup functions. The fallback should
forward only the operational method selectors (execute/confirm/owner management), never the
init*family. Whitelist forwardable selectors instead of delegating everything. - Separate the library's own initialization from per-proxy initialization. (Parity's second
2017 hack — the November "suicide" — was the flip side of this: the shared library itself was
left uninitialized and someone took it over and
kill()ed it. Both stem from treating public functions as constructors.) - Use a battle-tested proxy/initializer framework. Modern equivalents (OpenZeppelin
Initializable/initializer, transparent/UUPS proxies with_disableInitializers()in the implementation's constructor) exist precisely to make this entire bug class unreachable.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has several
unrelated PoCs that fail to compile under forge test's whole-project build):
cd 2017-07-Parity_first_hack_exp
ETH_RPC_URL=<ethereum-archive-rpc> forge test --mt testExploit -vvvvv
- RPC: an Ethereum archive endpoint is required — the fork pins block 4,043,799 (July 2017),
so the node must serve historical state at that block. Most pruned public RPCs will fail with
missing trie node/header not found. - The test asserts the wallet starts with exactly
82,189,932,605,820,062,911,880wei, that the attacker is not initially an owner, then performsinitWallet+execute, and finally asserts the wallet balance is0and the attacker gained the full amount.
Expected tail (see output.txt:62-65):
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.79s (2.45s CPU time)
Ran 1 test suite ...: 1 tests passed, 0 failed, 0 skipped (1 total tests)
References: OpenZeppelin post-mortem — https://www.openzeppelin.com/news/on-the-parity-wallet-multisig-hack-405a8c12e8f7 · Haseeb Qureshi, "A hacker stole $31M of Ether" — https://haseebq.com/a-hacker-stole-31m-of-ether/
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2017-07-Parity_first_hack_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Parity_first_hack_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Parity Multisig First Hack (July 2017)".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.