Belt Finance Logic Error Bug Fix Postmortem — Largest Ever Bounty Payout

Immunefi
Immunefi
Sep 30 · 5 min read

Summary

Whitehat Alexander Schlindwein, known for his critical bug reports in Armor and Fei Protocol, reported a critical bug in Belt Finance to Immunefi on August 10, 2021. The attack was a logic error in Belt Finance that caused one of their contracts to think that it had less money than it did, resulting in excess shares being issued for new deposits. Those shares could have been used to withdraw an unfair proportion of the assets in the contract. The attack was repeatable. Approximately $60m in BNB was at risk, but it was never exploited. The bug has since been patched.

Belt Finance paid out a bounty of $1,000,000 USDT. Binance Smart Chain’s Priority One bounty matching program added $50,000 to the payout, making the total $1,050,000.

This represents the largest single bounty Immunefi has ever paid out. We congratulate Alexander on continuing to maintain his legendary spot at the top of the Immunefi leaderboard.

Vulnerability Analysis

Belt Finance has a strategy token, which represents shares in the strategy (which is an interest bearing asset), and also has a strategy contract, which manages withdrawals and deposits in the protocol. Each strategy token represents a pro rata share of the assets deposited to the strategy contract.

https://github.com/BeltFi/belt-contract/blob/b45069e946dfe19418ce49637b3590b0f3e5a217/contracts/bsc/earnV2/strategies/alpaca/StrategyAlpacaImpl.sol#L166

If a hacker sends tokens directly to the strategy contract, the balanceSnapshot variable can be manipulated, which then causes the strategy token contract to perform an invalid calculation on the actual holdings of the strategy contract. This invalid calculation reduces the apparent balance of the contract below the actual balance. This can then be abused to purchase shares for almost the entirety of the managed funds for reduced cost and then drain the contract of its funds.

The core of the vulnerability is in this excerpt of the strategy implementation contract:

if(_wantAmt > wantLockedInHere()) {    _withdraw(_wantAmt.sub(        wantLockedInHere()    ));}balanceSnapshot = balanceSnapshot.sub(_wantAmt);

The fixed version of this code is:

if(_wantAmt > wantLockedInHere()) {    balanceSnapshot = balanceSnapshot.sub(_wantAmt.sub(        wantLockedInHere()    ));    _withdraw(_wantAmt.sub(        wantLockedInHere()    ));}

The logic of the withdraw function is supposed to send funds to the withdrawing user directly, if the contract has enough wantLockedInHere (balance of the contract not put to work generating yield) to satisfy the request. Only if the request cannot be satisfied from the wantLockedInHere, are funds liquidated from the yield-generating asset and sent to the user. When funds are liquidated from the yield-generating asset, the balanceSnapshot variable needs to be updated to keep track of that liquidation. The bug was introduced when the balanceSnapshot variable was updated even when the contract had enough wantLockedInHere to satisfy with withdrawal without liquidating the yield-generating asset. This essentially double counts the withdrawal, as the wantLockedInHere is decreased and the balanceSnapshot (the yield-generating asset value) is decreased.

When the total locked funds (the sum of the wantLockedInHere and the balanceSnapshot) is too low, a new call to deposit will mint a too-large amount of the strategy token for each want token deposited. By depositing a large amount of the want token while the total locked funds is too low, the attacker can gain a majority share of the yield farm without depositing a majority of the principal of the farm. After resetting the balanceSnapshot to the correct value, the attacker can then call withdraw with their shares to claim the majority of the funds in the yield farm contract.

The purpose of _withdraw is to reduce our stake in the yield-generating asset, represented by balanceSnapshot. But if tokens are sent directly to the strategy contract, then the if statement fails and _withdraw doesn’t get called. This means that when we check the wantLockedTotal, the hacker gets a value that’s too low. But of course, the hacker wants a value that is too low because it means more shares will be minted to the hacker.

To restate, if a call to _withdraw doesn’t happen, the strategy’s stake in the yield-generating asset is not appropriately reduced and the attacker receives too many shares.

The step by step guide to exploiting the vulnerability is as follows:

Step 1: A malicious hacker calls deposit, sending BNB into the strategy token, which sends into the strategy contract, which then sends into the bank, and then the strategy token mints the hacker a pro rata share of the strategy — this is normal staking into the yield farm

Step 2: The hacker sends wBNB directly into the strategy contract, so as to cause the if(_wantAmt > wantLockedInHere()) check to fail. This step temporarily causes the hacker to lose money. No deposits are occurring. The hacker is simply sending tokens directly to the strategy contract

Step 3: The hacker withdraws shares. This step triggers the bug. As a result of step 3, the strategy does a double count of the withdrawal. The strategy contract doesn’t think it has as much BNB as it actually does.

Step 4: When the hacker goes to deposit again, the strategy token performs an incorrect calculation based on that too-low balance and mints the hacker too many shares

Step 5: When the hacker calls earn, the strategy contract overwrites its existing balanceSnapshot by asking the bank directly how much money the strategy contract has. This fixes the too-low balance calculation and makes future calculations return the correct result.

Step 6: The hacker makes another call to withdraw, claiming the BNB represented by all the shares they received in steps 1 and 4. The result is that the hacker has more money than they started with because they were minted too many shares in step 4.

It’s important to note that step 2 is not something that should happen under normal circumstances. Sending wrapped BNB to the strategy contract is an unusual move and only done in this case to cause a check to fail.

Funds at risk were roughly calculated at $60m in BNB. A single iteration of the attack could take approximately $10M, but the attack could be repeated until the entire contract was drained.

Vulnerability Fix

This patch was applied to Belt’s contracts. In addition to fixing the bug described in this report, it makes several improvements to Belt’s yield farms.

https://github.com/BeltFi/belt-contract/commit/064282d554a424ca95ab7f87d8ec04c5f92cf343

Acknowledgements

We’d like to thank the Belt Finance team for paying out the single largest ever bug bounty on Immunefi to date at $1m. We’d also like to thank BSC’s Priority One program for stepping in with a $50,000 matching reward, placing the total payout to the whitehat at $1,050,000. The Priority One program just recently launched and is already making big strides towards securing the BSC ecosystem with this first matching payout.

Credits go to Alexander Schlindwein for finding the bug and Duncan Townsend, CTO of Immunefi, for developing a fix that the Belt team has used in production.

To report additional vulnerabilities, please see Belt Finance’s bug bounty program with Immunefi. If you’re interested in protecting your project with a bug bounty like Belt Finance, visit the Immunefi services page and fill out the form.

Immunefi

Immunefi is the premier bug bounty platform for smart contracts and DeFi projects.