Skip to content

[Security] detect_kickoff only watches one OperatorDataPushed graph per operator (unauthorized Take1) #431

Description

@asbestos22

Summary

Unauthorized kickoff (no L2 initWithdraw) is in the Gateway threat model. Automated Challenge starts at detect_kickoff, which is supposed to enqueue {graph_id}_KickoffSent for every posted graph whose kickoff is confirmed.

detect_kickoff does not scan every OperatorDataPushed graph. It uses fetch_on_turn_graph_by_status, which keeps one row per operator_pubkey. SQL order is operator_pubkey, kickoff_index ASC, so the watched graph is always the lowest nonce still at OperatorDataPushed.

An operator with two posted graphs leaves nonce 0 idle and broadcasts kickoff on nonce ≥ 1. Honest verifiers and the committee never create {graph_id}_KickoffSent for the kicked graph. After ConnectorA CSV the operator Take1s. PegBTC was never burned.

This is not #429. #429 is: KickoffSent exists, verifier Ok(()) on SPV lag, no retry. Here KickoffSent is never created for the kicked graph. Fixing #429 (defer on SPV lag) does not close this.

Not proven here: live kickoff / Take1 / BTC leaving the peg. Complete code path on stock gc-v2, not a live steal.

Affected component

GOATNetwork/bitvm-node gc-v2 @ f2f0285e — Bridge State Machine (kickoff detection). Live gc-v2 HEAD is still this commit (2026-08-17). No later patch on detect_kickoff / fetch_on_turn_graph_by_status.

  • node/src/scheduled_tasks/mod.rs fetch_on_turn_graph_by_status — “Only process one graph for each operator each time”
  • crates/store/src/localdb.rs find_graphs_by_status_group_by_operatorORDER BY operator_pubkey, kickoff_index
  • node/src/scheduled_tasks/graph_maintenance_tasks.rs detect_kickoff — only that filtered set; only that row’s kickoff_txid
  • Same helper also feeds detect_take1_or_challenge (OperatorKickOff) and process_graph_challenge (Challenge)
  • check_pre_kickoff_sent — only reached from detect_take1_or_challenge (OperatorKickOff), so an idle decoy never walks the chain forward
  • refresh_and_compensate — only when a message already names that graph_id
  • handle_take1_ready_operator — no L2 withdraw-status check
  • operator_kickoff / operator_sign_take1 — sign this graph only; do not require the lower nonce to be skipped

Not #416, not #418, not #428, not #429, not #430.

Mechanism

// fetch_on_turn_graph_by_status
// graphs_ori already ORDER BY operator_pubkey, kickoff_index
for graph in graphs_ori {
    if graph.operator_pubkey != pre_operator_pubkey {
        pre_operator_pubkey = graph.operator_pubkey.clone();
        graphs.push(graph); // first / lowest kickoff_index only
    }
}

// detect_kickoff
let graphs = fetch_on_turn_graph_by_status(..., OperatorDataPushed)?;
// only graphs[0] for this operator is checked for kickoff-on-chain

Two OperatorDataPushed graphs for one operator is the normal multi-pegin state (graph_nonce / kickoff_index chain). The unused lower nonce is the decoy. It never changes status (no maintenance ticker calls refresh_graph on unused OperatorDataPushed rows), so later ticks keep watching it forever.

G1 prekickoff spends G0’s next-prekickoff connector, not G0’s kickoff connector (vout 1). G0 therefore stays OperatorDataPushed in the local DB. Honest handle_kickoff_ready_operator would skip/close lower nonces first; a malicious operator calls operator_kickoff locally and does not.

No other path fills the gap:

  • detect_take1_or_challenge / process_graph_challenge also use fetch_on_turn (and on OperatorKickOff / Challenge, not the idle decoy).
  • check_pre_kickoff_sent walks the prekickoff chain only from an already-OperatorKickOff graph.
  • L2 InitWithdraw records a goat-tx hash; it does not move verifier graph status and does not enqueue KickoffSent.
  • upsert_message(..., is_update: false) cannot create {victim}_KickoffSent if detect_kickoff never names the victim.

Take1 does not need honest nodes to enqueue Take1Ready. operator_sign_take1 only needs committee presigs + unspent ConnectorA/B/guardian + CSV.

Steps (code path)

  1. Operator has G0 (kickoff_index=0) and G1 (kickoff_index=1), both postGraphData’d. G0 withdraw is None. Normal multi-pegin state.
  2. Operator broadcasts G1 prekickoff + kickoff. No initWithdraw on G1.
  3. Each honest node: detect_kickoff watches G0; G0 kickoff is not confirmed; no message.
  4. G1 is never scanned. No Challenge. No proceedWithdraw.
  5. Wait ConnectorA timelock.
  6. Operator signs Take1 and broadcasts.

A verifier that first comes online with only G1 in an empty DB would watch G1. Deployed nodes that presigned both pegins keep both rows. Same empty-DB caveat as #429. First graph on a brand-new operator is #429, not this.

Why the project's own TLA+ audit did not catch this

Repo ships audit/TLAPlus-20260710.md (commit 991faaa “Dev fix #418”). Scope note:

these specs model the local node's status bookkeeping and the timelock arithmetic of the transaction graph.

Coverage is every stateful enum (GraphStatus, instance/message races) plus shared-connector CSV margins. There is no model of the maintenance scheduler’s graph-selection (fetch_on_turn_graph_by_status): multiple graphs of the same operator competing for one per-tick slot.

TLA+ Finding 1 / 1b (Graph.status uncoordinated writers) is marked data-integrity, not fund-custody, and is FIXED in 991faaa via transition_graph_status + allowed_transition_from. That predecessor table correctly excludes PreKickoff/OperatorKickOff as sources of OperatorDataPushed. A graph that detect_kickoff never observes never reaches that guard. The status machine they verified is fine; the victim graph is never fed into it.

Recommendation 7 is a different gap (topology / value-conservation / leaf-script authorization). This ticket is the unmodeled scheduler gap, not Rec-7.

Impact (proven vs not)

Proven (source + local predicate)
Stock detect drops every OperatorDataPushed graph except the lowest kickoff_index per operator. Kickoff on a later graph does not create KickoffSent. Take1 has no L2 withdraw check.

Not proven
Live kickoff / Take1 / BTC moved / undercollateralized peg on testnet or mainnet.

Suggested fix

  1. Stop using fetch_on_turn_graph_by_status for protocol detect. It also feeds detect_take1_or_challenge and process_graph_challenge. Fixing only detect_kickoff leaves Assert/NACK/Take2 detect blind on a second Challenge graph.
  2. Scan every graph in the relevant status (or every known kickoff/assert txid).
  3. Periodically refresh_and_compensate unused OperatorDataPushed rows whose prekickoff or kickoff is on chain.
  4. Keep the #429 SPV-lag retry once KickoffSent exists.
  5. Do not treat “one graph per operator per tick” as a substitute for watching every live kickoff txid.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions