Kernel auth gap on Withdraw — reproducible PoC + analysis (stacked on #3)#1
Open
saroupille wants to merge 3 commits intofix/configure-messages-via-dalfrom
Open
Kernel auth gap on Withdraw — reproducible PoC + analysis (stacked on #3)#1saroupille wants to merge 3 commits intofix/configure-messages-via-dalfrom
saroupille wants to merge 3 commits intofix/configure-messages-via-dalfrom
Conversation
…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>
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.
Summary
This branch documents and provides reproducible proofs of an authentication gap in the kernel's
Withdrawpath (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 knownpublic_accountto a recipient they control. TheKernelWithdrawReqstruct has no signature field,apply_kernel_messageruns no authentication check, and the operator's single bearer token does not protect against directoctez-client send smart rollup messagesubmissions.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
KernelWithdrawReqis three fields —sender,recipient,amount— no signature, no proof.apply_kernel_messageonWithdrawchecks thatbalance(sender) >= amountand thatrecipientis a parseable tz1/KT1, writes an outbox message debitingsenderand creditingrecipient, 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'ssubmit_rollup_messagehandler verifies a single bearer token shared across the whole instance, but this is irrelevant becauseoctez-client send smart rollup messageis callable by any Tezos account holder, bypassing the operator entirely. The Shield path has the same structural absence of sender authentication (the STARK proof bindshash(sender)but has no private input tying to ownership).What this branch ships
docs/analysis/withdraw-auth-gap.mdtezos/rollup-kernel/tests/bridge_flow.rs(+104)withdraw_poc_drains_unauthorized_sender— runs undercargo test --test bridge_flow, no sandbox needed. Configure → deposit 500_001 mutez toalice→ unauthorized third party submits Withdraw withsender = "alice"→ asserts the drain succeeded. Positive-passing today (documenting the gap); flip to negative-asserting once auth lands.scripts/sandbox_withdraw_auth_bypass_poc.shbootstrap2(explicitly NOT the operator'ssource_alias) viaoctez-client send smart rollup message. Terminates withVULNERABILITY CONFIRMEDon success.tezos/rollup-kernel/src/bin/octez_kernel_message.rs(+21)withdrawsubcommand — minimal PoC helper that emits a framedKernelInboxMessage::Withdrawready foroctez-client. Removes cleanly once authentication is added.Evidence (abridged)
core/src/kernel_wire.rs:110-115—KernelWithdrawReq { sender, recipient, amount }. No signature, no proof.tezos/rollup-kernel/src/lib.rs:~1009— Withdraw match arm: balance check, recipient format check (TezosContract::from_b58checkat :509), outbox write. Nothing comparessenderwith anything the caller can prove.services/tzel/src/bin/tzel_operator.rs:304—require_bearer_authis a single-token check againstconfig.bearer_token. No per-user mapping.apps/wallet/src/lib.rs:6501— the legitimate CLI withdraw constructs aKernelWithdrawReqwith the user's chosensenderstring 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 bootstrap2From
bootstrap2, which is not the operator source. Kernel processes. Balance drains.Verification
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:
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.
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
withdrawsubcommand inoctez_kernel_message.rsis 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