Security audit of Parallel by Trail of Bits

Findings:

title: Vulnerable dependencies in the Substrate parachain
reported_impact: Medium
vulnerability_class_audit: Patching
description: 1. Vulnerable dependencies in the Substrate parachain
Severity: Medium
Difficulty: High
Type: Patching
Finding ID: TOB-PLF-1
Target: parallel repository
Description
The Parallel Finance parachain node uses the following dependencies with known vulnerabilities. (All of the dependencies listed are inherited from the Substrate framework.)
DependencyVersionIDDescription
chrono 0.4.19 RUSTSEC-2020-0159 Potential segfault in localtime_r invocations
lru 0.6.6 RUSTSEC-2021-0130 Use after free in lru crate
time 0.1.44 RUSTSEC-2020-0071 Potential segfault in the time crate
net2 0.2.37 RUSTSEC-2020-0016 net2 crate has been deprecated; use socket2 instead
Other than chrono, all the dependencies can simply be updated to their newest versions to fix the vulnerabilities. The chrono crate issue has not been mitigated and remains problematic. A specific sequence of calls must occur to trigger the vulnerability, which is discussed in this GitHub thread in the chrono repository.
Exploit Scenario
An attacker exploits a known vulnerability in the Parallel Finance node and performs a denial-of-service attack on the network by taking down all nodes in the network.
Recommendations
Short term, update all dependencies to their newest versions. Monitor the referenced GitHub thread regarding the chrono crate segfault issue.
Long term, run cargo-audit as part of the CI/CD pipeline and ensure that the team is alerted to any vulnerable dependencies that are detected.

title: Users can avoid accruing interest by repaying a zero amount
reported_impact: Medium
vulnerability_class_audit: Data Validation
description: 2. Users can avoid accruing interest by repaying a zero amount
Severity: Medium
Difficulty: Low
Type: Data Validation
Finding ID: TOB-PLF-2
Target: pallets/loans/src/lib.rs
Description
To repay borrowed funds, users call the repay_borrow extrinsic. The extrinsic implementation calls the Pallet::repay_borrow_internal method to recompute the loan balance. Pallet::repay_borrow_internal updates the loan balance for the account and resets the borrow index as part of the calculation.
fn repay_borrow_internal(
borrower: &T::AccountId,
asset_id: AssetIdOf<T>,
account_borrows: BalanceOf<T>,
repay_amount: BalanceOf<T>,
) -> DispatchResult {
// ... <redacted>
AccountBorrows::<T>::insert(
asset_id,
borrower,
BorrowSnapshot {
principal: account_borrows_new,
borrow_index: Self::borrow_index(asset_id),
},
);
TotalBorrows::<T>::insert(asset_id, total_borrows_new);
Ok(())
}
Figure 2.1: pallets/loans/src/lib.rs:1057-1087
The borrow index is used in the calculation of the accumulated interest for the loan in Pallet::current_balance_from_snapshot. Specifically, the outstanding balance, snapshot.principal, is multiplied by the quotient of borrow_index divided by snapshot.borrow_index.
pub fn current_balance_from_snapshot(
asset_id: AssetIdOf<T>,
snapshot: BorrowSnapshot<BalanceOf<T>>,
) -> Result<BalanceOf<T>, DispatchError> {
if snapshot.principal.is_zero() || snapshot.borrow_index.is_zero() {
return Ok(Zero::zero());
}
// Calculate new borrow balance using the interest index:
// recent_borrow_balance = snapshot.principal * borrow_index /
// snapshot.borrow_index
let recent_borrow_balance = Self::borrow_index(asset_id)
.checked_div(&snapshot.borrow_index)
.and_then(|r| r.checked_mul_int(snapshot.principal))
.ok_or(ArithmeticError::Overflow)?;
Ok(recent_borrow_balance)
}
Figure 2.2: pallets/loans/src/lib.rs:1106-1121
Therefore, if the snapshot borrow index is updated to Self::borrow_index(asset_id), the resulting recent_borrow_balance in Pallet::current_balance_from_snapshot will always be equal to snapshot.principal. That is, no interest will be applied to the loan. It follows that the accrued interest is lost whenever part of the loan is repaid. In an extreme case, if the repaid amount passed to repay_borrow is 0, users could reset the borrow index without repaying anything.
The same issue is present in the implementations of the liquidated_transfer and borrow extrinsics as well.
Exploit Scenario
A malicious user borrows assets from Parallel Finance and calls repay_borrow with a repay_amount of zero. This allows her to avoid paying interest on the loan.
Recommendations
Short term, modify the code so that the accrued interest is added to the snapshot principal when the snapshot is updated.
Long term, add unit tests for edge cases (like repaying a zero amount) to increase the chances of discovering unexpected system behavior.

title: Missing validation in Pallet::force_update_market
reported_impact: Informational
vulnerability_class_audit: Data Validation
description: 3. Missing validation in Pallet::force_update_market
Severity: Informational
Difficulty: High
Type: Data Validation
Finding ID: TOB-PLF-3
Target: pallets/loans/src/lib.rs
Description
The Pallet::force_update_market method can be used to replace the stored market instance for a given asset. Other methods used to update market parameters perform extensive validation of the market parameters, but force_update_market checks only the rate model.
pub fn force_update_market(
origin: OriginFor<T>,
asset_id: AssetIdOf<T>,
market: Market<BalanceOf<T>>,
) -> DispatchResultWithPostInfo {
T::UpdateOrigin::ensure_origin(origin)?;
ensure!(
market.rate_model.check_model(),
Error::<T>::InvalidRateModelParam
);
let updated_market = Self::mutate_market(asset_id, |stored_market| {
*stored_market = market;
stored_market.clone()
})?;
Self::deposit_event(Event::<T>::UpdatedMarket(updated_market));
Ok(().into())
}
Figure 3.1: pallets/loans/src/lib.rs:539-556
This means that the caller (who is either the root account or half of the general council) could inadvertently change immutable market parameters like ptoken_id by mistake.
Exploit Scenario
The root account calls force_update_market to update a set of market parameters. By mistake, the ptoken_id market parameter is updated, which means that Pallet::ptoken_id and Pallet::underlying_id are no longer inverses.
Recommendations
Short term, consider adding more input validation to the force_update_market extrinsic. In particular, it may make sense to ensure that the ptoken_id market parameter has not changed. Alternatively, add validation to check whether the ptoken_id market parameter is updated and to update the UnderlyingAssetId map to ensure that the value matches the Markets storage map.

title: Missing validation in multiple StakingLedger methods
reported_impact: Undetermined
vulnerability_class_audit: Data Validation
description: 4. Missing validation in multiple StakingLedger methods
Severity: Undetermined
Difficulty: High
Type: Data Validation
Finding ID: TOB-PLF-4
Target: pallets/liquid-staking/src/types.rs
Description
The staking ledger is used to keep track of the total amount of staked funds in the system. It is updated in response to cross-consensus messaging (XCM) requests to the parent chain (either Polkadot or Kusama). A number of the StakingLedger methods lack sufficient input validation before they update the staking ledger’s internal state. Even though the input is validated as part of the original XCM call, there could still be issues due to implementation errors or overlooked corner cases.
First, the StakingLedger::rebond method does not use checked arithmetic to update the active balance. The method should also check that the computed unlocking_balance is equal to the input value at the end of the loop to ensure that the system remains consistent.
pub fn rebond(&mut self, value: Balance) {
let mut unlocking_balance: Balance = Zero::zero();
while let Some(last) = self.unlocking.last_mut() {
if unlocking_balance + last.value <= value {
unlocking_balance += last.value;
self.active += last.value;
self.unlocking.pop();
} else {
let diff = value - unlocking_balance;
unlocking_balance += diff;
self.active += diff;
last.value -= diff;
}
if unlocking_balance >= value {
break;
}
}
}
Figure 4.1: pallets/liquid-staking/src/types.rs:199-219
Second, the StakingLedger::bond_extra method does not use checked arithmetic to update the total and active balances.
pub fn bond_extra(&mut self, value: Balance) {
self.total += value;
self.active += value;
}
Figure 4.2: pallets/liquid-staking/src/types.rs:223-226
Finally, the StakingLedger::unbond method does not use checked arithmetic when updating the active balance.
pub fn unbond(&mut self, value: Balance, target_era: EraIndex) {
if let Some(mut chunk) = self
.unlocking
.last_mut()
.filter(|chunk| chunk.era == target_era)
{
chunk.value = chunk.value.saturating_add(value);
} else {
self.unlocking.push(UnlockChunk {
value,
era: target_era,
});
};
self.active -= value;
}
Figure 4.3: pallets/liquid-staking/src/types.rs:230-253
Since the staking ledger is updated by a number of the XCM response handlers, and XCM responses may return out of order, it is important to ensure that input to the staking ledger methods is validated to prevent issues due to race conditions and corner cases.
We could not find a way to exploit this issue, but we cannot rule out the risk that it could be used to cause a denial-of-service condition in the system.
Exploit Scenario
The staking ledger's state is updated as part of a WithdrawUnbonded request, leaving the unlocking vector in the staking ledger empty. Later, when the response to a previous call to rebond is handled, the ledger is updated again, which leaves it in an inconsistent state.
Recommendations
Short term, ensure that the balance represented by the staking ledger’s unlocking vector is enough to cover the input balance passed to StakingLedger::rebond. Use checked arithmetic in all staking ledger methods that update the ledger’s internal state to ensure that issues due to data races are detected and handled correctly.

title: Failed XCM requests left in storage
reported_impact: Low
vulnerability_class_audit: Data Validation
description: 5. Failed XCM requests left in storage
Severity: Low
Difficulty: High
Type: Data Validation
Finding ID: TOB-PLF-5
Target: pallets/liquid-staking/src/lib.rs
Description
When the liquid-staking pallet generates an XCM request for the parent chain, the corresponding XCM response triggers a call to Pallet::notification_received. If the response is of the Response::ExecutionResult type, this method calls Pallet::do_notification_received to handle the result.
The Pallet::do_notification_received method checks whether the request was successful and then updates the local state according to the corresponding XCM request, which is obtained from the XcmRequests storage map.
fn do_notification_received(
query_id: QueryId,
request: XcmRequest<T>,
res: Option<(u32, XcmError)>,
) -> DispatchResult {
use ArithmeticKind::*;
use XcmRequest::*;
let executed = res.is_none();
if !executed {
return Ok(());
}
match request {
Bond {
index: derivative_index,
amount,
} => {
ensure!(
!StakingLedgers::<T>::contains_key(&derivative_index),
Error::<T>::AlreadyBonded
);
let staking_ledger =
<StakingLedger<T::AccountId, BalanceOf<T>>>::new(
Self::derivative_sovereign_account_id(derivative_index),
amount,
);
StakingLedgers::<T>::insert(derivative_index, staking_ledger);
MatchingPool::<T>::try_mutate(|p| -> DispatchResult {
p.update_total_stake_amount(amount, Subtraction)
})?;
T::Assets::burn_from(
Self::staking_currency()?,
&Self::account_id(),
Amount
)?;
}
// ... <redacted>
}
XcmRequests::<T>::remove(&query_id);
Ok(())
}
Figure 5.1: pallets/liquid-staking/src/lib.rs:1071-1159
If the method completes without errors, the XCM request is removed from storage via a call to XcmRequests<T>::remove(query_id). However, if any of the following conditions are true, the corresponding XCM request is left in storage indefinitely:
1. The request fails and Pallet::do_notification_received exits early.
2. Pallet::do_notification_received fails.
3. The response type is not Response::ExecutionResult.
These three cases are currently unhandled by the codebase. The same issue is present in the crowdloans pallet implementation of Pallet::do_notification_received.
Recommendations
Short term, ensure that failed XCM requests are handled correctly by the crowdloans and liquid-staking pallets.

title: Risk of using stale oracle prices in loans pallet
reported_impact: Low
vulnerability_class_audit: Data Validation
description: 6. Risk of using stale oracle prices in loans pallet
Severity: Low
Difficulty: High
Type: Data Validation
Finding ID: TOB-PLF-6
Target: pallets/loans/src/lib.rs
Description
The loans pallet uses oracle prices to find a USD value of assets using the get_price function. The get_price function internally uses the T::PriceFeeder::get_price function, which returns a timestamp and the price. However, the returned timestamp is ignored.
pub fn get_price(asset_id: AssetIdOf<T>) -> Result<Price, DispatchError> {
let (price, _) = T::PriceFeeder::get_price(&asset_id).ok_or(Error::<T>::PriceOracleNotReady)?;
if price.is_zero() {
return Err(Error::<T>::PriceIsZero.into());
}
log::trace!(target: "loans::get_price", "price: {:?}", price.into_inner());
Ok(price)
}
Figure 6.1: pallets/loans/src/lib.rs:1430-1441
Exploit Scenario
The price feeding oracles fail to deliver prices for an extended period of time. The get_price function returns stale prices, causing the get_asset_value function to return a non-market asset value.
Recommendations
Short term, modify the code so that it compares the returned timestamp from the T::PriceFeeder::get_price function with the current timestamp, returns an error if the price is too old, and handles the emergency price, which currently has a timestamp of zero. This will stop the market if stale prices are returned and allow the governance process to intervene with an emergency price.

title: Missing calculations in crowdloans extrinsics
reported_impact: Undetermined
vulnerability_class_audit: Undefined Behavior
description: 7. Missing calculations in crowdloans extrinsics
Severity: Undetermined
Difficulty: High
Type: Undefined Behavior
Finding ID: TOB-PLF-7
Target: pallets/crowdloans/src/lib.rs
Description
The claim extrinsic in the crowdloans pallet is missing code to subtract the claimed amount from vault.contributed to update the total contribution amount. A similar bug exists in the refund extrinsic: there is no subtraction from vault.contributed after the Self::contribution_kill call.
pub fn claim(
origin: OriginFor<T>,
crowdloan: ParaId,
lease_start: LeasePeriod,
lease_end: LeasePeriod,
) -> DispatchResult {
// ... <redacted>
Self::contribution_kill(
vault.trie_index,
&who,
ChildStorageKind::Contributed
);
Self::deposit_event(Event::<T>::VaultClaimed(
crowdloan,
(lease_start, lease_end),
ctoken,
who,
amount,
VaultPhase::Succeeded,
));
Ok(())
}
Figure 7.1: pallets/crowdloans/src/lib.rs:718-765
Exploit Scenario
The claim extrinsic is called, but the total amount in vault.contributed is not updated, leading to incorrect calculations in other places.
Recommendations
Short term, update the claim and refund extrinsics so that they subtract the amount from vault.contributed.
Long term, add a test suite to ensure that the vault state stays consistent after the claim and refund extrinsics are called.

title: Event emitted when update_vault and set_vrf calls do not make updates
reported_impact: Informational
vulnerability_class_audit: Auditing and Logging
description: 8. Event emitted when update_vault and set_vrf calls do not make updates
Severity: Informational
Difficulty: High
Type: Auditing and Logging
Finding ID: TOB-PLF-8
Target: pallets/crowdloans/src/lib.rs
Description
The update_vault extrinsic in the crowdloans pallet is responsible for updating the three values shown. It is possible to call update_vault in such a way that no update is performed, but the function emits an event regardless of whether an update occurred. The same situation occurs in the set_vrfs extrinsic.
pub fn update_vault(
origin: OriginFor<T>,
crowdloan: ParaId,
cap: Option<BalanceOf<T>>,
end_block: Option<BlockNumberFor<T>>,
contribution_strategy: Option<ContributionStrategy>,
) -> DispatchResult {
T::UpdateVaultOrigin::ensure_origin(origin)?;
let mut vault = Self::current_vault(crowdloan).ok_or(Error::<T>::VaultDoesNotExist)?;
if let Some(cap) = cap {
// ... <redacted>
}
if let Some(end_block) = end_block {
// ... <redacted>
}
if let Some(contribution_strategy) = contribution_strategy {
// ... <redacted>
}
Self::deposit_event(Event::<T>::VaultUpdated(crowdloan,(lease_start, lease_end),contribution_strategy,cap,end_block));
Ok(())
}
Figure 8.1: pallets/crowdloans/src/lib.rs:424-472
pub fn set_vrfs(origin: OriginFor<T>, vrfs: Vec<ParaId>) -> DispatchResult {
T::VrfOrigin::ensure_origin(origin)?;
log::trace!(target: "crowdloans::set_vrfs", "pre-toggle. vrfs: {:?}",vrfs);
Vrfs::<T>::try_mutate(|b| -> Result<(), DispatchError> {
*b = vrfs.try_into().map_err(|_| Error::<T>::MaxVrfsExceeded)?;
Ok(())
})?;
Self::deposit_event(Event::<T>::VrfsUpdated(Self::vrfs()));
Ok(())
}
Figure 8.2: pallets/crowdloans/src/lib.rs:599-616
Exploit Scenario
A system observes that the VaultUpdate event was emitted even though the vault state did not actually change. Based on this observation, it performs logic that should be executed only when the state has been updated.
Recommendations
Short term, modify the VaultUpdate event so that it is emitted only when the update_vault extrinsic makes an actual update. Optionally, have the update_vault extrinsic return an error to the caller when calling it results in no updates.

title: The referral code is a sequence of arbitrary bytes
reported_impact: Informational
vulnerability_class_audit: Data Validation
description: 9. The referral code is a sequence of arbitrary bytes
Severity: Informational
Difficulty: High
Type: Data Validation
Finding ID: TOB-PLF-9
Target: pallets/crowdloans/src/lib.rs
Description
The referral code is used in a number of extrinsic calls in the crowdloans pallet. Because the referral code is never validated, it can be a sequence of arbitrary bytes. The referral code is logged by a number of extrinsics. However, it is currently impossible to perform log injection because the referral code is printed as a hexadecimal string rather than raw bytes (using the debug representation).
pub fn contribute(
origin: OriginFor<T>,
crowdloan: ParaId,
#[pallet::compact] amount: BalanceOf<T>,
referral_code: Vec<u8>,
) -> DispatchResultWithPostInfo {
// ... <redacted>
log::trace!(
target: "crowdloans::contribute",
"who: {:?}, para_id: {:?}, amount: {:?}, referral_code: {:?}",
&who,
&crowdloan,
&amount,
&referral_code
);
Ok(().into())
}
Figure 9.1: pallets/crowdloans/src/lib.rs:502-594
Exploit Scenario
The referral code is rendered as raw bytes in a vulnerable environment, introducing an opportunity to perform a log injection attack.
Recommendations
Short term, choose and implement a data type that models the referral code semantics as closely as possible.

title: Missing validation of referral code size
reported_impact: Low
vulnerability_class_audit: Data Validation
description: 10. Missing validation of referral code size
Severity: Low
Difficulty: Low
Type: Data Validation
Finding ID: TOB-PLF-10
Target: pallets/crowdloans/src/lib.rs
Description
The length of the referral code is not validated by the contribute extrinsic defined by the crowdloans pallet. Since the referral code is stored by the node, a malicious user could call contribute multiple times with a very large referral code. This would increase the memory pressure on the node, potentially leading to memory exhaustion.
fn do_contribute(
who: &AccountIdOf<T>,
crowdloan: ParaId,
vault_id: VaultId,
amount: BalanceOf<T>,
referral_code: Vec<u8>,
) -> Result<(), DispatchError> {
XcmRequests::<T>::insert(
query_id,
XcmRequest::Contribute {
crowdloan,
vault_id,
who: who.clone(),
amount,
referral_code: referral_code.clone(),
},
);
Ok(())
}
Figure 10.1: pallets/crowdloans/src/lib.rs:1429-1464
Exploit Scenario
A malicious user calls the contribute extrinsic multiple times with a very large referral code. This increases the memory pressure on the validator nodes and eventually causes all parachain nodes to run out of memory and crash.
Recommendations
Short term, add validation that limits the size of the referral code argument to the contribute extrinsic.

title: Code duplication in crowdloans pallet
reported_impact: Informational
vulnerability_class_audit: Patching
description: 11. Code duplication in crowdloans pallet
Severity: Informational
Difficulty: High
Type: Patching
Finding ID: TOB-PLF-11
Target: pallets/crowdloans/src/lib.rs
Description
A number of extrinsics in the crowdloans pallet have duplicate code. The close, reopen, and auction_succeeded extrinsics have virtually identical logic. The migrate_pending and refund extrinsics are also fairly similar.
Exploit Scenario
A vulnerability is found in the duplicate code, but it is patched in only one place.
Recommendations
Short term, refactor the close, reopen, and auction_succeeded extrinsics into one function, to be called with values specific to the extrinsics. Refactor common pieces of logic in the migrate_pending and refund extrinsics.
Long term, avoid code duplication, as it makes the system harder to review and update. Perform regular code reviews and track any logic that is duplicated.