From 77cd981670efd843e79f7de8f8df5e6e1292621b Mon Sep 17 00:00:00 2001 From: XiaZong Date: Mon, 6 Apr 2026 05:32:44 +0000 Subject: [PATCH 1/3] fix(utxo): reject empty outputs to prevent fund destruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL vulnerability fix for Issue #2819 ### Vulnerability Empty outputs bypass conservation check: - outputs=[] + fee=0 → output_total=0 - Check: (0 + 0) > input_total → False (bypassed) - Result: Inputs spent, no outputs created → funds destroyed ### Impact - Severity: CRITICAL (200 RTC) - 100% fund destruction possible - Violates conservation law ### Fix Add empty outputs check before conservation validation ### Testing - ✅ Added test case: test_utxo_empty_outputs_bug.py - ✅ All 50 existing tests still pass - ✅ PoC test confirms vulnerability is fixed **Bounty**: #2819 - Red Team UTXO Implementation **Reporter**: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd) **Tier**: BCOS-L2 --- node/test_utxo_empty_outputs_bug.py | 120 ++++++++++++++++++++++++++++ node/utxo_db.py | 7 ++ 2 files changed, 127 insertions(+) create mode 100644 node/test_utxo_empty_outputs_bug.py diff --git a/node/test_utxo_empty_outputs_bug.py b/node/test_utxo_empty_outputs_bug.py new file mode 100644 index 00000000..efa8d336 --- /dev/null +++ b/node/test_utxo_empty_outputs_bug.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Test case for UTXO Empty Outputs Bug - FUND DESTRUCTION VULNERABILITY +Issue: #2819 - Red Team UTXO Implementation + +This test demonstrates a CRITICAL vulnerability where empty outputs +result in complete fund destruction, violating the conservation law. +""" + +import unittest +import tempfile +import os +import sys +import time + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from utxo_db import UtxoDB, UNIT + + +class TestUTXOEmptyOutputsBug(unittest.TestCase): + """ + CRITICAL: Empty outputs must be rejected to prevent fund destruction. + + Bounty: #2819 - Red Team UTXO Implementation + Severity: CRITICAL (200 RTC) + Reporter: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd) + + Vulnerability: + When outputs=[] and fee=0, the conservation check: + if inputs and (output_total + fee) > input_total: + becomes: + if inputs and (0 + 0) > input_total: + which evaluates to False, allowing the transaction to proceed. + Result: Inputs are spent, no outputs are created → funds destroyed. + """ + + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False) + self.tmp.close() + self.db = UtxoDB(self.tmp.name) + self.db.init_tables() + + def tearDown(self): + os.unlink(self.tmp.name) + + def test_empty_outputs_rejected(self): + """ + CRITICAL: Empty outputs must be rejected to prevent fund destruction. + + Steps: + 1. Create UTXO with 100 RTC + 2. Try to spend with outputs=[] + 3. Verify transaction is rejected + 4. Verify balance is preserved + """ + # Step 1: Create initial UTXO with 100 RTC + ok = self.db.apply_transaction({ + 'tx_type': 'mining_reward', + 'inputs': [], + 'outputs': [{'address': 'alice', 'value_nrtc': 100 * UNIT}], + 'fee_nrtc': 0, + 'timestamp': int(time.time()), + }, block_height=1) + self.assertTrue(ok, "Coinbase should succeed") + + # Verify Alice has 100 RTC + alice_before = self.db.get_balance('alice') + self.assertEqual(alice_before, 100 * UNIT, "Alice should have 100 RTC") + + # Get the UTXO + boxes = self.db.get_unspent_for_address('alice') + self.assertEqual(len(boxes), 1, "Alice should have 1 UTXO") + box_id = boxes[0]['box_id'] + + # Step 2: EXPLOIT - Try to spend with empty outputs + ok = self.db.apply_transaction({ + 'tx_type': 'transfer', + 'inputs': [{'box_id': box_id, 'spending_proof': 'sig'}], + 'outputs': [], # EMPTY - This is the vulnerability! + 'fee_nrtc': 0, + 'timestamp': int(time.time()), + }, block_height=2) + + # Step 3: Transaction MUST be rejected + self.assertFalse(ok, + "CRITICAL: Empty outputs should be rejected to prevent fund destruction!") + + # Step 4: Balance must be preserved + alice_after = self.db.get_balance('alice') + self.assertEqual(alice_after, 100 * UNIT, + "Balance should not change if transaction is rejected") + + # Verify no funds were destroyed + total_supply = self.db.get_balance('alice') + \ + self.db.get_balance('bob') + \ + self.db.get_balance('charlie') + self.assertEqual(total_supply, 100 * UNIT, + "Total supply should remain 100 RTC - no funds destroyed") + + +if __name__ == '__main__': + # Run the test + suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOEmptyOutputsBug) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Print summary + print("\n" + "=" * 70) + if result.failures: + print("⚠️ CRITICAL VULNERABILITY CONFIRMED!") + print("⚠️ Test FAILED - Empty outputs are accepted (BUG!)") + print("=" * 70) + print("\nThis means funds can be destroyed via empty outputs.") + print("Conservation law is bypassed.") + print("\nFix: Add 'if not outputs: return False' in apply_transaction()") + else: + print("✅ Test PASSED - Empty outputs are rejected (FIXED)") + print("=" * 70) diff --git a/node/utxo_db.py b/node/utxo_db.py index 1e960b66..a1d0361b 100644 --- a/node/utxo_db.py +++ b/node/utxo_db.py @@ -388,6 +388,13 @@ def apply_transaction(self, tx: dict, block_height: int, conn.execute("ROLLBACK") return False + # CRITICAL FIX: Reject empty outputs to prevent fund destruction + # Without this check, outputs=[] bypasses conservation law + # and results in complete fund destruction + if not outputs: + conn.execute("ROLLBACK") + return False + output_total = sum(o['value_nrtc'] for o in outputs) # Every output must carry a strictly positive value. From 80bc4bde6a1474065ab1457cf4da3c8758d2a569 Mon Sep 17 00:00:00 2001 From: XiaZong Date: Mon, 6 Apr 2026 05:35:36 +0000 Subject: [PATCH 2/3] docs: add SPDX header to test file --- node/test_utxo_empty_outputs_bug.py | 1 + 1 file changed, 1 insertion(+) diff --git a/node/test_utxo_empty_outputs_bug.py b/node/test_utxo_empty_outputs_bug.py index efa8d336..4cca61b2 100644 --- a/node/test_utxo_empty_outputs_bug.py +++ b/node/test_utxo_empty_outputs_bug.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# SPDX-License-Identifier: MIT """ Test case for UTXO Empty Outputs Bug - FUND DESTRUCTION VULNERABILITY Issue: #2819 - Red Team UTXO Implementation From 8d21356fd847855d0458ff6cd2a95586fcfaef06 Mon Sep 17 00:00:00 2001 From: XiaZong Date: Mon, 6 Apr 2026 13:02:08 +0000 Subject: [PATCH 3/3] fix(utxo): comprehensive empty outputs fix CRITICAL + MEDIUM vulnerability fixes for Issue #2819 **apply_transaction()**: Reject empty outputs (CRITICAL) **mempool_add()**: Reject empty outputs (MEDIUM - defense in depth) Testing: 50/50 existing tests + 2 new PoC tests pass --- node/test_utxo_empty_outputs_bug.py | 9 +-- node/test_utxo_mempool_bug.py | 98 +++++++++++++++++++++++++++++ node/utxo_db.py | 13 +++- 3 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 node/test_utxo_mempool_bug.py diff --git a/node/test_utxo_empty_outputs_bug.py b/node/test_utxo_empty_outputs_bug.py index 4cca61b2..a4d7125d 100644 --- a/node/test_utxo_empty_outputs_bug.py +++ b/node/test_utxo_empty_outputs_bug.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """ -Test case for UTXO Empty Outputs Bug - FUND DESTRUCTION VULNERABILITY +Test case for UTXO Empty Outputs Bug in apply_transaction() Issue: #2819 - Red Team UTXO Implementation This test demonstrates a CRITICAL vulnerability where empty outputs -result in complete fund destruction, violating the conservation law. +result in complete fund destruction. """ import unittest @@ -14,8 +14,7 @@ import sys import time -# Add parent directory to path for imports -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, '/tmp/Rustchain_utxo/node') from utxo_db import UtxoDB, UNIT @@ -102,12 +101,10 @@ def test_empty_outputs_rejected(self): if __name__ == '__main__': - # Run the test suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOEmptyOutputsBug) runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) - # Print summary print("\n" + "=" * 70) if result.failures: print("⚠️ CRITICAL VULNERABILITY CONFIRMED!") diff --git a/node/test_utxo_mempool_bug.py b/node/test_utxo_mempool_bug.py new file mode 100644 index 00000000..3a10b527 --- /dev/null +++ b/node/test_utxo_mempool_bug.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +Test case for UTXO Mempool Empty Outputs Bug +Issue: #2819 - Red Team UTXO Implementation + +This test demonstrates that mempool_add() also accepts empty outputs. +""" + +import unittest +import tempfile +import os +import sys +import time + +sys.path.insert(0, '/tmp/Rustchain_utxo/node') + +from utxo_db import UtxoDB, UNIT + + +class TestUTXOMempoolEmptyOutputsBug(unittest.TestCase): + """ + MEDIUM: mempool_add() accepts empty outputs, Bounty: #2819 - Red Team UTXO Implementation + Severity: MEDIUM (50 RTC) + Reporter: XiaZong (RTC0816b68b604630945c94cde35da4641a926aa4fd) + """ + + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False) + self.tmp.close() + self.db = UtxoDB(self.tmp.name) + self.db.init_tables() + + def tearDown(self): + os.unlink(self.tmp.name) + + def test_mempool_empty_outputs_rejected(self): + """ + MEDIUM: mempool should reject empty outputs. + Steps: + 1. Create UTXO with 100 RTC + 2. Try to add tx with outputs=[] to mempool + 3. Verify transaction is rejected + 4. Verify mempool is empty + """ + # Step 1: Create initial UTXO with 100 RTC + ok = self.db.apply_transaction({ + 'tx_type': 'mining_reward', + 'inputs': [], + 'outputs': [{'address': 'alice', 'value_nrtc': 100 * UNIT}], + 'fee_nrtc': 0, + 'timestamp': int(time.time()), + }, block_height=1) + self.assertTrue(ok, "Coinbase should succeed") + + # Verify Alice has 100 RTC + alice_before = self.db.get_balance('alice') + self.assertEqual(alice_before, 100 * UNIT) + + # Get the UTXO + boxes = self.db.get_unspent_for_address('alice') + self.assertEqual(len(boxes), 1) + box_id = boxes[0]['box_id'] + + # Step 2: EXPLOIT - Try to add tx with empty outputs to mempool + ok = self.db.mempool_add({ + 'tx_id': 'malicious_tx_001', + 'tx_type': 'transfer', + 'inputs': [{'box_id': box_id, 'spending_proof': 'sig'}], + 'outputs': [], # EMPTY - This should be rejected! + 'fee_nrtc': 0, + }) + + # Step 3: Transaction MUST be rejected + self.assertFalse(ok, + "MEDIUM: Empty outputs should be rejected from mempool!") + + # Step 4: Verify mempool is empty + candidates = self.db.mempool_get_block_candidates() + self.assertEqual(len(candidates), 0, + "Mempool should be empty if transaction is rejected") + + +if __name__ == '__main__': + suite = unittest.TestLoader().loadTestsFromTestCase(TestUTXOMempoolEmptyOutputsBug) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + print("\n" + "=" * 70) + if result.failures: + print("⚠️ VULNERABILITY CONFIRMED!") + print("⚠️ Test FAILED - Empty outputs accepted by mempool (BUG!)") + print("=" * 70) + print("\nThis allows DoS via mempool flooding.") + print("\nFix: Add empty outputs check in mempool_add()") + else: + print("✅ Test PASSED - Empty outputs rejected from mempool (FIXED)") + print("=" * 70) diff --git a/node/utxo_db.py b/node/utxo_db.py index a1d0361b..1dd1046d 100644 --- a/node/utxo_db.py +++ b/node/utxo_db.py @@ -389,9 +389,10 @@ def apply_transaction(self, tx: dict, block_height: int, return False # CRITICAL FIX: Reject empty outputs to prevent fund destruction - # Without this check, outputs=[] bypasses conservation law - # and results in complete fund destruction - if not outputs: + # Without this check, outputs=[] bypasses conservation law: + # output_total=0, fee=0 → (0+0) > input_total → False (bypassed) + # Result: inputs spent, no outputs created → funds destroyed + if not outputs and tx_type not in MINTING_TX_TYPES: conn.execute("ROLLBACK") return False @@ -668,6 +669,12 @@ def mempool_add(self, tx: dict) -> bool: conn.execute("ROLLBACK") return False + # MEDIUM FIX: Reject empty outputs to prevent DoS + outputs = tx.get('outputs', []) + if not outputs and tx_type not in MINTING_TX_TYPES: + conn.execute("ROLLBACK") + return False + input_total = 0 for inp in inputs: row = conn.execute(