[analysis] Canonical binding bypass in ProofBundle::verify — PoC#2
Open
saroupille wants to merge 14 commits into
Open
[analysis] Canonical binding bypass in ProofBundle::verify — PoC#2saroupille wants to merge 14 commits into
saroupille wants to merge 14 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Unshieldwith anattacker-chosen
v_pub. The L1 consequence is draining the bridge of allother 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
burnemitted to the bridge.Root cause —
tzel_verifier::ProofBundle::verify()pins no canonicalcircuit. Every field that identifies the circuit (
preprocessed_root, FRIgeometry, column counts, output addresses) is rebuilt from wire-supplied
verify_meta. An attacker using the publicstwo-circuitsAPIs suppliestheir 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 -- --nocaptureOne test passes, printing the forged
Unshield, the credited attackerbalance, 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.
docs/analysis/verifier-canonical-binding.mdverifier/tests/canonical_binding_bypass.rstezos/rollup-kernel/tests/e2e_verifier_binding_bypass.rsverifier/Cargo.toml,tezos/rollup-kernel/Cargo.toml,Cargo.lockNon-regression: all 12 pre-existing tests in
tezos/rollup-kernel/tests/bridge_flow.rsstill pass.Scope
Only
Unshieldis wired end-to-end.ShieldandTransfershare theprimitive break — the same forged
VerifyMetaaccepts anyCircuitConfig— 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; constructCircuitConfigfrom 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.