Skip to content

[UTXO-BUG] HIGH-1: TOCTOU race in spend_box() — silent double-spend#2069

Merged
Scottcjn merged 1 commit intoScottcjn:mainfrom
ArokyaMatthew:utxo-bug/high1-spend-box-toctou
Apr 5, 2026
Merged

[UTXO-BUG] HIGH-1: TOCTOU race in spend_box() — silent double-spend#2069
Scottcjn merged 1 commit intoScottcjn:mainfrom
ArokyaMatthew:utxo-bug/high1-spend-box-toctou

Conversation

@ArokyaMatthew
Copy link
Copy Markdown
Contributor

Vulnerability Class

High — Race condition / TOCTOU (100 RTC bounty)

The Bug

spend_box() performs a SELECT then UPDATE without an exclusive lock when called standalone. Two concurrent callers can both read spent_at IS NULL, then both UPDATE — the second gets rowcount=0 but the method never checks rowcount, so it returns the box dict as if the spend succeeded.

Race Sequence

  1. Thread A: SELECT → sees unspent → proceeds
  2. Thread B: SELECT → sees unspent → proceeds
  3. Thread A: UPDATE (rowcount=1) → commits ✓
  4. Thread B: UPDATE (rowcount=0, already spent) → commits ← silent failure

Thread B believes it successfully spent the box but the UPDATE was a no-op.

Fix

  1. Acquire BEGIN IMMEDIATE when own=True to serialize the SELECT+UPDATE
  2. Check UPDATE rowcount == 1; raise ValueError if 0 (concurrent spend)
  3. Proper ROLLBACK on all early-exit paths

Tests Added

  • test_spend_box_double_spend_raises — verifies ValueError on double-spend via spend_box()
  • test_spend_box_nonexistent_returns_none — verifies None return for missing box

All 36 tests pass.

Files Changed

  • node/utxo_db.py — spend_box() rewritten with BEGIN IMMEDIATE + rowcount check
  • node/test_utxo_db.py — 2 test cases added

Ref: Bounty #2819

MY WALLET IS aroky-x86-miner

…le-spend

spend_box() performs SELECT then UPDATE without an exclusive lock when
called standalone (own=True). Two concurrent callers can both read
spent_at IS NULL, then both UPDATE — the second gets rowcount=0 but
the method never checks rowcount, so it returns the box dict as if
the spend succeeded. The caller proceeds thinking the box was spent.

Fixes:
1. Acquire BEGIN IMMEDIATE when own=True to serialize the operation
2. Check UPDATE rowcount == 1; raise ValueError if 0 (concurrent spend)
3. Proper ROLLBACK on all early-exit paths

Tests added:
- test_spend_box_double_spend_raises
- test_spend_box_nonexistent_returns_none

All 36 tests pass.

Bounty: #2819 (High, 100 RTC)
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

Welcome to RustChain! Thanks for your first pull request.

Before we review, please make sure:

  • Your PR has a BCOS-L1 or BCOS-L2 label
  • New code files include an SPDX license header
  • You've tested your changes against the live node

Bounty tiers: Micro (1-10 RTC) | Standard (20-50) | Major (75-100) | Critical (100-150)

A maintainer will review your PR soon. Thanks for contributing!

@github-actions github-actions bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines labels Apr 5, 2026
@zhuzhushiwojia
Copy link
Copy Markdown

Great security audit work! These fixes are critical for the protocol.

@Scottcjn Scottcjn merged commit 79e09d6 into Scottcjn:main Apr 5, 2026
3 checks passed
@Scottcjn
Copy link
Copy Markdown
Owner

Scottcjn commented Apr 5, 2026

Merged. Excellent find — the TOCTOU race between SELECT and UPDATE in spend_box() was a real concurrency vulnerability. The BEGIN IMMEDIATE + rowcount verification is textbook correct.

75 RTC paid.

Wallet: ArokyaMatthew | Reason: bounty #2819 HIGH-1 TOCTOU race fix in spend_box()

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

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants