> For the complete documentation index, see [llms.txt](https://docs.zkbob.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.zkbob.com/zkbob-overview/vulnerability-post-mortem.md).

# Vulnerability Post Mortem

## Summary

On 06/05/26 the internal zkBob security team discovered a critical double-spend vulnerability in one of the ZK circuits used by the protocol.

There is no public evidence that the vulnerability was ever exploited. However, we cannot rule out the possibility entirely.

As a result, all zkBob pools are being deprecated and placed into withdraw-only mode. If you hold funds in any active zkBob account, go to <https://app.zkbob.com/> and withdraw them now.

## Impact

All live zkBob pools were affected and required immediate action:

* Polygon PoS USDC pool
* Optimism USDC pool
* Optimism ETH pool

## Timeline

* 2026/06/05 — Potential circuit logic flaw identified during a routine security review
* 2026/06/05 — All zkBob pools paused as a precaution, pending further investigation
* 2026/06/06 — Vulnerability confirmed as critical
* 2026/06/07 — Fix planning started
* 2026/06/11 — First public statement acknowledging the vulnerability released
* 2026/06/15 — Circuit fix applied; new version of the protocol circuits released
* 2026/06/18 — Updated UI/SDK packages released
* 2026/06/22 — Updated circuit parameters released following an internal Groth16 ceremony
* 2026/06/23 — Updated smart contract supporting withdraw-only mode released and deployed
* 2026/07/02 — All updated components (libraries, SDK, UI, smart contracts) passed internal review
* 2026/07/03 — Smart contract upgrade scheduled through the governance multisig via a timelock
* 2026/07/05 — Smart contract upgrade executed
* 2026/07/06 — Updated UI released; zkBob pools restarted in withdraw-only mode; public restart announcement released

## Root cause

zkBob uses a hybrid model that combines UTXO and account-balance accounting. One of its ZK circuits is designed to prove that only valid account state transitions are accepted, ensuring that every inbound UTXO note can be spent only once and that the sum of all consumed UTXO notes matches the change in account balance during a state update.

For existing accounts, the circuit worked as intended. However, an edge-case vulnerability was identified for accounts being created for the first time while already holding unspent incoming UTXO notes.

Because such an account is being created for the first time, some state-transition rules were relaxed. In particular, since no prior account snapshot existed in the global zkBob Merkle tree, the circuit did not validate the Merkle proof attesting to the correctness of the prior account state. An empty account with zero values was used as a stub, and the zkBob SDK filled the Merkle proof with zeros. This Merkle proof data is also used as an input when computing `input_pos_index` and `nullifier`, where the `nullifier` ensures that all older account states are invalidated during each state transition.

However, because the circuit would accept any arbitrary data as the Merkle proof for a just-initialized account, an attacker could control `input_pos_index` and `nullifier` by crafting malicious Merkle proof data. As a result, an attacker could initialize multiple accounts that double-spend the same inbound UTXO notes: each circuit run would produce a different `nullifier` and therefore fail to correctly invalidate the previously used zero-account states.

Vulnerable circuit: <https://github.com/zkBob/libzeropool-zkbob/blob/903369517b3f8133cd398787478ba5bf46cca2c2/src/circuit/tx.rs#L187-L195>

```rust
//assuming input_pos_index <= current_index
let ref input_pos_index = c_from_bits_le(s.in_proof.0.path.as_slice());

//check nullifier
(&p.nullifier - c_nullfifier(&in_account_hash, &eta, input_pos_index, params)).assert_zero();

let cur_root = c_poseidon_merkle_proof_root(&in_account_hash, &s.in_proof.0, params.compress());
//assert root == cur_root || account.is_dummy()
//all uninitialized empty accounts considered to be in the privacy set
(cur_root.is_eq(&p.root) | s.tx.input.0.is_initial(&poolid)).assert_const(&true);
```

## Fix

An additional constraint was introduced to enforce that only one valid `nullifier` and account state can exist for a not-yet-initialized account, preventing any new double-spend transactions in the future.

```diff
//assuming input_pos_index <= current_index
let ref input_pos_index = c_from_bits_le(s.in_proof.0.path.as_slice());

//check nullifier
(&p.nullifier - c_nullfifier(&in_account_hash, &eta, input_pos_index, params)).assert_zero();

let cur_root = c_poseidon_merkle_proof_root(&in_account_hash, &s.in_proof.0, params.compress());
//assert root == cur_root || account.is_dummy()
//all uninitialized empty accounts considered to be in the privacy set
(cur_root.is_eq(&p.root) | s.tx.input.0.is_initial(&poolid)).assert_const(&true);
+(input_pos_index.clone() * s.tx.input.0.is_initial(&poolid).as_num()).assert_zero();
```

**Due to the nature of the zkBob protocol, it is technically impossible to reliably prove whether the bug was ever exploited on any of the publicly deployed pools.** This is a common limitation of ZK-based privacy solutions. We have no reason to believe it was exploited: no public evidence or indication was found suggesting otherwise, and no suspicious token balances or withdrawal activity were identified during our internal retroactive review.

Nevertheless, we cannot rule out the possibility entirely. We have therefore decided to permanently deprecate the existing zkBob pools, disable all deposits, and place the pools into withdraw-only mode. As a precaution, all withdrawals will pass through a manually reviewed queue; we expect all submitted withdrawals to be processed smoothly and redeemed at a 1:1 rate on a regular schedule.

## User action

If you hold any remaining funds in a zkBob account in any pool, go to <https://app.zkbob.com/> and start a withdrawal submission.

**Please note that submitted withdrawals will not be processed immediately.** All withdrawal claims are recorded on-chain and processed manually within 1–2 weeks, after which funds are automatically credited to your wallet address. Withdrawals are processed in FIFO order in batches, unless suspicious activity or a potential solvency issue is identified and requires further action from the team.

**Deposits and transfers in the existing pools will remain disabled.**
