Skip to content

Kernel auth gap on Withdraw — reproducible PoC + analysis (stacked on #3)#1

Open
saroupille wants to merge 3 commits intofix/configure-messages-via-dalfrom
analysis/withdraw-auth-gap-poc
Open

Kernel auth gap on Withdraw — reproducible PoC + analysis (stacked on #3)#1
saroupille wants to merge 3 commits intofix/configure-messages-via-dalfrom
analysis/withdraw-auth-gap-poc

Conversation

@saroupille
Copy link
Copy Markdown
Owner

Summary

This branch documents and provides reproducible proofs of an authentication gap in the kernel's Withdraw path (and, by the same mechanism, Shield). Any entity that can submit an external inbox message to the rollup — i.e., any Tezos L1 account holder — can drain any known public_account to a recipient they control. The KernelWithdrawReq struct has no signature field, apply_kernel_message runs no authentication check, and the operator's single bearer token does not protect against direct octez-client send smart rollup message submissions.

This is not a bug report framed as "fix me in this PR". It is an analysis branch that (a) makes the gap trivially reproducible in CI, (b) walks through the evidence in the codebase, and (c) sketches the design space. The design decision — accept single-tenant as the intended model, add a Tezos sig, add a WOTS leaf per account, or something else — belongs upstream and is out of scope here.

Built on top of PR trilitech#3 (fix/configure-messages-via-dal) to keep the kernel tree consistent with admin-DAL routing.

The gap in one paragraph

KernelWithdrawReq is three fields — sender, recipient, amount — no signature, no proof. apply_kernel_message on Withdraw checks that balance(sender) >= amount and that recipient is a parseable tz1/KT1, writes an outbox message debiting sender and crediting recipient, and returns. The kernel has no access to the L1 tx source that carried the inbox message, and the struct carries no information that could bind the withdraw to the true owner of the public account. The tzel-operator's submit_rollup_message handler verifies a single bearer token shared across the whole instance, but this is irrelevant because octez-client send smart rollup message is callable by any Tezos account holder, bypassing the operator entirely. The Shield path has the same structural absence of sender authentication (the STARK proof binds hash(sender) but has no private input tying to ownership).

What this branch ships

File Purpose
docs/analysis/withdraw-auth-gap.md Full write-up: evidence with line references, threat model (public_accounts are enumerable via durable state RPC + bridge deposits are public on L1), blast radius, and four mitigation sketches with tradeoffs.
tezos/rollup-kernel/tests/bridge_flow.rs (+104) withdraw_poc_drains_unauthorized_sender — runs under cargo test --test bridge_flow, no sandbox needed. Configure → deposit 500_001 mutez to alice → unauthorized third party submits Withdraw with sender = "alice" → asserts the drain succeeded. Positive-passing today (documenting the gap); flip to negative-asserting once auth lands.
scripts/sandbox_withdraw_auth_bypass_poc.sh End-to-end sandbox smoke that forks the DAL smoke, keeps setup + deposit, then attacks: submit a Withdraw from bootstrap2 (explicitly NOT the operator's source_alias) via octez-client send smart rollup message. Terminates with VULNERABILITY CONFIRMED on success.
tezos/rollup-kernel/src/bin/octez_kernel_message.rs (+21) withdraw subcommand — minimal PoC helper that emits a framed KernelInboxMessage::Withdraw ready for octez-client. Removes cleanly once authentication is added.

Evidence (abridged)

  • core/src/kernel_wire.rs:110-115KernelWithdrawReq { sender, recipient, amount }. No signature, no proof.
  • tezos/rollup-kernel/src/lib.rs:~1009 — Withdraw match arm: balance check, recipient format check (TezosContract::from_b58check at :509), outbox write. Nothing compares sender with anything the caller can prove.
  • services/tzel/src/bin/tzel_operator.rs:304require_bearer_auth is a single-token check against config.bearer_token. No per-user mapping.
  • apps/wallet/src/lib.rs:6501 — the legitimate CLI withdraw constructs a KernelWithdrawReq with the user's chosen sender string and posts through the operator; the same construction is reachable by any third party.

The attack path used in the sandbox PoC is exactly:

octez-client send smart rollup message "hex:[ \"<framed withdraw>\" ]" from bootstrap2

From bootstrap2, which is not the operator source. Kernel processes. Balance drains.

Verification

$ cargo test --test bridge_flow withdraw_poc_drains_unauthorized_sender
cargo test: 1 passed, 8 filtered out (1 suite, 0.02s)

$ TZEL_OCTEZ_SANDBOX_PRESERVE=1 ./scripts/sandbox_withdraw_auth_bypass_poc.sh
...
==========================================================
VULNERABILITY CONFIRMED: alice's 500001 mutez was drained
by a withdraw message signed by bootstrap2 (not operator).
No bearer token was needed.  No proof was needed.
==========================================================

Open question for the kernel maintainer

The design intent here needs to be stated explicitly before any further UX / multi-tenant deployment work proceeds. Specifically:

  1. Is the current model single-tenant by intent? If yes, documenting this constraint in deployment guides + operator runbooks + wallet UX would prevent misuse. In that case, these PoCs serve as a regression trap rather than a fix target.

  2. Or is sender authentication at the kernel level planned? The analysis doc sketches two families (Tezos-sig-bound-at-deposit, WOTS-leaf-per-account), both are post-quantum-compatible with the existing kernel structure. If this is the intended direction, it shapes downstream work: bridge contract changes, wallet submission flow, operator submission API, etc.

Follow-up hygiene

This branch does not propose a fix. The withdraw subcommand in octez_kernel_message.rs is a PoC helper; it should be removed once authentication lands. The Rust test and sandbox script are kept as regression traps.

🤖 Generated with Claude Code

saroupille and others added 3 commits April 21, 2026 11:37
…per)

Emit a framed `KernelInboxMessage::Withdraw` ready to be submitted via
`octez-client send smart rollup message`, with no signature and no
proof — the `KernelWithdrawReq` struct has no such fields, and neither
the kernel nor the operator ask for them on the user withdraw path.

Used by `scripts/sandbox_withdraw_auth_bypass_poc.sh` and referenced
in `docs/analysis/withdraw-auth-gap.md` to make the auth gap
reproducible.  Kept minimal (one subcommand, three string/integer
parameters, same encoding path as the existing `configure-*` paths)
so that a later commit can remove it cleanly once authentication is
added to `KernelWithdrawReq`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a kernel-level integration test (`bridge_flow.rs`) that exercises
the current auth gap on `KernelInboxMessage::Withdraw`:

  1. Configure bridge ticketer (admin path, signed with the dev WOTS ask).
  2. Deposit 500_001 mutez to `alice` via the legitimate bridge flow.
  3. Submit a Withdraw with `sender = "alice"` and a recipient the
     attacker controls — as an external inbox message, no signature,
     no proof.  Nothing in the caller's provenance is checked by the
     kernel (the PVM has no access to the L1 tx author anyway, and the
     `KernelWithdrawReq` struct has no sig/proof field that could bind
     the withdraw to the actual owner of `alice`).
  4. Assert the withdraw applied, alice's balance is zero, and the
     outbox message credits the attacker's recipient.

The test is **positive-passing today** (the kernel accepts the
attack), which is exactly what documents the gap.  When authentication
is ever added — e.g. a Tezos sig verified against an owner stored at
deposit time, or a per-account WOTS leaf registered and checked — this
test MUST be updated to expect a rejection and flip its assertions.
At that point it turns into a regression trap against accidentally
removing the auth.

Reference: `docs/analysis/withdraw-auth-gap.md` for the full analysis
and mitigation sketches.  Sandbox-level reproduction at
`scripts/sandbox_withdraw_auth_bypass_poc.sh`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two artefacts:

1. `docs/analysis/withdraw-auth-gap.md` — full write-up: what the
   gap is, evidence in the code (KernelWithdrawReq struct,
   apply_kernel_message match arm, operator's bearer-only auth,
   direct-L1-submit bypass), threat model, blast radius, and
   four mitigation sketches with tradeoffs (not an endorsement —
   the design decision belongs upstream).

2. `scripts/sandbox_withdraw_auth_bypass_poc.sh` — end-to-end
   reproduction on an octez sandbox.  Forked from the DAL smoke,
   keeps setup + originate + configure + deposit unchanged, then
   replaces the shield fixture step with:
       bootstrap2 (NOT operator) → octez_kernel_message withdraw
       → octez-client send smart rollup message from bootstrap2
       → kernel applies → alice's balance drained to 0
   Terminates with "VULNERABILITY CONFIRMED" on success.

The sandbox PoC and the kernel-level test in `bridge_flow.rs`
(previous commit) are complementary: the Rust test exercises the
kernel PVM directly (runs in CI under `cargo test`, no sandbox
needed), while the sandbox PoC demonstrates the full end-to-end
attack path including the "submit from a non-operator tz1 via
`octez-client send smart rollup message`" step.

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