@0xCRASHOUT Actually, the project exposes a SDK in the npm that lets users to generate proof through the function generateZkProof(). I am also curious how the attacker discovered the target code.
Another under-constrained bug maybe....
Attack pattern:
1. Attacker deposited 25,000 USDC to get one note.
2. Attacker called function transact() to transfer the note multiple time, then it got multiple notes.
3. Attacker called function transact() to withdraw USDC with the notes.
Ref:
- https://t.co/l0LdhynMAW
- https://t.co/CntyXeMpdg
Another under-constrained bug maybe....
Attack pattern:
1. Attacker deposited 25,000 USDC to get one note.
2. Attacker called function transact() to transfer the note multiple time, then it got multiple notes.
3. Attacker called function transact() to withdraw USDC with the notes.
Ref:
- https://t.co/l0LdhynMAW
- https://t.co/CntyXeMpdg
Correct: this is not the same bug as the previous one, though both are circuit public input binding issues and the execution trace is similar.
This follow-up exploit hit escapeHatch on a different deployment — the "Private Rollup Bridge" contract (0x7379), and targets a binding gap in the escape hatch circuit.
Root cause: old_data_root is turned into two independent witnesses — one passed into the join-split circuit for private note membership verification (line 33), another exposed as the public input that Solidity validates against L1 state (line 50/88). No equality constraint connects them.
This allows the attacker to construct a fake Merkle tree containing self-owned notes of arbitrary value, prove membership against that fake root, while publishing the real on-chain root as the public input. The Solidity require(oldDataRoot == dataRoot) check passes, the join-split signature passes (attacker owns the fake notes), and the settlement executes the withdrawal.
The escape hatch circuit was later entirely removed from the codebase (aztec-connect PR #402), but the deployed verifier contract still contains the EscapeHatchVk, allowing proofs generated with the vulnerable circuit to pass on-chain verification.
We also note that the same circuit has a similar unbinding issue with input_owner / output_owner witnesses (line 38-39 vs line 111-112), though this was not required for the current attack.
aztec-connect PR #402:
https://t.co/BtNujnw1mq
🚨 A legacy Aztec PrivateRollupBridge / RollupProcessor on Ethereum was drained for `1158 ETH` through `escapeHatch(bytes,bytes,bytes)`.
This is not the same bug as the June 14 Aztec Connect incident https://t.co/0tHoEdcpr5, although both sit in the old Aztec rollup/bridge stack.
Tx: https://t.co/cP0HPrSe4t
The attacked L1 contract was `0x737901bea3eeb88459df9ef1be8ff3ae1b42a2ba`.
The attacker called `escapeHatch(...)` while the escape hatch window was open. The public inputs exposed to L1 used `rollupId = 4487`, `rollupSize = 0`, `numTxs = 1`, `publicInput = 0`, `publicOutput = 1158 ETH`, `assetId = 0`, and `outputOwner = 0x6952d9246e9afe8b887b2877225163436f78e97f`.
After the verifier accepted the proof, the L1 RollupProcessor advanced state and executed `withdraw(...)`, sending `1158 ETH` to the attacker-controlled EOA.
🔎 Root cause: this is an L1/ZK boundary bug.
The Solidity contract only sees public inputs and a verifier result. It checks that the public `old_data_root` equals its current on-chain `dataRoot`, then applies the public output as an L1 withdrawal.
But the escape-hatch circuit has a missing equality constraint.
In `escape_hatch_circuit.cpp`, `tx.js_tx.old_data_root` is turned into two independent witnesses. One witness is passed into the inner join-split circuit, where private note membership is checked. A separate witness is later exposed as the public `old_data_root` that Solidity validates against L1 state.
Those two witness values are never constrained to be equal.
This allows a malicious prover to use a fake internal data tree for the private note membership proof, while publishing the real on-chain `old_data_root` as the L1 public input. The verifier accepts the proof, L1 root checks pass, and the RollupProcessor executes the `1158 ETH` withdrawal.
That is the core failure: the root checked privately by the ZK circuit and the root checked publicly by the L1 contract can diverge.
🔗 Difference from the June 14 Aztec Connect exploit:
The June 14 incident targeted Aztec Connect proxy `0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455` through `processRollup(...)`.
Its bug was a `numRealTxs` / committed-inner-rollup mismatch: the proof path committed to more rollup data than the L1 deposit/withdrawal accounting loop processed.
This new incident targets legacy RollupProcessor `0x737901bea3eeb88459df9ef1be8ff3ae1b42a2ba` through `escapeHatch(...)`.
Its bug is circuit-level witness binding: the internal data root used for private note membership is not forced to equal the public data root accepted by L1.
Same broad class of old rollup processor proof/accounting assumptions. Different contract, different entrypoint, different root cause.
🛡️ Takeaway: ZK circuits must explicitly bind private witnesses to the public inputs that drive L1 side effects.
If a root, balance, owner, nullifier, or commitment is used on both sides of the L1/ZK boundary, “same source field in witness generation” is not enough. The circuit must enforce equality.
Powered by #DarkNavy Web3 AI Agent
We are investigating a potential exploit affecting a deprecated Aztec payments product from 2021. ~$2m was transferred from the immutable smart contract in transaction:
https://t.co/7l4OcKQukc
The deprecated product is an immutable stage 2 rollup that was sunset in 2022.
Aztec Labs holds no admin keys or control over the system; it cannot be paused or upgraded by us.
This is a separate incident from the exploit on the depreciated Aztec Connect product that occurred on June 14, 2026.
We will share further updates in due course.