Reproduced Exploit

BLS Wallet — one signature pays twice because the nonce moves too late

A wallet operation is authorized by a signature over (nonce, value). The nonce is checked, the call sends ether, and only then is the nonce incremented. The recipient is a contract. Its receive calls process again with the same signature before that increment.

Jul 2022•Other•reentrancy•4 min read

Chain

Other

Category

reentrancy

Date

Jul 2022

Source

AuditVault

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. Reproduction of a public audit finding curated by AuditVault — the original finding: 19279-operations-are-vulnerable-to-signature-replay-via-reentrancy. Standalone Foundry PoC and full write-up: 19279-operations-are-vulnerable-to-signature-replay-via-reentrancy_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/reentrancy/single-function · vuln/auth/signature-replay

Key info#

ProtocolBLS Wallet
FindingSigma Prime, AuditVault 19279
Vulnerable codeprocess increments nonce after the value call
HarmOne signature that authorizes 1 ether pays 2 ether
Chainlocal synthetic (precomputed ECDSA signature)

TL;DR#

A wallet operation is authorized by a signature over (nonce, value). The nonce is checked, the call sends ether, and only then is the nonce incremented. The recipient is a contract. Its receive calls process again with the same signature before that increment.

The wallet is funded with 2 ether. The signature authorizes 1 ether at nonce 0. The nested call still sees nonce 0, so it pays another 1 ether. The sink holds 2 ether and the nonce ends at 2.

The control increments the nonce before the call. The nested process fails the signature check. The sink holds 1 ether and 1 ether stays in the wallet.

The vulnerable code#

The signature is checked against the current nonce. The payment happens next. The nonce write is last.

SOLIDITY
require(recover(value, nonce, v, r, s) == OWNER, "sig");
(bool ok,) = to.call{value: value}("");
require(ok, "call");
// @> VULN: the nonce is incremented after the call, so a reentering process still sees the signed nonce.
uint256 bumped = nonce + 1;
nonce = bumped;

The sink replays once:

SOLIDITY
if (!replayed) {
    replayed = true;
    try gateway.process(to, value, v, r, s) {} catch {}
}

Root cause#

The nonce is the replay lock, and it is updated after an untrusted call. A recipient that reenters observes the nonce the signature was made for. The check passes again. Moving the increment above the call invalidates the signature for every nested process.

Preconditions#

The signer key is known to this reproduction and the signature was produced offline with cast wallet sign over keccak256(abi.encode(nonce, value)), then the Ethereum signed-message prefix. The wallet holds 2 ether. The operation sends 1 ether to a contract that reenters exactly once. No aggregator or mempool is required.

Attack walkthrough#

  1. The exploit funds the gateway with 2 ether and arms the sink with the precomputed signature for nonce 0 and 1 ether.
  2. ecrecover of that signature is the owner.
  3. process checks the signature at nonce 0 and sends 1 ether to the sink.
  4. receive sets replayed and calls process again with the same signature.
  5. The inner call still sees nonce 0, so it sends another 1 ether.
  6. Both calls then increment the nonce. It ends at 2. The sink's balance rose by 2 ether.
  7. On the fixed gateway the nonce becomes 1 before the call. The inner signature check fails. The sink receives 1 ether.

Diagrams#

flowchart TD A["Owner signature covers nonce 0 and 1 ether"] --> B["process checks the signature and sends 1 ether"] B --> C["Sink receive calls process again with the same signature"] C --> D["VULN: nonce is still 0 so the second check passes"] D --> E["A second 1 ether is sent"] E --> F["Nonce is incremented twice and the sink holds 2 ether"]

Impact#

A signature that authorizes a 1 ether payment pays 2 ether. The wallet that was funded with 2 ether is left empty. Repeating the reentry would keep paying until the wallet balance is below the operation value.

Remediation#

Increment the nonce after the signature check and before the external call. Nested process calls then see the next nonce and the original signature no longer verifies.

How to reproduce#

BASH
cd 19279-operations-are-vulnerable-to-signature-replay-via-reentrancy_exp
forge test -vv

test_one_signature_pays_twice asserts the sink receives 2 ether. test_control_nonce_before_call_pays_once asserts the nested call is rejected and the sink receives 1 ether.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • 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.