Skip to content

Commit 4d01946

Browse files
committed
Fix underflow panic in vesting and HTLC insufficient funds error path
When a vesting or HTLC contract had total_amount > balance (reachable via the 32-byte creation format), the InsufficientFunds error construction computed self.balance - min_cap which panics on underflow. In the block processing path this poisons the blockchain RwLock, making the node permanently unusable. Use saturating_sub for the error field arithmetic and add validation that total_amount <= tx_value during contract creation.
1 parent 9199364 commit 4d01946

5 files changed

Lines changed: 193 additions & 9 deletions

File tree

primitives/account/src/account/htlc_contract.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ impl HashedTimeLockedContract {
7979

8080
if new_balance < min_cap {
8181
return Err(AccountError::InsufficientFunds {
82-
balance: self.balance - min_cap,
83-
needed: self.balance - new_balance,
82+
balance: self.balance.saturating_sub(min_cap),
83+
needed: self.balance.saturating_sub(new_balance),
8484
});
8585
}
8686

primitives/account/src/account/vesting_contract.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ impl VestingContract {
5151

5252
if new_balance < min_cap {
5353
return Err(AccountError::InsufficientFunds {
54-
balance: self.balance - min_cap,
55-
needed: self.balance - new_balance,
54+
balance: self.balance.saturating_sub(min_cap),
55+
needed: self.balance.saturating_sub(new_balance),
5656
});
5757
}
5858

primitives/account/tests/vesting_contract.rs

Lines changed: 182 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,14 @@ fn it_can_create_contract_from_transaction() {
200200
assert_eq!(contract.step_amount, 50.try_into().unwrap());
201201
assert_eq!(contract.total_amount, 100.try_into().unwrap());
202202

203-
// Transaction 3
203+
// Transaction 3: valid 32-byte format (total_amount <= tx_value)
204204
let mut data: Vec<u8> = Vec::with_capacity(Address::SIZE + 32);
205205
let owner = Address::from([0u8; 20]);
206206
Serialize::serialize_to_writer(&owner, &mut data);
207207
Serialize::serialize_to_writer(&0u64.to_be_bytes(), &mut data);
208208
Serialize::serialize_to_writer(&100u64.to_be_bytes(), &mut data);
209209
Serialize::serialize_to_writer(&Coin::try_from(50).unwrap(), &mut data);
210-
Serialize::serialize_to_writer(&Coin::try_from(150).unwrap(), &mut data);
210+
Serialize::serialize_to_writer(&Coin::try_from(80).unwrap(), &mut data);
211211
tx.recipient_data = data;
212212
tx.recipient = tx.contract_creation_address();
213213

@@ -232,9 +232,59 @@ fn it_can_create_contract_from_transaction() {
232232
assert_eq!(contract.start_time, 0);
233233
assert_eq!(contract.time_step, 100);
234234
assert_eq!(contract.step_amount, 50.try_into().unwrap());
235-
assert_eq!(contract.total_amount, 150.try_into().unwrap());
235+
assert_eq!(contract.total_amount, 80.try_into().unwrap());
236+
237+
// Transaction 4: 32-byte format with total_amount > tx_value (rejected)
238+
let mut data: Vec<u8> = Vec::with_capacity(Address::SIZE + 32);
239+
Serialize::serialize_to_writer(&owner, &mut data);
240+
Serialize::serialize_to_writer(&0u64.to_be_bytes(), &mut data);
241+
Serialize::serialize_to_writer(&100u64.to_be_bytes(), &mut data);
242+
Serialize::serialize_to_writer(&Coin::try_from(50).unwrap(), &mut data);
243+
Serialize::serialize_to_writer(&Coin::try_from(150).unwrap(), &mut data);
244+
tx.recipient_data = data;
245+
tx.recipient = tx.contract_creation_address();
246+
247+
let mut tx_logger = TransactionLog::empty();
248+
let result = accounts.test_create_new_contract::<VestingContract>(
249+
&tx,
250+
Coin::ZERO,
251+
&block_state,
252+
&mut tx_logger,
253+
true,
254+
);
255+
assert_eq!(
256+
result,
257+
Err(AccountError::InvalidTransaction(
258+
TransactionError::InvalidData
259+
))
260+
);
261+
262+
// Transaction 5: 32-byte format with step_amount > total_amount (rejected)
263+
let mut data: Vec<u8> = Vec::with_capacity(Address::SIZE + 32);
264+
Serialize::serialize_to_writer(&owner, &mut data);
265+
Serialize::serialize_to_writer(&0u64.to_be_bytes(), &mut data);
266+
Serialize::serialize_to_writer(&100u64.to_be_bytes(), &mut data);
267+
Serialize::serialize_to_writer(&Coin::try_from(80).unwrap(), &mut data);
268+
Serialize::serialize_to_writer(&Coin::try_from(50).unwrap(), &mut data);
269+
tx.recipient_data = data;
270+
tx.recipient = tx.contract_creation_address();
236271

237-
// Transaction 4: invalid data
272+
let mut tx_logger = TransactionLog::empty();
273+
let result = accounts.test_create_new_contract::<VestingContract>(
274+
&tx,
275+
Coin::ZERO,
276+
&block_state,
277+
&mut tx_logger,
278+
true,
279+
);
280+
assert_eq!(
281+
result,
282+
Err(AccountError::InvalidTransaction(
283+
TransactionError::InvalidData
284+
))
285+
);
286+
287+
// Transaction 6: invalid data length
238288
tx.recipient_data = Vec::with_capacity(Address::SIZE + 2);
239289
Serialize::serialize_to_writer(&owner, &mut tx.recipient_data);
240290
Serialize::serialize_to_writer(&0u16.to_be_bytes(), &mut tx.recipient_data);
@@ -481,6 +531,134 @@ fn reserve_release_balance_works() {
481531
assert!(result.is_ok());
482532
}
483533

534+
#[test]
535+
fn total_amount_exceeds_balance_panics_on_reserve_balance() {
536+
// Attack scenario: attacker uses the 32-byte vesting creation format to set
537+
// total_amount > transaction.value. This creates a contract where min_cap() can
538+
// exceed self.balance. When an outgoing transaction triggers can_change_balance(),
539+
// the error path computes `self.balance - min_cap` which panics on underflow.
540+
//
541+
// In the mempool path (reserve_balance), this runs inside a tokio::task::spawn,
542+
// so the panic kills only the verification task — not the whole node process.
543+
// However, in the block processing path (commit_outgoing_transaction), the panic
544+
// unwinds through Blockchain::push which holds an RwLockUpgradableReadGuard.
545+
// The poisoned lock makes the blockchain permanently unusable, effectively
546+
// crashing the node.
547+
548+
let mut rng = test_rng(true);
549+
let key_owner = KeyPair::generate(&mut rng);
550+
let key_recipient = KeyPair::generate(&mut rng);
551+
552+
// Create a vesting contract where total_amount (2000) > balance (1000).
553+
// This is reachable via the 32-byte CreationTransactionData format which
554+
// deserializes total_amount from attacker-controlled recipient_data without
555+
// validating total_amount <= tx_value.
556+
let vesting_contract = VestingContract {
557+
balance: Coin::from_u64_unchecked(1000),
558+
owner: Address::from(&key_owner.public),
559+
start_time: 0,
560+
time_step: u64::MAX, // Ensures 0 steps elapsed, so min_cap = total_amount
561+
step_amount: Coin::from_u64_unchecked(1),
562+
total_amount: Coin::from_u64_unchecked(2000), // > balance!
563+
};
564+
565+
let accounts = TestCommitRevert::with_initial_state(&[
566+
(
567+
Address::from(&key_owner.public),
568+
Account::Basic(BasicAccount {
569+
balance: Coin::from_u64_unchecked(1000),
570+
}),
571+
),
572+
(
573+
Address([1u8; 20]),
574+
Account::Vesting(vesting_contract.clone()),
575+
),
576+
]);
577+
578+
let mut db_txn = accounts.env().write_transaction();
579+
let sender_address = Address::from(&key_owner);
580+
let data_store = accounts.data_store(&sender_address);
581+
582+
// Block time 0 with time_step=MAX means 0 steps have elapsed, so
583+
// min_cap = total_amount = 2000 > balance = 1000.
584+
let block_state = BlockState::new(1, 0);
585+
586+
let mut reserved_balance = ReservedBalance::new(sender_address.clone());
587+
588+
// Outgoing transaction for 1 luna — the amount doesn't matter,
589+
// any outgoing tx triggers the panic because min_cap > balance.
590+
let tx = make_signed_transaction(key_owner.clone(), key_recipient.clone(), 1);
591+
592+
// This panics in can_change_balance at:
593+
// balance: self.balance - min_cap → 1000 - 2000 → underflow panic
594+
//
595+
// After the fix, this should return Err(AccountError::InsufficientFunds)
596+
// without panicking.
597+
let result = vesting_contract.reserve_balance(
598+
&tx,
599+
&mut reserved_balance,
600+
&block_state,
601+
data_store.read(&mut db_txn),
602+
);
603+
assert!(
604+
result.is_err(),
605+
"Expected InsufficientFunds error, got: {:?}",
606+
result
607+
);
608+
}
609+
610+
#[test]
611+
fn total_amount_exceeds_balance_panics_on_commit_outgoing() {
612+
// Same root cause as above, but triggered via commit_outgoing_transaction —
613+
// the block processing path. This is the more dangerous path because it runs
614+
// while holding the blockchain RwLock, poisoning it on panic.
615+
616+
let mut rng = test_rng(true);
617+
let key_owner = KeyPair::generate(&mut rng);
618+
let key_recipient = KeyPair::generate(&mut rng);
619+
620+
let mut vesting_contract = VestingContract {
621+
balance: Coin::from_u64_unchecked(1000),
622+
owner: Address::from(&key_owner.public),
623+
start_time: 0,
624+
time_step: u64::MAX,
625+
step_amount: Coin::from_u64_unchecked(1),
626+
total_amount: Coin::from_u64_unchecked(2000),
627+
};
628+
629+
let accounts = TestCommitRevert::with_initial_state(&[
630+
(
631+
Address::from(&key_owner.public),
632+
Account::Basic(BasicAccount {
633+
balance: Coin::from_u64_unchecked(1000),
634+
}),
635+
),
636+
(
637+
Address([1u8; 20]),
638+
Account::Vesting(vesting_contract.clone()),
639+
),
640+
]);
641+
642+
let block_state = BlockState::new(1, 0);
643+
let tx = make_signed_transaction(key_owner.clone(), key_recipient.clone(), 1);
644+
645+
// This panics in can_change_balance during commit_outgoing_transaction.
646+
// After the fix, this should return Err(AccountError::InsufficientFunds).
647+
let mut tx_logger = TransactionLog::empty();
648+
let result = accounts.test_commit_outgoing_transaction(
649+
&mut vesting_contract,
650+
&tx,
651+
&block_state,
652+
&mut tx_logger,
653+
true,
654+
);
655+
assert!(
656+
result.is_err(),
657+
"Expected InsufficientFunds error, got: {:?}",
658+
result
659+
);
660+
}
661+
484662
#[test]
485663
fn can_reserve_balance_after_time_step() {
486664
// -----------------------------------

primitives/transaction/src/account/vesting_contract.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ impl CreationTransactionData {
159159
step_amount,
160160
total_amount,
161161
} = CreationTransactionData32::deserialize_all(data)?;
162+
if total_amount > tx_value {
163+
return Err(TransactionError::InvalidData);
164+
}
165+
if step_amount > total_amount {
166+
return Err(TransactionError::InvalidData);
167+
}
162168
CreationTransactionData {
163169
owner,
164170
start_time,

primitives/transaction/tests/vesting_contract_verify.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ fn it_can_verify_creation_transaction() {
107107
transaction.recipient = transaction.contract_creation_address();
108108
assert_eq!(
109109
AccountType::verify_incoming_transaction(&transaction),
110-
Ok(())
110+
Err(TransactionError::InvalidData)
111111
);
112112
}
113113

0 commit comments

Comments
 (0)