Halborn Logo

// Blog

Blockchain Security

Port Finance Bugfix Review


profile

Rob Behnke

June 29th, 2022


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 (

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

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
.

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
.

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.

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.