Summary

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.  

Intro to Lending

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.

Borrowing

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.

Liquidations

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.

Maintaining State

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.

Vulnerability Analysis

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.

Lending Pools

Port Finance’s lending pools (Reserves) are described with a number of parameters, including ReserveConfig:

/// 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,
}
/// 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

User positions are described with Obligation structs which store data about all their borrows and collateral deposits.

/// 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.

Withdrawing Collateral

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.

/// 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.

Liquidating Collateral

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.

The Exploit

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. 

Vulnerability Fix

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.

Port Finance Bugfix Review 
Rob Behnke
06.29.2022