Skip to content

[Security] Swap claim watcher permanently wedges on Address::from_script of an attacker-chosen output script #435

Description

@asbestos22

Summary

Any unprivileged user can permanently wedge a Committee node's bridge-out (Swap) event watcher with one cheap on-chain claim.

BitcoinNoncedOutputClaimHandler.claim only checks keccak256(scriptPubKey) against the committed txoHash. It accepts OP_RETURN / bare / non-standard payout scripts. The node then does Address::from_script(...)? on that same script. A non-address script returns Err, the block-range DB transaction rolls back, and the Swap cursor from_height never advances. Every later tick re-fetches the same poison event.

Not proven here: live poison initialize+claim on testnet4 (withheld — that would wedge the deployed Committee node). This is a complete stock path on gc-v2, not a fund theft.

Affected component

GOATNetwork/bitvm-node gc-v2 — Bridge State Machine (Swap/EscrowManager event watcher).

  • node/src/scheduled_tasks/event_watch_task.rs handle_swap_claim_eventsAddress::from_script(...)?
  • monitor_events_itemfetch_and_handle_block_range_events(...).await? then from_height = to_height + 1 + commit
  • fetch_and_handle_bridge_out_events — Initializes then Claims in the same range
  • On-chain: BitcoinNoncedOutputClaimHandler.claim — keccak of script only, no script-type gate

Checked on gc-v2. Not #428 (unauth tag PUTs). Not #429 / #430 / #431. Not #21. Not #339 (style nits / hex parse). Not Pashov.

Mechanism

let to_addr = Address::from_script(
    bitcoin::Script::from_bytes(&claim_data.output_script),
    get_network(),
)?;

Inside the per-range transaction:

fetch_and_handle_block_range_events(...).await?; // Err returns before commit
watch_contract.from_height = to_height + 1;
tx.upsert_watch_contract(&watch_contract).await?;
tx.commit().await?;

find_claim_data swallows witness-decode errors (Ok(None)). The reachable wedge is a valid extract of a contract-accepted claim whose output_script is not P2PKH/P2SH/P2WPKH/P2WSH/P2TR.

Initialize in the same block range creates the instance first, so Claim does not skip from_script. initialize as both offerer and claimer with the reputation flag unset needs no claimer signature. amount has no > 0 require.

Only Actor::Committee watches Swap. Gateway is a separate monitor_events_item transaction. Swap Err does not roll back bridge-in.

WatchContractStatus::Failed is set on the history path (fetch_history_events), not the live tick. Live tick: log + retry the same range forever. Same wedge, no self-recovery without a code change + cursor surgery.

Impact

Remote, unauthenticated, cheap, permanent DoS of Committee bridge-out indexing (Claim/Refund/timeout tracking, bridge_out_global_stats). Escrow funds stay on-chain-authoritative (EscrowStorage._finalize is atomic). No pegin steal. No Gateway wedge.

Availability of a money-path indexer, not unauthorized withdrawal.

Suggested fix

Best-effort address decode (sibling already exists at utils.rs generate_instance: Address::from_script(...).map(|a| a.to_string()).unwrap_or_default()), and isolate per-event failures instead of aborting the block-range tx. Still advance from_height.

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