Rob Behnke
June 29th, 2022
Whitehat nojob reported a critical vulnerability in Port Finance via Immunefi on March 29. If a malicious attacker had exploited the vulnerability, they could have stolen $20-25 million. But because of nojob’s responsible disclosure, no user funds were lost. Port Finance promptly patched the bug and paid the whitehat $180,000 USD and $450,000 in PORT tokens linearly vested over a year. This is the max payout for Port Finance’s bounty program.
Excellent work by nojob for the find and Port Finance for the fix and reward!
Immunefi facilitated this responsible disclosure with their platform. This bugfix review is written by Halborns Piotr, Practice Manager (Solana).
Now, on to an explanation of DeFi lending, Port Finance, and the vulnerability itself.
The capital market in the traditional financial system is not easily accessible to the general public – only the big players have the VIP card to access it.
Suppose you are an investor looking to finance your next business venture. One way of doing it is by taking a loan and offering your assets as collateral. The collateralized capital will essentially be frozen and can grow over time. It can also be redeemed at a later date. This is not a risk-free strategy, though. If you are not careful, you may default on your loan payment and lose your collateral.
Lending protocols in DeFi have now democratized access to debt for everyone. Using your digital assets as collateral, you can borrow from these protocols and leverage upon it. As one of the largest DeFi categories, decentralized lending and borrowing has been growing exponentially since late 2020.
The value of the assets you want to borrow is determined with a parameter called LTV (Loan To Value). Although multiple definitions exist, for the purposes of this example we can define it as a ratio of the value of your loan to the value of your collateral. Currently, most lending protocols offer overcollateralized loans only. For instance, if you borrow $60 worth of some cryptocurrency and you provide $100 as collateral, your LTV is $60/$100 = 0.6.
Liquidation is a safety mechanism which protects lenders from negative price volatility. In a nutshell, if for some reason the value of your collateral is insufficient, the liquidators may purchase your collateral at a discount.
Lending protocols usually set target LTVs for their lending pools, and as a borrower, your main risks involve losing your collateral through mismanagement of your position’s LTV.
Cryptocurrency is known for its extreme price volatility, and if your loan goes below the target LTV, you are at risk of having your collateral liquidated.
This is not an ideal outcome, as you will most likely take a considerable loss and pay a liquidation fee (penalty) on top of that. It is then vital to constantly monitor and maintain a healthy collateral ratio for your loans.
In the DeFi world, the ethos is that shouldn’t be any central entities managing platforms and protocols. Protocol management is often delegated to protocol communities instead. In general, blockchain applications favour the “push over pull” strategy. Rather than doing it in one heavy operation, updating the protocol state is split into several lighter operations that have to be executed in a particular order. This simplifies the application design and incentivizes users to participate in protocol management by rewarding them with tokens or extra perks.
Port Finance is a non-custodial money market protocol on Solana. Its goals are to bring a whole suite of interest rate products, including variable rate lending, fixed rate lending, and interest rate swap to the Solana blockchain.
Port Finance’s lending pools (
Reserve
s) are described with a number of parameters, including ReserveConfig
:“
rust/// Lending market reserve state
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Reserve {
/// Version of the struct
pub version: u8,
/// Last slot when supply and rates updated
pub last_update: LastUpdate,
/// Lending market address
pub lending_market: Pubkey,
/// Reserve liquidity
pub liquidity: ReserveLiquidity,
/// Reserve collateral
pub collateral: ReserveCollateral,
/// Reserve configuration values
pub config: ReserveConfig,
}
“
“
rust/// Reserve configuration values
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ReserveConfig {
/// Optimal utilization rate, as a percentage
pub optimal_utilization_rate: u8,
/// Target ratio of the value of borrows to deposits, as a percentage
/// 0 if use as collateral is disabled
pub loan_to_value_ratio: u8,
/// Bonus a liquidator gets when repaying part of an unhealthy obligation, as a percentage
pub liquidation_bonus: u8,
/// Loan to value ratio at which an obligation can be liquidated, as a percentage
pub liquidation_threshold: u8,
/// Min borrow APY
pub min_borrow_rate: u8,
/// Optimal (utilization) borrow APY
pub optimal_borrow_rate: u8,
/// Max borrow APY
pub max_borrow_rate: u8,
/// Program owner fees assessed, separate from gains due to interest accrual
pub fees: ReserveFees,
/// corresponded staking pool pubkey of deposit
pub deposit_staking_pool: COption<Pubkey>,
}
“
loan_to_value_ratio
determines the target LTV for the pool and liquidation_bonus
determines the discount at which liquidators can buy the liquidated collateral.Simply put, the value of collateral to buy at a discount is calculated as
collateral_to_be_liquidated * (1 + liquidation_bonus)
. The liquidator pays for collateral_to_be_liquidated
but they receive collateral_to_be_liquidated * (1 + liquidation_bonus)
.User positions are described with
Obligation
structs which store data about all their borrows and collateral deposits.“
rust/// Lending market obligation state
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Obligation {
/// Version of the struct
pub version: u8,
/// Last update to collateral, liquidity, or their market values
pub last_update: LastUpdate,
/// Lending market address
pub lending_market: Pubkey,
/// Owner authority which can borrow liquidity
pub owner: Pubkey,
/// Deposited collateral for the obligation, unique by deposit reserve address
pub deposits: Vec<ObligationCollateral>,
/// Borrowed liquidity for the obligation, unique by borrow reserve address
pub borrows: Vec<ObligationLiquidity>,
/// Market value of deposits
pub deposited_value: Decimal,
/// Market value of borrows
pub borrowed_value: Decimal,
/// The maximum borrow value at the weighted average loan to value ratio
pub allowed_borrow_value: Decimal,
/// The dangerous borrow value at the weighted average liquidation threshold
pub unhealthy_borrow_value: Decimal,
}
“
When a user interacts with their collateral, the
deposits
array is updated and when they interact with their borrows, the borrows
array is updated.allowed_borrow_value
denotes the maximum total market value of assets the user can borrow and it is calculated as the sum of the value of all user collaterals multiplied by the relevant pools’ LTVs.unhealthy_borrow_value
denotes the threshold market value of borrowed assets at which the user collateral is at risk of liquidation and is calculated as the sum of user collaterals multiplied by the relevant pools’ liquidation thresholds. unhealthy_borrow_value
should be greater than allowed_borrow_value
.Each user deposit is recorded in the user’s
Obligation
, updating or adding a relevant ObligationCollateral
struct. Users can withdraw their collateral, decreasing the allowed_borrow_value
and unhealthy_borrow_value
.The maximum market value of collateral that can be withdrawn from an
Obligation
was calculated with the Obligation::max_withdraw_value
.“
rust/// Calculate the maximum collateral value that can be withdrawn
pub fn max_withdraw_value(&self) -> Result<Decimal, ProgramError> {
let required_deposit_value = self
.borrowed_value
.try_mul(self.deposited_value)?
.try_div(self.allowed_borrow_value)?;
if required_deposit_value >= self.deposited_value {
return Ok(Decimal::zero());
}
self.deposited_value.try_sub(required_deposit_value)
}
“
The maximum value to be withdrawn was calculated as the delta of the total user deposit value, and the total user deposit value multiplied by the borrow to allowed borrow ratio.
If the
borrow_value
of an obligation is greater than the unhealthy_borrow_value
, the obligation is liquidatable. Liquidators can buy the obligation collateral at a discount defined by the collateral reserve’s liquidation_bonus
. A maximum of 50% of the total borrowed_value
can be withdrawn in one go. The result is scaled by the bonus_rate
for the collateral’s reserve and transferred to the liquidator’s account.Before Port Finance had patched the bug, a malicious user could have withdrawn all their obligation collaterals without paying off their full debt under the following assumptions:
– They find some reserve R1 with high liquidation
bonus_rate
and some reserve R2 with high LTV such that the sum of reserve R1 bonus_rate
and reserve R2 LTV is greater than 100%.– They create an obligation and deposit some token T1 as collateral to reserve R1.
– They deposit some token T2 as collateral to reserve R2. The value of the T2 deposit should be greater than the value of the T1 deposit by several orders of magnitude, e.g. 3.
– They borrow token T2 from the reserve with high LTV. The borrow value should be similar to the value of the T1 token deposit.
– They withdraw the entire T2 token deposit. This makes their obligation liquidatable because the T2 token borrow is heavily undercollateralized now.
– They liquidate their obligation. Because the T2 token borrow value is similar to the value of the T1 token deposit and the R1 reserve has high
bonus_rate
, the **obligation collateral is liquidated at a quicker pace than the borrow is being repaid, and the malicious user drains their T1 token deposit before they pay off their borrow in full**.A total of $20-25 million could have been stolen.
The
Obligation::max_withdraw_value
function was fixed by introducing the withdraw_collateral_ltv
parameter denoting the LTV of reserve corresponding to the withdrawn collateral and calculating the maximum withdraw value as the ratio of the delta of the allowed borrow value and the borrowed value, and the reserve LTV.