Skip to content

[analysis] Canonical binding bypass in ProofBundle::verify — PoC#2

Open
saroupille wants to merge 14 commits into
mainfrom
analysis/verifier-canonical-binding
Open

[analysis] Canonical binding bypass in ProofBundle::verify — PoC#2
saroupille wants to merge 14 commits into
mainfrom
analysis/verifier-canonical-binding

Conversation

@saroupille
Copy link
Copy Markdown
Owner

@saroupille saroupille commented Apr 22, 2026

TL;DR

Impact — a party that can post a single external message to the rollup
inbox (any L1 account holder, no signing authority required) can mint
arbitrary shielded balance inside the pool via a forged Unshield with an
attacker-chosen v_pub. The L1 consequence is draining the bridge of all
other users' deposits — capped at the bridge's current balance, so it
rugpulls every depositor without the attacker needing to deposit themselves.
Demonstrated end-to-end: 400 000 mutez minted into the pool → kernel accepts
→ Tezos outbox burn emitted to the bridge.

Root causetzel_verifier::ProofBundle::verify() pins no canonical
circuit. Every field that identifies the circuit (preprocessed_root, FRI
geometry, column counts, output addresses) is rebuilt from wire-supplied
verify_meta. An attacker using the public stwo-circuits APIs supplies
their own circuit and their own proof that verifies against it.

Reproduce (≤10 minutes, no L1 sandbox required):

git checkout analysis/verifier-canonical-binding
cargo +nightly-2025-07-14 test -p tzel-rollup-kernel \
    --test e2e_verifier_binding_bypass -- --nocapture

One test passes, printing the forged Unshield, the credited attacker
balance, and the L1 outbox burn.

Full write-up — vulnerability walk-through, 10-step attack chain,
mitigation options (4 options, trade-offs), code-line citations, and an
agent-parseable appendix: docs/analysis/verifier-canonical-binding.md.

What this branch ships

No production code modified. All changes are test-only + disclosure docs.

Path Role Lines
docs/analysis/verifier-canonical-binding.md Disclosure write-up 664
verifier/tests/canonical_binding_bypass.rs Bundle-level PoC, 2 tests ~570
tezos/rollup-kernel/tests/e2e_verifier_binding_bypass.rs End-to-end PoC, 1 test ~970
verifier/Cargo.toml, tezos/rollup-kernel/Cargo.toml, Cargo.lock Dev-dep additions only

Non-regression: all 12 pre-existing tests in
tezos/rollup-kernel/tests/bridge_flow.rs still pass.

Scope

Only Unshield is wired end-to-end. Shield and Transfer share the
primitive break — the same forged VerifyMeta accepts any CircuitConfig
— but running them would add no evidence. One broken acceptance predicate
is a single bug.

Open question for the maintainer

The disclosure doc proposes four mitigation paths. The reviewer's
preference is Option 2 (drop circuit-describing fields from
VerifyMeta; construct CircuitConfig from a pinned canonical source)
because it is a structural invariant rather than a caller contract.

Which direction would you like taken? And would you prefer we hold off
on further work in this area (Shield/Transfer e2e PoCs, broader audit of
other wire fields taken at face value) until a fix lands?

Prior disclosures

Same author, earlier branch on this fork:
analysis/withdraw-auth-gap-poc
— absence of sender authentication on KernelInboxMessage::Withdraw.
Accepted and fixed in 24h.

Together these are the second canonical-trust issue we have found in the
verification chain. A broader sweep asking "which wire field is taken at
face value where an on-chain or build-time constant should pin it?"
may
be worth doing.

Arthur Breitman and others added 14 commits April 17, 2026 23:10
Add integration test verifier/tests/canonical_binding_bypass.rs that
constructs a ProofBundle outside services/reprover::custom_recursive_prove
and shows that tzel_verifier::ProofBundle::verify() still returns Ok(()).

ProofBundle::verify() derives its entire CircuitConfig (output_addresses,
n_blake_gates, preprocessed_column_ids, preprocessed_root) and ProofConfig
(FRI geometry, component column counts, ...) from self.verify_meta —
which is attacker-controllable. circuit_air::verify::verify_circuit is
generic and does not pin any canonical root or program hash.

An adversary therefore:
  1. Picks an arbitrary output_preimage.
  2. Computes the Blake2 hash via the bundle's own recipe.
  3. Builds a tiny circuit whose first two output gates emit those hash
     QM31s (fibonacci-style so the lookup accounting balances).
  4. Proves it with the public circuit_prover API.
  5. Packages the result into a ProofBundle with a self-consistent
     VerifyMeta, zstd-compresses the proof.

bundle.verify() returns Ok(()). The rollup kernel will then interpret
attacker-chosen output_preimage bytes as authenticated program output.

The test compiles and runs with
  cargo +nightly-2025-07-14 test -p tzel-verifier \
      --test canonical_binding_bypass --release

Does NOT modify any production code under verifier/src/, core/src/, or
tezos/rollup-kernel/src/ — only adds a test file and extends dev-deps in
verifier/Cargo.toml.
Add a second test, proof_bundle_verify_accepts_transfer_shaped_bundle,
that exercises the same bypass but with an output_preimage shaped to
parse through tzel_core::parse_single_task_output_preimage and the
apply_transfer tail-parsing loop (core/src/lib.rs:1854) for a
1-nullifier transfer.

The preimage uses:
  - canonical transfer program hash from the rollup kernel's real
    `verified_bridge_flow` fixture
    (tezos/rollup-kernel/testdata/verified_bridge_flow.json):
    bdc52ff0ce6470a4ab62796ee5e5a5b5b61376f0a0f046124b3db9294a494406
  - fixture auth_domain from the same file
  - a fresh random nullifier (dead_beef_dead_beef) the kernel has
    never seen
  - attacker-chosen cm_1/cm_2/cm_3 and memo hashes

task_output_size must be 12 (so 1 + 12 = 13 felts total) because
`parse_single_task_output_preimage` enforces
`output_preimage.len() == 1 + task_output_size`.

Both tests now pass. The proof the kernel receives is STARK-valid
and the bundle-level verify() accepts — downstream apply_transfer
will interpret the attacker's cm's and nullifier as legitimate.
- Rewrite module-level doc to explicitly name the bundle.rs:113 entry
  point, the circuit_air::verify::verify_circuit generic nature, and
  the attack chain's 5 concrete steps.
- Trim per-run diagnostics to the essentials: preimage_hash_target_qm31s,
  circuit shape summary, compressed proof size, verify() result.
- Rename Phase-numbered section comments to neutral "Test 1"/"Test 2".
- Rewrite the transfer-shaped test's doc to enumerate what bundle.verify
  specifically fails to check: no preimage-shape check, no canonical
  program hash pin, no canonical preprocessed-root pin, no
  output_preimage[2] inspection.
- Add running instructions to the module doc.

Both tests still pass cleanly:

    cargo +nightly-2025-07-14 test -p tzel-verifier \
        --test canonical_binding_bypass --release

    test proof_bundle_verify_accepts_bundle_from_attacker_pipeline ... ok
    test proof_bundle_verify_accepts_transfer_shaped_bundle ... ok
    test result: ok. 2 passed; 0 failed
Demonstrates real-world impact of the canonical-binding gap shown in
phases 2-4. The rollup kernel's actual `run_with_host` path — the same
code that would execute on a deployed tzel rollup — accepts an attacker
bundle produced outside `services/reprover::custom_recursive_prove` and
applies its Unshield side effects:

  - Fresh nullifier inserted into the kernel nullifier set.
  - Attacker-chosen L2 recipient credited with an attacker-chosen v_pub
    (the attacker controls the amount; apply_unshield does NOT bind v_pub
    to any deposit or prior note).
  - New commitment (cm_fee) appended to the note tree.
  - A follow-up Withdraw emits a Tezos outbox burn message — the forged
    funds are exiting to L1.

Artifact: `tezos/rollup-kernel/tests/e2e_verifier_binding_bypass.rs`.
Reuses the same attacker STARK pipeline as
`verifier/tests/canonical_binding_bypass.rs::proof_bundle_verify_accepts_
transfer_shaped_bundle`, specialized for the Unshield tail layout. The
`program_hash` used in the forged bundle's `output_preimage[2]` is the
canonical unshield hash copied verbatim from the real fixture at
`tezos/rollup-kernel/testdata/verified_bridge_flow.json`.

Run (debug, no env vars needed — matches bridge_flow CI style):

    cargo +nightly-2025-07-14 test -p tzel-rollup-kernel \
        --test e2e_verifier_binding_bypass -- --nocapture

Run (release, faster; requires the dev admin-key env vars since release
disables `cfg(debug_assertions)`):

    TZEL_ROLLUP_CONFIG_ADMIN_PUB_SEED_HEX=… \
    TZEL_ROLLUP_VERIFIER_CONFIG_ADMIN_LEAF_HEX=… \
    TZEL_ROLLUP_BRIDGE_CONFIG_ADMIN_LEAF_HEX=… \
      cargo +nightly-2025-07-14 test -p tzel-rollup-kernel \
        --test e2e_verifier_binding_bypass --release -- --nocapture

The values for those env vars (derived from the canonical
`hash(b"tzel-dev-rollup-config-admin")` admin ask) are documented in
the test file's module doc.

No production code is modified. Only adds the new integration test and
extends `tezos/rollup-kernel/Cargo.toml`'s `[dev-dependencies]` with the
stwo-circuits prover crates already pinned by `verifier/Cargo.toml`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses issues from internal review before disclosure PR:

- B1: reword module doc and inline comment around public_output_values
  cross-checking. Prior phrasing could be misread as "last 8 u32s are
  unchecked" — actually verify_circuit cross-checks all 16 against the
  attacker's own circuit output, which is itself attacker-chosen.
- B2: reword "Both tests print ..." — the assertion IS the bypass
  signal; prints are decorative and only visible with --nocapture.
- S1: drop dead `anyhow::Result` import from the e2e test; keep
  `IValue` (needed for QM31::blake trait resolution) and
  `CircuitSerialize` as trait-in-scope via `as _`.
- S3: shorten the arbitrary preimage from 13 felts (canonical transfer
  length) to 3 felts, so the shape-agnostic claim is unambiguous.
- S6: add a # Toolchain section to both test module docs stating
  nightly-2025-07-14 is required and why.

Verified end-to-end:
  - cargo +nightly-2025-07-14 test -p tzel-verifier
      --test canonical_binding_bypass --release     → 2 passed
  - cargo +nightly-2025-07-14 test -p tzel-rollup-kernel
      --test e2e_verifier_binding_bypass --release  → 1 passed (w/ env)
  - cargo +nightly-2025-07-14 test -p tzel-rollup-kernel
      --test bridge_flow                             → 12 passed (non-regression)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Responsible-disclosure write-up (664 lines) describing the gap in
ProofBundle::verify() and its downstream kernel impact.

Structure:
- TL;DR with one-command reproduction
- Vulnerability walkthrough with file:line citations into
  verifier/src/bundle.rs, verifier/src/lib.rs, core/src/lib.rs,
  tezos/rollup-kernel/src/lib.rs, stwo-circuits verify.rs + statement.rs
- Why the privacy bootloader doesn't close the gap (verification-side
  vs proving-side separation)
- 10-step attack chain with the felt<p gotcha footnote
- Impact (commit 8983481 shield-secret binding does not cover this)
- Reproduction commands for both PoCs with toolchain + env vars
- 4 proposed mitigations with tradeoffs and stated preference
- Appendix A: 24-row code citation table
- Appendix B: agent-parseable claim/verify-at/what-to-see list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant