top of page

Diving deep into a critical protocol insolvency bug in Fringe.fi lending platform

Updated: Nov 3, 2022

Today we'll discuss a critical bug I reported to Fringe.Fi bug bounty program on 31/07. In the worst-case scenario, it could make the platform insolvent (negative balance), which is classified as critical severity. Unfortunately, since the program has a very low TVL and the critical reward is capped at 10% of economic damage, I was paid only a 2K bounty. It's comical because that is their advertised payout for LOW findings. So, if I would have reported the issue as HIGH severity, I probably would have received 7.5x.

Alright, enough with the bounty ranting, let's get to the technical details. But first, what is Fringe.fi ?


Intro


Fringe is a lending platform specializing in speculative tokens as collateral. They divide tokens to different tiers and assign appropriate LVRs (Loan-to-value ratio). Here's a part of the current supported tokens list:



As you can see, a nice variety of project tokens.


Their current operational platform is called PIT (Primary index token) and uses USDC as lending / borrowing credit. The contract supports these main operations:

  1. supply - send USDC to the PIT and earn lending APY

  2. redeem - get back supplied USDC + earned APY

  3. deposit - send project tokens to be used as collateral, per their LVR

  4. withdraw - retrieve project tokens used as collateral, ensuring health factor < 1 (no undercollaterization)

  5. borrow - get USDC using collateral power, paying borrow APY

  6. repay - send lent USDC back

  7. liquidate - liquidate undercollaterized borrower.

Okay, sounds simple enough, what could go wrong?


The Bug


Most of the lending logic is done via a pretty much plain-forked Compound V2, with the lending token being a cToken that always appreciates in value. However, PIT does need to know how busy each project token is as collateral for the borrow.


mapping(address => mapping(address => mapping(address => BorrowPosition))) public borrowPosition; // user address => project token address => lending token address => BorrowPosition

struct BorrowPosition {
    uint256  loanBody;   // [loanBody] = lendingToken
    uint256 accrual;   // [accrual] = lendingToken
}

When users take loans, this mapping pushes a new BorrowPosition, with loanBody being lent amount and accrual starting as zero. In every interaction, accrual is updated using this function:


function updateInterestInBorrowPositions(address account, address lendingToken) public {
    uint256 cumulativeLoanBody = 0;
    uint256 cumulativeTotalOutstanding = 0;
    for(uint256 i = 0; i < projectTokens.length; i++) {
        BorrowPosition memory _borrowPosition = borrowPosition[account][projectTokens[i]][lendingToken];
        cumulativeLoanBody += _borrowPosition.loanBody;
        cumulativeTotalOutstanding += _borrowPosition.loanBody + _borrowPosition.accrual;
    }
    if (cumulativeLoanBody == 0) {
        return;
    }
    uint256 currentBorrowBalance = lendingTokenInfo[lendingToken].bLendingToken.borrowBalanceCurrent(account);
    if (currentBorrowBalance >= cumulativeTotalOutstanding){
        uint256 estimatedAccrual = currentBorrowBalance - cumulativeTotalOutstanding;
        BorrowPosition storage _borrowPosition;
        for(uint i = 0; i < projectTokens.length; i++) {
            _borrowPosition = borrowPosition[account][projectTokens[i]][lendingToken];
            _borrowPosition.accrual += estimatedAccrual * _borrowPosition.loanBody / cumulativeLoanBody;
        }
    }
}

It's not important to understand it, the main takeaway is that the appreciation in bLendingToken's borrowBalance() , also known as borrow APY, is divided across the existing BorrowPositions, into the accrual field.


We've talked about health factor, which is total collateral amount / total borrowed amount, which must remain above 1 (overcollaterized). Its implementation is:


function healthFactor(address account, address projectToken, address lendingToken) public view returns (uint256 numerator, uint256 denominator) {
    numerator = pit(account, projectToken, lendingToken);
    uint8 lendingTokenDecimals = ERC20Upgradeable(lendingToken).decimals();
    denominator = totalOutstanding(account, projectToken, lendingToken) / (10 ** (lendingTokenDecimals - decimals()));
}

function totalOutstanding(address account, address projectToken, address lendingToken) public view returns (uint256) {
    BorrowPosition memory _borrowPosition = borrowPosition[account][projectToken][lendingToken];
    return _borrowPosition.loanBody + _borrowPosition.accrual;
}

All this seems pretty legit, so where's the issue? Well, I lied when stating that accrual is updated in every interaction. Time to take a look at withdraw():


function withdraw(address projectToken, address lendingToken, uint256 projectTokenAmount) public isProjectTokenListed(projectToken) isLendingTokenListed(lendingToken) nonReentrant {
    require(!projectTokenInfo[projectToken].isWithdrawPaused, "PIT: projectToken is paused");
    require(projectTokenAmount > 0, "PIT: projectTokenAmount==0");
    DepositPosition storage _depositPosition = depositPosition[msg.sender][projectToken][lendingToken];
    if (projectTokenAmount == type(uint256).max) {
        if (borrowPosition[msg.sender][projectToken][lendingToken].loanBody > 0) {
            updateInterestInBorrowPositions(msg.sender, lendingToken);
            // ..... bunch of non relevant stuff, calculate non-busy projectTokenAmount 
        } else {
            projectTokenAmount = _depositPosition.depositedProjectTokenAmount;
        }
    }
    require(projectTokenAmount <= _depositPosition.depositedProjectTokenAmount, "PIT: try to withdraw more than available");
    _depositPosition.depositedProjectTokenAmount -= projectTokenAmount;
    (uint256 healthFactorNumerator, uint256 healthFactorDenominator) = healthFactor(msg.sender, projectToken, lendingToken);
    if (healthFactorNumerator < healthFactorDenominator) {
        revert("PIT: withdrawable amount makes healthFactor<1");
    }
    totalDepositedProjectToken[projectToken] -= projectTokenAmount;
    ERC20Upgradeable(projectToken).safeTransfer(msg.sender, projectTokenAmount);
    emit Withdraw(msg.sender, projectToken, lendingToken, projectTokenAmount, msg.sender);
}

The key lines to look for are highlighted. Notice that updateInterestInBorrowPositions() is only called if parameter projectTokenAmount is UINT256_MAX, which means "withdraw as much as possible".

Shortly after, the depositPosition is decremented and healthFactor is checked to be over 1. If it checks out, the withdraw executes succesfully.


The issue is that borrowers may withdraw collateral using non-MAX amount, and leave undercollaterized position in the contract. The accrued variable is stale, so it's not counted against the borrower.


Here's a simple demonstration of a possible exploit:

  1. Attacker deposits Frax Share tokens worth 100$, which equates to 71$ lending power (Frax Share is currently the asset with highest LVR)

  2. Attacker borrows 20$ from the protocol.

  3. Assume current borrow APY = 16.8%, lender APY = 8.6% (see image below)

  4. Attacker lends the borrowed 20$ to the protocol.

  5. Fast forward 8 years (lol).

  6. The correct BorrowPosition should be: { uint256 loanBody; // $20 uint256 accrual; // 20 * 1.168^8 - $20 = $49.3 , from compound interest }

  7. accrual is actually still $0

  8. Attacker withdraws $71.8 in project tokens, leaving $28.2

  9. $28.2 x 0.71 LVR = $20.02 > $20, so health factor is positive.

  10. Note that attacker cannot be liquidated up to this stage because his health factor prior to the withdraw is positive.


In the end, attacker's balance is:

$71.8 Withdrawn tokens

$20 * 1.086^8 = $38.7 Borrowed tokens x lending APY

Total: $110.5 > $100 dollars


The remaining $28.2 in the contract is left for dead, and the APY from the $20 more than covers for it.


The fact attacker made a profit is not the main issue, as they could just have well made a much higher profit by committing the entire $100 for supply APY. The issue is that the actual health factor in the end is:

28.2 / (20 + 49.3) = 0.41, meaning the borrow is deeply undercollaterized. The $41 above health factor = 1, which is the liquidation point, represents money lost for the protocol: It paid supply APY without receiving the corresponding borrow APY. If the attack is done in a large enough scale, using additional leverage, it could eventually make the protocol bankrupt.


Note that in the example above, attacker waits 8 years to cash out, but they can wait much less to exit with profit, and different ratio of initial borrow / collateral lead to interesting divergences in terms of time to profit.


Back when I reported the finding, Fringe docs stated they will create a stablecoin called USB which will have 100% collaterization ratio. They ended up rightfully ditching that idea, but if it were to happen it could be used to a much greater impact than the current 71% LVR.


If you've read this far then you deserve a special treat - although the bug was acknowledged as critical and paid $2K , it is still not patched! I've received Immunefi's permission to disclose the finding, as it is over 90 days since submission, but theoretically Fringe could still lose significant money from such an exploit. If you have additional clever ways of maximizing the severity of the issue, I would like to hear about it.

0 comments

Comentarios


bottom of page