Skip to content

Kernel auth gap on Withdraw — PoC adapted for current master#6

Closed
saroupille wants to merge 1 commit into
trilitech:mainfrom
saroupille:analysis/withdraw-auth-gap-poc-v2
Closed

Kernel auth gap on Withdraw — PoC adapted for current master#6
saroupille wants to merge 1 commit into
trilitech:mainfrom
saroupille:analysis/withdraw-auth-gap-poc-v2

Conversation

@saroupille
Copy link
Copy Markdown
Collaborator

Follow-up to saroupille#1. That PR documented a reproducible
auth gap on KernelInboxMessage::Withdraw; the kernel-side PoC it
shipped used the shortcut
encode_ticket_deposit_message(victim, amount) to populate the
victim's public balance in one step, and that shortcut no longer
reaches the Withdraw handler because upstream commit 89834818
("Bind shield deposits to secret-derived keys") rejects deposits
whose recipient key is not deposit:<32-byte-hex>.

This PR ships a new test that reaches the same state via direct
store seeding (bridge ticketer + victim's balance written to
/tzel/v1/state/balances/by-key/<hex(addr)>) and shows the auth
gap is still present on current master.

What the test does

tezos/rollup-kernel/tests/bridge_flow.rs :: withdraw_poc_v2_drains_seeded_public_balance_without_auth

  1. Writes a bridge ticketer to PATH_BRIDGE_TICKETER so the Withdraw
    handler can encode its outbox message.
  2. Writes 500_001 to the victim's balance key — the same state a
    legitimate unshield would have produced.
  3. Pushes an external inbox message containing
    KernelWithdrawReq { sender: victim_tz1, recipient: attacker_tz1, amount: 500_001 }.
  4. Asserts KernelResult::Withdraw(_), victim's balance is now 0 or
    absent, and an outbox withdrawal record addressed to the attacker
    was enqueued for 500_001 mutez.

Run:

cargo +nightly-2025-07-14 test --release --test bridge_flow \
    withdraw_poc_v2_drains_seeded_public_balance_without_auth
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 12 filtered out

No feature flag, no fixture proofs. The proof-verifier-gated
fixture tests on master currently panic at the first config step
(verified_bridge_flow.json appears out of sync with the code); the
seeded approach avoids that drift.

Evidence on master (unchanged since PR #1)

  • core/src/kernel_wire.rs:110-115KernelWithdrawReq { sender, recipient, amount }. No signature, no proof.
  • tezos/rollup-kernel/src/lib.rs:1004-1028 — Withdraw match arm:
    checks !is_deposit_balance_key(sender), reads ticketer, checks
    balance >= amount, writes outbox, debits. Nothing binds sender
    to the caller.

The is_deposit_balance_key check added by 89834818 narrows the
attack surface (a deposit:<hex> key cannot be drained via Withdraw)
but does not address the general case: once any unshield has credited
a tz1 balance, that balance is drainable by anyone who can submit an
inbox message — any Tezos L1 account holder — because the rollup
kernel has no access to the L1 source of the inbox message and the
request itself carries no auth.

Impact

  • Any public rollup balance (produced by unshield) can be drained by
    any party able to submit an inbox message to the rollup, e.g. via
    octez-client send smart rollup message from any funded L1 account.
  • The bridge's deposit:<hex> balances are safe from direct drain
    (per the commit 8983481 guard) but still transit through a
    drainable state after unshield.
  • The operator's require_bearer_auth (services/tzel/src/bin/
    tzel_operator.rs:304) is bypassed because the attack submits via
    octez-client directly, not through the operator.

Fix space

Not proposing a fix in this PR. Relevant direction, already sketched
on design/tezos-auth-shield-withdraw: bind each Withdraw to a proof
the caller owns sender. Two families discussed there:

  • Tezos sig on Withdraw: extend KernelWithdrawReq with a
    signature field; kernel verifies the sig against sender (when
    sender is a tz1/KT1). Requires a canonical Withdraw-message
    serialization to sign.
  • WOTS leaf per account: same pattern the shield/unshield path
    already uses for private notes, lifted to public balances. Larger
    change, PQ-compatible.

Either would remove the need for the is_deposit_balance_key
guard — deposit and public-balance keys could converge into one
namespace once Withdraw is authenticated.

What this PR does NOT propose

  • No fix (the design decision is upstream's).
  • No change to wallet, operator, or bridge contract.
  • No removal of the v1 PoC on analysis/withdraw-auth-gap-poc; this
    sits alongside it as the current-master variant.

Verification artefacts

  • Test file changed: tezos/rollup-kernel/tests/bridge_flow.rs
    (+108 lines, one new test, no other changes).
  • Branches from upstream/main at 1bea26b.
  • No dependency changes.

🤖 Generated with Claude Code

…alance on master

The earlier PoC on analysis/withdraw-auth-gap-poc used the deposit
shortcut `encode_ticket_deposit_message(victim, amount)` to populate
a victim's public balance in a single step. Since upstream commit
8983481 ("Bind shield deposits to secret-derived keys") the kernel
rejects any deposit whose recipient key is not `deposit:<32-byte-hex>`,
so that shortcut no longer reaches the Withdraw handler.

`KernelWithdrawReq` is still `{sender, recipient, amount}` on master
(origin/main @ 1bea26b) — no signature, no proof. The auth gap the
original PoC documented is still live; the secret-bound-deposit check
is a partial patch for the deposit sub-case only.

This adapted PoC seeds the kernel store directly (bridge ticketer +
victim's balance at `/tzel/v1/state/balances/by-key/hex(tz1…)`) to
reach the state a legitimate unshield would have produced, then
submits an unauthenticated Withdraw from a third party. The drain
succeeds, the victim's balance is zeroed / deleted, and an outbox
message addressed to the attacker is enqueued.

Why seeded state instead of fixture-driven shield+unshield: the
`verified_bridge_flow.json` fixture is out of sync with master (all
fixture-based tests in `bridge_flow.rs` currently panic under the
`proof-verifier` feature). Seeding bypasses the fixture drift and
isolates the bug of interest — once balance exists, anyone drains.

Remove this test once the kernel gains sender authentication on
Withdraw (design/tezos-auth-shield-withdraw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@saroupille saroupille closed this Apr 25, 2026
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