Skip to content

Commit 042daec

Browse files
authored
fix: reject empty UTXO outputs to prevent fund destruction (#2120)
Fixes critical vulnerability where outputs=[] with fee=0 bypasses conservation law check. Adds validation in both apply_transaction() and mempool_add() to reject empty outputs for non-minting transaction types. Includes comprehensive test cases demonstrating the vulnerability and fix. Reporter: yuzengbaao (XiaZong) Payment: 50 RTC to wallet RTC0816b68b604630945c94cde35da4641a926aa4fd
1 parent ac6ef99 commit 042daec

3 files changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: MIT
3+
"""
4+
Test case for UTXO Empty Outputs Bug in apply_transaction()
5+
Issue: #2819 - Red Team UTXO Implementation
6+
7+
This test demonstrates a CRITICAL vulnerability where empty outputs
8+
result in complete fund destruction.
9+
"""
10+
11+
import unittest
12+
import tempfile
13+
import os
14+
import sys
15+
import time
16+
17+
sys.path.insert(0, '/tmp/Rustchain_utxo/node')
18+
19+
from utxo_db import UtxoDB, UNIT
20+
21+
22+
class TestUTXOEmptyOutputsBug(unittest.TestCase):
23+
"""
24+
CRITICAL: Empty outputs must be rejected to prevent fund destruction.
25+
26+
Bounty: #2819 - Red Team UTXO Implementation
27+
Severity: CRITICAL (200 RTC)
28+
Reporter: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd)
29+
30+
Vulnerability:
31+
When outputs=[] and fee=0, the conservation check:
32+
if inputs and (output_total + fee) > input_total:
33+
becomes:
34+
if inputs and (0 + 0) > input_total:
35+
which evaluates to False, allowing the transaction to proceed.
36+
Result: Inputs are spent, no outputs are created → funds destroyed.
37+
"""
38+
39+
def setUp(self):
40+
self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
41+
self.tmp.close()
42+
self.db = UtxoDB(self.tmp.name)
43+
self.db.init_tables()
44+
45+
def tearDown(self):
46+
os.unlink(self.tmp.name)
47+
48+
def test_empty_outputs_rejected(self):
49+
"""
50+
CRITICAL: Empty outputs must be rejected to prevent fund destruction.
51+
52+
Steps:
53+
1. Create UTXO with 100 RTC
54+
2. Try to spend with outputs=[]
55+
3. Verify transaction is rejected
56+
4. Verify balance is preserved
57+
"""
58+
# Step 1: Create initial UTXO with 100 RTC
59+
ok = self.db.apply_transaction({
60+
'tx_type': 'mining_reward',
61+
'inputs': [],
62+
'outputs': [{'address': 'alice', 'value_nrtc': 100 * UNIT}],
63+
'fee_nrtc': 0,
64+
'timestamp': int(time.time()),
65+
}, block_height=1)
66+
self.assertTrue(ok, "Coinbase should succeed")
67+
68+
# Verify Alice has 100 RTC
69+
alice_before = self.db.get_balance('alice')
70+
self.assertEqual(alice_before, 100 * UNIT, "Alice should have 100 RTC")
71+
72+
# Get the UTXO
73+
boxes = self.db.get_unspent_for_address('alice')
74+
self.assertEqual(len(boxes), 1, "Alice should have 1 UTXO")
75+
box_id = boxes[0]['box_id']
76+
77+
# Step 2: EXPLOIT - Try to spend with empty outputs
78+
ok = self.db.apply_transaction({
79+
'tx_type': 'transfer',
80+
'inputs': [{'box_id': box_id, 'spending_proof': 'sig'}],
81+
'outputs': [], # EMPTY - This is the vulnerability!
82+
'fee_nrtc': 0,
83+
'timestamp': int(time.time()),
84+
}, block_height=2)
85+
86+
# Step 3: Transaction MUST be rejected
87+
self.assertFalse(ok,
88+
"CRITICAL: Empty outputs should be rejected to prevent fund destruction!")
89+
90+
# Step 4: Balance must be preserved
91+
alice_after = self.db.get_balance('alice')
92+
self.assertEqual(alice_after, 100 * UNIT,
93+
"Balance should not change if transaction is rejected")
94+
95+
# Verify no funds were destroyed
96+
total_supply = self.db.get_balance('alice') + \
97+
self.db.get_balance('bob') + \
98+
self.db.get_balance('charlie')
99+
self.assertEqual(total_supply, 100 * UNIT,
100+
"Total supply should remain 100 RTC - no funds destroyed")
101+
102+
103+
if __name__ == '__main__':
104+
suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOEmptyOutputsBug)
105+
runner = unittest.TextTestRunner(verbosity=2)
106+
result = runner.run(suite)
107+
108+
print("\n" + "=" * 70)
109+
if result.failures:
110+
print("⚠️ CRITICAL VULNERABILITY CONFIRMED!")
111+
print("⚠️ Test FAILED - Empty outputs are accepted (BUG!)")
112+
print("=" * 70)
113+
print("\nThis means funds can be destroyed via empty outputs.")
114+
print("Conservation law is bypassed.")
115+
print("\nFix: Add 'if not outputs: return False' in apply_transaction()")
116+
else:
117+
print("✅ Test PASSED - Empty outputs are rejected (FIXED)")
118+
print("=" * 70)

node/test_utxo_mempool_bug.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: MIT
3+
"""
4+
Test case for UTXO Mempool Empty Outputs Bug
5+
Issue: #2819 - Red Team UTXO Implementation
6+
7+
This test demonstrates that mempool_add() also accepts empty outputs.
8+
"""
9+
10+
import unittest
11+
import tempfile
12+
import os
13+
import sys
14+
import time
15+
16+
sys.path.insert(0, '/tmp/Rustchain_utxo/node')
17+
18+
from utxo_db import UtxoDB, UNIT
19+
20+
21+
class TestUTXOMempoolEmptyOutputsBug(unittest.TestCase):
22+
"""
23+
MEDIUM: mempool_add() accepts empty outputs, Bounty: #2819 - Red Team UTXO Implementation
24+
Severity: MEDIUM (50 RTC)
25+
Reporter: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd)
26+
"""
27+
28+
def setUp(self):
29+
self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
30+
self.tmp.close()
31+
self.db = UtxoDB(self.tmp.name)
32+
self.db.init_tables()
33+
34+
def tearDown(self):
35+
os.unlink(self.tmp.name)
36+
37+
def test_mempool_empty_outputs_rejected(self):
38+
"""
39+
MEDIUM: mempool should reject empty outputs.
40+
Steps:
41+
1. Create UTXO with 100 RTC
42+
2. Try to add tx with outputs=[] to mempool
43+
3. Verify transaction is rejected
44+
4. Verify mempool is empty
45+
"""
46+
# Step 1: Create initial UTXO with 100 RTC
47+
ok = self.db.apply_transaction({
48+
'tx_type': 'mining_reward',
49+
'inputs': [],
50+
'outputs': [{'address': 'alice', 'value_nrtc': 100 * UNIT}],
51+
'fee_nrtc': 0,
52+
'timestamp': int(time.time()),
53+
}, block_height=1)
54+
self.assertTrue(ok, "Coinbase should succeed")
55+
56+
# Verify Alice has 100 RTC
57+
alice_before = self.db.get_balance('alice')
58+
self.assertEqual(alice_before, 100 * UNIT)
59+
60+
# Get the UTXO
61+
boxes = self.db.get_unspent_for_address('alice')
62+
self.assertEqual(len(boxes), 1)
63+
box_id = boxes[0]['box_id']
64+
65+
# Step 2: EXPLOIT - Try to add tx with empty outputs to mempool
66+
ok = self.db.mempool_add({
67+
'tx_id': 'malicious_tx_001',
68+
'tx_type': 'transfer',
69+
'inputs': [{'box_id': box_id, 'spending_proof': 'sig'}],
70+
'outputs': [], # EMPTY - This should be rejected!
71+
'fee_nrtc': 0,
72+
})
73+
74+
# Step 3: Transaction MUST be rejected
75+
self.assertFalse(ok,
76+
"MEDIUM: Empty outputs should be rejected from mempool!")
77+
78+
# Step 4: Verify mempool is empty
79+
candidates = self.db.mempool_get_block_candidates()
80+
self.assertEqual(len(candidates), 0,
81+
"Mempool should be empty if transaction is rejected")
82+
83+
84+
if __name__ == '__main__':
85+
suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOMempoolEmptyOutputsBug)
86+
runner = unittest.TextTestRunner(verbosity=2)
87+
result = runner.run(suite)
88+
89+
print("\n" + "=" * 70)
90+
if result.failures:
91+
print("⚠️ VULNERABILITY CONFIRMED!")
92+
print("⚠️ Test FAILED - Empty outputs accepted by mempool (BUG!)")
93+
print("=" * 70)
94+
print("\nThis allows DoS via mempool flooding.")
95+
print("\nFix: Add empty outputs check in mempool_add()")
96+
else:
97+
print("✅ Test PASSED - Empty outputs rejected from mempool (FIXED)")
98+
print("=" * 70)

node/utxo_db.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,14 @@ def apply_transaction(self, tx: dict, block_height: int,
388388
conn.execute("ROLLBACK")
389389
return False
390390

391+
# CRITICAL FIX: Reject empty outputs to prevent fund destruction
392+
# Without this check, outputs=[] bypasses conservation law:
393+
# output_total=0, fee=0 → (0+0) > input_total → False (bypassed)
394+
# Result: inputs spent, no outputs created → funds destroyed
395+
if not outputs and tx_type not in MINTING_TX_TYPES:
396+
conn.execute("ROLLBACK")
397+
return False
398+
391399
output_total = sum(o['value_nrtc'] for o in outputs)
392400

393401
# Every output must carry a strictly positive value.
@@ -661,6 +669,12 @@ def mempool_add(self, tx: dict) -> bool:
661669
conn.execute("ROLLBACK")
662670
return False
663671

672+
# MEDIUM FIX: Reject empty outputs to prevent DoS
673+
outputs = tx.get('outputs', [])
674+
if not outputs and tx_type not in MINTING_TX_TYPES:
675+
conn.execute("ROLLBACK")
676+
return False
677+
664678
input_total = 0
665679
for inp in inputs:
666680
row = conn.execute(

0 commit comments

Comments
 (0)