Skip to content

Commit af816e6

Browse files
committed
[UTXO-BUG] CRIT-1: Conservation law bypass via negative/zero-value outputs
apply_transaction() never validates that output value_nrtc is positive. A negative-value output reduces output_total, allowing an attacker to create outputs exceeding input_total while the conservation check passes. Attack: 100 RTC input -> [+200 RTC, -100 RTC] outputs output_total = 200 + (-100) = 100 <= input_total = 100 -> PASSES Attacker now has 200 RTC from a 100 RTC input. Fix: validate every output has integer value_nrtc > 0 before the conservation check. Also rejects zero-value dust and float types. Tests added: - test_negative_value_output_rejected - test_zero_value_output_rejected - test_float_value_nrtc_rejected Bounty: #2819 (Critical, 200 RTC)
1 parent 68c75aa commit af816e6

2 files changed

Lines changed: 72 additions & 0 deletions

File tree

node/test_utxo_db.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,69 @@ def test_mempool_empty_inputs_rejected_for_transfer(self):
355355
ok = self.db.mempool_add(tx)
356356
self.assertFalse(ok)
357357

358+
# -- bounty #2819: negative / zero value outputs -------------------------
359+
360+
def test_negative_value_output_rejected(self):
361+
"""Negative value_nrtc on an output bypasses conservation law.
362+
363+
Attack: 100 RTC input → [+200 RTC, -100 RTC] outputs.
364+
output_total = 200 + (-100) = 100 <= input_total = 100, PASSES.
365+
Attacker mints 100 RTC from nothing.
366+
"""
367+
self._apply_coinbase('alice', 100 * UNIT)
368+
boxes = self.db.get_unspent_for_address('alice')
369+
370+
ok = self.db.apply_transaction({
371+
'tx_type': 'transfer',
372+
'inputs': [{'box_id': boxes[0]['box_id'],
373+
'spending_proof': 'sig'}],
374+
'outputs': [
375+
{'address': 'attacker', 'value_nrtc': 200 * UNIT},
376+
{'address': 'burn', 'value_nrtc': -100 * UNIT},
377+
],
378+
'fee_nrtc': 0,
379+
}, block_height=10)
380+
381+
self.assertFalse(ok)
382+
# Balance must be unchanged
383+
self.assertEqual(self.db.get_balance('alice'), 100 * UNIT)
384+
self.assertEqual(self.db.get_balance('attacker'), 0)
385+
386+
def test_zero_value_output_rejected(self):
387+
"""Zero-value outputs are meaningless dust that bloats the UTXO set."""
388+
self._apply_coinbase('alice', 100 * UNIT)
389+
boxes = self.db.get_unspent_for_address('alice')
390+
391+
ok = self.db.apply_transaction({
392+
'tx_type': 'transfer',
393+
'inputs': [{'box_id': boxes[0]['box_id'],
394+
'spending_proof': 'sig'}],
395+
'outputs': [
396+
{'address': 'bob', 'value_nrtc': 100 * UNIT},
397+
{'address': 'dust', 'value_nrtc': 0},
398+
],
399+
'fee_nrtc': 0,
400+
}, block_height=10)
401+
402+
self.assertFalse(ok)
403+
404+
def test_float_value_nrtc_rejected(self):
405+
"""value_nrtc must be an integer; floats cause silent truncation."""
406+
self._apply_coinbase('alice', 100 * UNIT)
407+
boxes = self.db.get_unspent_for_address('alice')
408+
409+
ok = self.db.apply_transaction({
410+
'tx_type': 'transfer',
411+
'inputs': [{'box_id': boxes[0]['box_id'],
412+
'spending_proof': 'sig'}],
413+
'outputs': [
414+
{'address': 'bob', 'value_nrtc': 99.5 * UNIT},
415+
],
416+
'fee_nrtc': 0,
417+
}, block_height=10)
418+
419+
self.assertFalse(ok)
420+
358421

359422
class TestCoinSelect(unittest.TestCase):
360423

node/utxo_db.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,15 @@ def apply_transaction(self, tx: dict, block_height: int,
341341
return False
342342

343343
output_total = sum(o['value_nrtc'] for o in outputs)
344+
345+
# Every output must carry a strictly positive value.
346+
# Without this, a negative-value output lowers output_total,
347+
# letting an attacker create more value than the inputs hold.
348+
for o in outputs:
349+
if not isinstance(o['value_nrtc'], int) or o['value_nrtc'] <= 0:
350+
conn.execute("ROLLBACK")
351+
return False
352+
344353
if fee < 0:
345354
conn.execute("ROLLBACK")
346355
return False

0 commit comments

Comments
 (0)