Exploiting Spartan Protocol’s LP-Share Calculation Flaws
At midnight on Labour Day, Binance Smart Chain (BSC), the blockchain running EVM-compatible smart contracts, suffered its first flash loan attack. Over $30mm was drained from Spartan Protocol’s liquidity pool.
The target of the accident was Spartan Protocol, a DeFi protocol for synthetic assets running on BSC. When diving into the detailed source code, we can see it is a tweaked version of the UniswapV2 protocol. Specifically, the fee mechanism is modified to incentivize liquidity providers when liquidity is scarce. As a result, users trading larger volumes are charged more fees. Almost all main assets on BSC (e.g. BNB) have corresponding UniswapV2-like pairs (e.g. WBNB-SPARTA). Similar to UniswapV2, those pairs are open for users to add/remove liquidity. For example, Alice is able to send (WBNB+SPARTA) into the WBNB-SPARTA pool and get SPT1-WBNB LP tokens back, redeemable for the underlying assets.
Unlike most flash loan attacks caused by manipulating AMM-based oracle price feeds, the Spartan Protocol hack resulted from an issue with the underlying protocol logic, which saw an exploit in the removeLiquidity() process. A flash loan was used to amplify the damage.
The following code snippet shows that the outputBase and outputToken are calculated by the calcLiquidityShare() in the Utils contract. The corresponding assets are transferred to the caller (i.e., the liquidity provider) (lines 245–246) after the LP tokens (units) are successfully burned (line 244).
The core problem lies in the calcLiquidityShare() function. In the original Uniswap design, when you remove liquidity with a certain amount of LP tokens from a pool, you get the (LP tokens/total LP tokens) portion of the total underlying assets. For example, Alice creates a pool with (100 WBNB + 100 SPARTA) and gets 100 SPT1-WBNB LP tokens back. When Alice removes liquidity from the pool with 10 SPT1-WBNB LP tokens, (10 WBNB + 10 SPARTA) should be transferred out since 10 LP tokens is 10% of the total supply of SPT1-WBNB. Accordingly, line 385 in the following code snippet should get the balance of the underlying asset (amount) in the pool. The amount of the underlying asset that should be transferred out is calculated based on the total LP tokens supplied (line 386) and the number of LP tokens to burn (units): amount*(units/totalSupply).
The implementation seems simple and elegant. So, what’s the problem?
The developer missed one thing.
Simply by transferring tokens into the pool, one can manipulate the amount in line 385. If you don’t care who transfers assets into the pool, you’ll get more underlying assets back when the pool’s balance of underlying assets increases; however, one could take out the assets transferred into the pool without loss, leading to a juicy exploit.
For additional context, let’s take a look at how Uniswap implements the removeLiquidity() process. Specifically, UniswapV2Pair.burn() — the core part of liquidity removal — sends the LP tokens in the Pair contract (done mainly by the Router contract) and invokes the burn() function to “burn” those LP tokens and get back the underlying asset. As shown in the following code snippet, lines 144–149 are similar to Spartan’s implementation where _token0 and _token1 are transferred to the liquidity provider based on the ratio of the LP tokens sent (liquidity) to the total LP tokens supplied (_totalSupply).
One key difference is in line 153 — the _update() call. In lines 82–83 below, we can see the current balance of underlying assets (balance0, balance1) is synced to the Pair’s reserved assets (reserve0, reserve1). Therefore, if the attacker intentionally sends in some underlying assets into the Pair contract before invoking burn(), those assets would be considered pooled assets and shared amongst all accounts with the corresponding LP tokens.
Back to the Pool contract of Spartan Protocol — since removeLiquidity() fails to synchronize the balance of the underlying assets into the reserved assets (baseAmount, tokenAmount), the attacker can addLiquidity() to get LP tokens after the burn() call and removeLiquidity() to get the funds back. You might notice that _decrementPoolBalances() is called in line 243, which is similar to the _update() call in Uniswap. But something is missing.
_decrementPoolBalances() does not get the update-to-date balances of BASE and TOKEN. Instead, it only decrements the reserved amounts (baseAmount, tokenAmount).
Therefore, in addLiquidity(), the amount of previously transferred (BASE, TOKEN) assets (_actualInputBase, _actualInputToken) is calculated by using the current balances minus (baseAmount, tokenAmount) as shown in lines 224–225. In line 226, the liquidityUnits are derived from said amounts, and LP tokens are minted in line 228.
The code snippet below elaborates how _actualInputBase is calculated. Specifically, the balance of BASE (_baseBalance) is retrieved in line 274. In line 276, (_baseBalance — baseAmount) is set as the actual input amount and returned.
To exploit this loophole and make a profit, we can follow four simple steps:
1) Add liquidity and get LP tokens back;
2) Transfer some assets into the Pool contract to amplify the number of underlying assets of the LP tokens collected in Step #1;
3) Remove liquidity and get more assets than what you added in Step #1;
4) Add the assets transferred into the Pool contract as liquidity and remove it immediately.
The key word here is “amplify”, hence making use of a flash loan — a no-brainer for the attacker. If you get a lot of assets prior to launching Step #1, you could do more with Step #2 and increase your profits. We demonstrate how to launch the attack with PancakeSwap’s flash-swap according to what the attacker did in the following:
In lines 37 and 39, we borrow three times the amount of assets (e.g., WBNB) in the victim Spartan pool through the swap() call to implement our exploit in the pancakeCall() callback function. If you’re new to flash-swap, notice the third parameter — “brrrr” — of the swap() is necessary here. Otherwise, your pancakeCall() callback will not be triggered. You can find more details in the UniswapV2Pair contract.
After getting a large amount of WBNB (i.e. victimAsset), the first thing we need to do is buy SPARTA tokens. In our early analysis, we were confused by the multiple small amounts of SPARTA purchased by the attacker. But as we looked deeper into the Spartan Protocol source code, we concluded it was because of the fee mechanism: we would be charged a substantial fee for purchasing too much in one batch, reducing our profits. For this reason, we need to buy 1/3 of SPARTA in the victim pool in five batches of pancakeCall() (lines 46–52). We then need to add SPARTA + WBNB as liquidity into the victim pool (lines 59–61).
Next, we need to transfer assets into the victim pool. To reduce the fee, we purchase SPARTA in 10 batches (lines 67–70) and transfer all of them, in equivalent amounts of WBNB, into the pool (lines 77–78):
Here’s the interesting part.
The removeLiquidity() call in line 82 withdraws the amplified amount of WBNB + SPARTAN after transferring all LP tokens into the pool in line 81.
The last step is to add the funds used for amplification as liquidity (line 85) and take them out immediately (line 86–87).
Now, you can see that most of the WBNB in the victim pool is drained. We can further sell all the leftover SPARTA to the victim pool and get more WBNB back:
At the end of the attack transaction, we need to pay the 0.25% flash loan fee. The following code snippet shows that the retAmount is calculated based on the fee mechanism in lines 102 and 104. Therefore, we can use the EVM feature to revert the whole transaction if it does not make profit (line 106).
The above procedures cannot drain the victim pool in one transaction. We repeated the attack 20 times (4 times in one transaction, 5 transactions in total) to drain ~90% of WBNB in the victim pool.
Houston, we have a problem
The above screenshot shows that the block height we forked the BSC mainnet for testing was 7,115,815. That block was mined at May-04–2021 01:07:27 AM UTC+0 according to BscScan:
Wasn’t the attack done on May 1st? Yes, but the loophole was not fixed immediately.
By the time we reproduced the attack on May 3rd, we had found that over $100k in user funds were still at risk, including WBNB, BUSD, CREAM, ETH, BURGER, XRP, DOT, LINK, and RAVEN pools. At 5:57pm UTC+8 May 3rd, we contacted the Spartan Protocol team with the re-exploit proof of the loophole. At 6:16pm UTC+8, they updated the Utils contract to prevent the possible exploit by this transaction. However, the patch also stopped liquidity providers from removing liquidity, which meant it was not the ultimate solution.
At 8:28am UTC+8 May 4th, our monitor showed that the Utils contract was updated again, but to the buggy version for some reason. It meant those funds were again at risk. Around 40 minutes after the update, the final patch was deployed at block height 7,155,816. That’s why 7,115,815 was the latest block height where we could exploit the loophole.
In our follow-up communication with the Spartan Protocol team, we helped to set up the reproduction environment with our exploit code for testing the final Utils contract. Since the compensation plan will send out the reserved tokens based on the snapshot before the attack, we also helped the team generate an accurate snapshot with BSC archive nodes. Thanks to the Spartan Protocol Team for acknowledging us in their weekly update.
The Information does not provide and should not be treated as giving investment advice. The Information does not take into account specific investment objectives, financial situation or the particular needs of any prospective investor. No representation or warranty is made, expressed or implied, with respect to the fairness, correctness, accuracy, reasonableness or completeness of the Information. We do not undertake to update the Information. It should not be regarded by prospective investors as a substitute for the exercise of their own judgment or research. Prospective investors should consult with their own legal, regulatory, tax, business, investment, financial and accounting advisers to the extent that they deem it necessary, and make any investment decisions based upon their own judgment and advice from such advisers as they deem necessary and not upon any view expressed herein.