For the complete documentation index, see llms.txt. This page is also available as Markdown.

89762 bc medium owner cancelling a reversible transfer mints an unbacked zk tree leaf permanent permissionless state growth

Submitted on Aug 21st 2026 at 01:42:17 UTC by @jokerbuena for Audit Comp | Quantus

  • Report ID: #89762

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/immunefi-team/audit-comp-quantus-chain

  • Impacts:

    • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Description

Summary

Any ordinary signed account can mint unbacked leaves into the wormhole ZK tree, permanently and repeatably, by scheduling a reversible transfer and immediately cancelling it. No value moves, no balance changes, no privileged actor is involved — yet the tree grows by one leaf per cancel and leaves are never removed.

Measured on a fresh clone of the in-scope asset at the audited commit: 15 cancels produced 15 leaves of face value 1000000000000 each — 15000000000000 of nominal ZK exit capacity — while the attacker's free balance, total balance and the chain's total issuance were byte-identical before and after.

Scope

The defect is in Quantus's own code:

  • runtime/src/transaction_extensions.rs — WormholeProofRecorderExtension (Quantus extension)

  • pallets/reversible-transfers/src/lib.rs — Quantus pallet

  • pallets/wormhole/src/lib.rs — Quantus pallet

All three sit inside the listed asset "The Substrate L1 node — runtime, FRAME pallets, QPoW consensus, quantus-node".

Upstream fungible::MutateHold::transfer_on_hold and pallet_balances::done_transfer_on_hold appear in the causal chain as context only: they behave as designed and emit a truthful event. What is not designed is Quantus's recorder treating that event as a credit and minting a ZK leaf from it. This report asks for no change to unmodified upstream code.

No privileged actor. Both extrinsics (schedule_transfer_with_delay, cancel) are callable by any ordinary signed account. The Technical Collective plays no part.

Root cause

cancel routes to release_held_funds_with_fee. Both of its branches force the destination to a signing account — pallets/reversible-transfers/src/lib.rs:876 (ensure!(who == &pending.guardian) → pending.guardian) and :879 (ensure!(who == &pending.from) → pending.from):

For a one-time schedule pending.guardian == pending.from, so the owner branch sets recipient = pending.from and :943 then calls:

That is a hold-to-free conversion on a single account. No credit occurs. But the balances pallet emits TransferOnHold { source: X, dest: X, amount }, and Quantus's WormholeProofRecorderExtension post-dispatch event scan treats that arm as a credit and records a wormhole leaf for X. The recorder already guards the neighbouring ReserveRepatriated arm for exactly this reason ("self-repatriations return early"); the TransferOnHold arm has no such guard.

The runtime's own weighting confirms it expects the insert: WormholeProofRecorderExtension::<Runtime>::count_transfers(&cancel_call) == 1.

Impact

Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol) — the in-scope Medium impact, matched word for word.

  1. A core invariant is permanently false. Leaves are meant to be 1:1 with real credits. After this sequence they are not, and leaves are never removed.

  2. Permissionless, unbounded, permanent state growth. Two extrinsics per leaf, repeatable at ordinary fee cost, by any signed account, with no delay wait.

  3. No extractable value, and that is stated deliberately. The leaf's to is a signing account; the exit path (Wormhole::verify_private_batch / verify_public_batch) is gated by ZK verification in ValidateUnsigned::pre_dispatch, and the circuit binds leaf.to == WA(secret). A signing account is not a Poseidon wormhole address, so no proof can be produced against these leaves. That is precisely why the Griefing line — the entry that does not require extractable value — is the one claimed here.

Latent escalation, named not claimed: if any future recipient of release_held_funds_with_fee can be a Poseidon address, these leaves become spendable and the class changes to unauthorised minting. Not currently reachable — both cancel branches force a signing account.

Recommendation

Add a (&source != &dest) guard on the TransferOnHold arm of WormholeProofRecorderExtension's event scan, mirroring the guard the recorder already applies to ReserveRepatriated. One line, in Quantus's own code.

A source == dest early return in upstream transfer_on_hold would also stop the symptom, but that is unmodified upstream Substrate and out of scope for this program; it is named only to show the guard is absent on both sides.

Not a duplicate

The three audits shipped inside the in-scope repository (audits/EigerWormholeAudit.pdf, audits/quantus-substrate-audit-20260513.pdf, audits/Proof-of-Work and Poseidon Security Review 1.1.pdf) do not cover this path. Text-extracted and searched: transfer_on_hold 0, TransferOnHold 0, unbacked 0, leaf_count 0 — against positive controls wormhole 207, Quantus 205, pallet 389, so the search discriminates.

The single cancel_transfer occurrence in the substrate audit is the opposite defect: a scheduler split-counter bug where cancel_named lookup fails and cancellation fails with CancellationFailed, leaving funds held. Here cancellation succeeds and mints a leaf. Different root cause, opposite symptom, different fix.

Distinct from my other reports: not QN-92 (execute_transfer → transfer_keep_alive(self) no-op → explicit record_transfer_proof; different call, different recorder path, and no scheduler delay here), not QN-60 (fee 10 vs 4), not QN-61 (min-exit 10 vs 1), not QN-70 (miner fee credit without a leaf — the opposite direction).

https://gist.github.com/JokerBUENA/b5650909ce1c3b7dccf067d21b150c43

Proof of Concept

PoC

1

Clone the in-scope asset at the audited commit

2

Append the attached PoC

Append QN-93-runtime-self-cancel.rs into the existing test module of runtime/src/transaction_extensions.rs, immediately before fn run_lifecycle_with_result(. No production code is modified.

3

Run the test

Re-run on a fresh clone on 2026-08-21 (cargo 1.93.0), git status showing only the test file touched — the audited source is untouched:

The first printed line is the discriminating control: a plain transfer_keep_alive(self) driven through the same run_lifecycle records zero leaves. The 15 leaves therefore come from the cancel path, not from the harness.

Verbatim output (2026-08-17, 3b243b8, then reverted)

PoC source — QN-93-runtime-self-cancel.rs

Append into the test module named above.

Was this helpful?