Skip to content

[Security] instance_answers_monitor overwrites a live peg-in instance with a blank UserDiscarded stub via unguarded upsert_instance (DoS / state corruption) #464

Description

@byfor8

Summary

instance_answers_monitor can overwrite a live, progressing peg-in instance row with a blank stub. The write primitive it uses is a bare INSERT OR REPLACE of every instance column, while the P2P path refuses exactly this write through a compare-and-set status guard. A committee node that is behind on Gateway history and simultaneously live on P2P can have its instance row wiped: committees_answers emptied, parameters set to NULL, btc_txid cleared, status forced to UserDiscarded. The row cannot self-heal; afterwards the node stops contributing pegin_confirm signatures.

Security impact: denial of service on the committee consensus path (pegin_confirm is n of n on the Bitcoin path) plus incorrect state transition on the affected node's local store. No fund theft path identified.

Affected component

  • Repository: GOATNetwork/bitvm-node
  • Commit reviewed: 1ff9a4aeced45309a097eeba5955e26164302ab3 (current HEAD at time of review)
  • Files: node/src/scheduled_tasks/instance_maintenance_tasks.rs, crates/store/src/localdb.rs, node/src/utils.rs, node/src/scheduled_tasks/event_watch_task.rs, node/src/scheduled_tasks/mod.rs
  • Component class: Implementation (Bridge State Machine node internals)

Description

instance_answers_monitor (node/src/scheduled_tasks/instance_maintenance_tasks.rs:118-231) scans goat_tx_record rows of type BridgeInRequest with status Pending. For a record that is outside the response window and whose input UTXOs are no longer available, the monitor builds a fresh instance via generate_instance_from_bridge_in_request_event (node/src/scheduled_tasks/event_watch_task.rs:997-1054) and stamps it UserDiscarded, then writes it with upsert_instance.

The stub is built by generate_instance (node/src/utils.rs:4371-4422) with committees_answers = empty, parameters = None, btc_txid = None. The write goes through upsert_instance (crates/store/src/localdb.rs:1124-1131), which is a bare INSERT OR REPLACE INTO instance (...) that replaces every column.

The P2P path deliberately refuses this write: store_pegin_request (node/src/utils.rs:4437-4465) routes through upsert_pegin_request_instance (crates/store/src/localdb.rs:1179-1217), which carries an explicit allow list of statuses (UserIniting, UserInited). The code comment there states the guard exists "keeps a replayed or forged request from rolling a live instance back to UserInited and clearing what later stages stored". The monitor bypasses that same guard by calling the unguarded sibling.

How it can be exploited (no attacker action needed, self race)

Maintenance is deferred while Gateway history catchup runs (is_processing_gateway_history_events check, node/src/scheduled_tasks/mod.rs:263-282), but P2P keeps running during that window. A node behind on history can therefore have a live instance created by P2P (committees answered, pegin_prepare broadcast, request UTXOs spent) while its BridgeInRequest goat_tx_record is still Pending, because that record is only ever written by the history watcher (event_watch_task.rs:674-687), never by P2P. When catchup finishes, the first monitor pass sees the record outside the response window with spent UTXOs and executes the INSERT OR REPLACE, wiping the live row.

Recovery is impossible from the outside: UserDiscarded is not in the CAS allow list, so replayed PeginRequest messages are ignored, and later BridgeIn mint events update only the status field, not committees_answers, parameters, or btc_txid.

Steps to reproduce

  1. Run a committee node with a watch contract whose from_height + gap is behind the finalized tip, so history catchup is active and instance_answers_monitor is deferred (mod.rs:263-282).
  2. Over P2P, deliver a PeginRequest for a bridge-in instance. store_pegin_request creates the live instance row (CAS guarded). Committees answer and the user broadcasts pegin_prepare, so the request UTXOs are spent.
  3. Let history catchup complete; it upserts the BridgeInRequest goat_tx_record as Pending (event_watch_task.rs:674-687).
  4. instance_answers_monitor runs, sees the record outside the response window with outpoint_available false, and calls upsert_instance with the blank UserDiscarded stub (instance_maintenance_tasks.rs:168-182).
  5. Inspect the instance row: committees_answers emptied, parameters NULL, btc_txid NULL, status UserDiscarded. Replay a PeginRequest; it is ignored because UserDiscarded is outside the allow list.

Proof of concept (source level)

No PoC transaction was performed and no live system was contacted; this is a source-only review at the pinned commit. The relevant code, byte-exact at 1ff9a4a:

// instance_maintenance_tasks.rs:168-182, the destructive write
    instance.status = InstanceBridgeInStatus::UserDiscarded.to_string();
    Some(instance)
...
    let mut tx = local_db.start_transaction().await?;
    if let Some(event) = event {
        if is_outside_response_window {
            if let Some(instance) = discarded_instance {
                tx.upsert_instance(&instance).await?;
// localdb.rs:1124-1131, unguarded write primitive
    pub async fn upsert_instance(&mut self, instance: &Instance) -> anyhow::Result<bool> {
        ...
        let res = sqlx::query!(
            "INSERT OR
            REPLACE INTO instance (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height,
                        user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers,
                       pegin_data_tx_hash, btc_height, parameters, status_updated_at, post_pegin_txhash, created_at, updated_at)
// utils.rs:4410-4422, the blank stub fields
        status: InstanceBridgeInStatus::UserInited.to_string(),
        ...
        btc_txid: None,
        pegin_confirm_txid: None,
        pegin_cancel_txid: None,
        committees_answers: IndexMap::new(),
        pegin_data_tx_hash: "".to_string(),
        btc_height: 0,
        parameters: None,
// utils.rs:4437-4465, the CAS guard the P2P path uses and the monitor bypasses
    // A PeginRequest only initializes an instance. Guarding the write on the
    // pre-pegin statuses keeps a replayed or forged request from rolling a live
    // instance back to UserInited and clearing what later stages stored.
    let stored = storage_processor
        .upsert_pegin_request_instance(
            &instance,
            &[
                InstanceBridgeInStatus::UserIniting.to_string(),
                InstanceBridgeInStatus::UserInited.to_string(),
            ],
        )

Security impact

Denial of service on the committee consensus path: the affected node stops contributing pegin_confirm signatures, and pegin_confirm is n of n on the Bitcoin path, so a stalled committee delays in-flight peg-ins. Incorrect state transition on the node's local instance store, with no self-healing path. Not a fund theft path.

Source only review; no PoC transactions were performed; no live system was contacted.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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