Table of Contents
Overview
ELI5 Summary
Step-by-step Explanation
Video Explanation
References
1. Overview
On July 18 2023, the BNO protocol on Binance Chain was exploited, resulting in a loss of over 500K USD. The core issue of the exploit is due to the accidental paying of rewards when staking and unstaking in the same transaction.
2. ELI5 Summary
The attack starts with 277,856 BNO tokens in the attacker's account
The attacker calls stakeNFT() in BNO’s Pool contract and transfers NFT 13 and NFT 14 inside.
The attacker calls pledge() and passes in 277,856 BNO tokens
The attacker calls emergencyWithdraw() to withdraw 277,856 BNO tokens
The attacker calls unstakeNFT() to withdraw the 2 NFTs
The attacker earns 3,743 BNO because emergencyWithdraw() sets the reward amount to zero after the reward was already distributed.
Step 2-6 is repeated, and each time the attacker earns a little more BNO because he passes in whatever he earned into pledge().
A total of 767,213 BNO tokens were siphoned from the Pool contract
3. Step-by-step Explanation
I have split the attack into 4 phases. The second phase is the most important one since I’ll be diving into the attack and explaining how the attack actually works. I’ll be using Phalcon Explorer to track the flow of funds. To follow the invocation flow with me, click on the link on Phalcon Explorer and scroll all the way down until you see the words “Invocation Flow”.
Phase 1: The Setup, Lines 1-13
Phase 2: The Attack, Lines 19-64
Phase 3: The Attack, Repeated, Lines 70-5113
Phase 4: The Repayment
Phase 1: The Setup, Lines 1-13
Line 1-13: The Attacker manages to get 277,856 BNO tokens from interacting with the COW-BNO liquidity pool in Pancakeswap. I am unsure how he gets it, but looking at the last few lines in the invocation flow, he has to return 287,194 BNO tokens back to the liquidity pool, so I assume that this is a flash loan.
Note that the Attacker also has 2 NFTs, NFT IDs 13 & 14.
Attacker’s Address: 0xA6566574eDC60D7B2AdbacEdB71D5142cf2677fB
Attack Contract: 0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd
Phase 2: The Attack, Lines 19-64
The attack comprises 4 functions in the BNO Pool contract. stakeNFT(), pledge(), emergencyWithdraw(), unstakeNFT().
BNO Pool Address: 0xdCA503449899d5649D32175a255A8835A03E4006
Pool Address Code: VSCode
Line 19-35: The attack contract first calls stakeNFT() with NFT 13 and NFT 14 and with 0.008 BNB.
Note the stakeNFT() function in the BNO Pool contract.
The first line, line 629, checks that msg.value >= withdrawalFee. This withdrawalFee is coded to 0.008 BNB, which is why the attacker has to provide 0.008 BNB to call the function. The few key internal function calls to note are pendingFit() [Line 630] and updatePool() [Line 657]. The NFT is transferred into the Pool contract on line 644.
Line 38-43: The attack contract calls pledge() with 277,856 as the input parameter.
Again, there is a check for withdrawalFee payment in line 535, so the attack contract has to pass in 0.008 BNB. The 277,856 BNO tokens are transferred to the Pool address in line 551.
Line 44-46: The emergencyWithdraw() function is called.
Note that the 277,856 BNO tokens are transferred back to the attack contract’s address in line 623. allstake and rewardDebt in userInfo is set back to 0.
Line 47-64: unstakeNFT() is called.
The 2 NFTs are returned back in line 684. Through these four function calls, the attacker pocketed 3,743 BNO tokens.
So, where is the issue in the four function calls?
stakeNFT() → pledge() → emergencyWithdraw() → unstakeNFT()
The issue is actually in the updatePool() internal function call. To understand better, let’s work backward using Phalcon and the code (I will be creating a video walkthrough about this part because I think it’ll be easier to explain verbally but I’ll do my best here). Firstly, I did not show you the updatePool() function, so here it is.
Here is the pendingFit() function as well, as it will be important in understanding the issue.
Let’s look at pendingFit() first. In the four functions that were called by the attack contract, stakeNFT(), pledge(), and unstakeNFT() call pendingFit() to get the pending value. If the pending value is greater than zero, some stuff will happen and an event called ‘Withdraw’ will be emitted.
If you take a close look at Phalcon, ‘Withdraw’ is only emitted when unstakeNFT() is called. This means that in unstakeNFT(), the pending value is more than zero. Let’s look at what happens when pending is more than zero.
In line 669, there is a transfer from the contract to the msg.sender with the pending amount. On Phalcon, line 52, you can see that the event Withdraw is emitted with the attacker contract as the msg.sender and 3,743 (with 18 decimals) as the pending amount. So, we realize that this is the part where the attacker profited. Let’s now look at pendingFit() in detail, and see what is returned from that function.
On the last line of pendingFit(), userreward is returned. This means that userreward is somehow 3,743. userrreward is calculated in the previous line, which is
user.allstake + user.nftAddition * accPerShare / 1e12 - user.rewardDebt
In this calculation, somehow 3,743 is returned. I’ll not go through the exact calculation here (you can use the debug function in Phalcon), but note the user.allstake and user.rewardDebt variables.
Let’s backtrack to emergencyWithdraw(). Remember, emergencyWithdraw() is called before unstakeNFT().
Note that after calling the emergencyWithdraw(), the userInfo.allstake and userInfo.rewardDebt is set to zero. Going back to the calculation,
user.allstake + user.nftAddition * accPerShare / 1e12 - user.rewardDebt =
0 + user.nftAddition * accPerShare / 1e12 - 0 =
user.nftAddition * accPerShare / 1e12
Now that we have eliminated two variables, the problem lies with either user.nftAddition or accPerShare or both. Let’s backtrack one more time and look at pledge(). pledge() calls updatePool(), so let’s focus on updatePool()
Since we know where to look, let’s focus on nftAddition or accPerShare and see where the state changes. In line 518, you can see that nftAddition is updated. Lines 519 and 523 updates the poolInfo and not the userInfo, so those lines are irrelevant.
In nftAddition, note that userInfo.allstake is used. If allstake is zero, then nftAddition will be zero.
user.nftAddition * accPerShare / 1e12 =
0 * accPerShare / 1e12 =
0
If emergencyWithdraw() sets allstake to zero, then this state change should set nftAddition to zero which would set pendingFit() to zero, and then the attacker will not get any funds. So, what happened? Let’s take a look at unstakeNFT once more and notice where UpdatePool is.
updatePool() is called after the pending check. At that time when emergencyWithdraw() is called, updatePool() is not called yet. It is only called at the end of everything, meaning that pending in unstakeNFT() still uses the old value instead of 0.
How do we rectify this issue? To be honest, I’m not very sure (actually still thinking about it). The problem is that updatePool() should be called at the end of emergencyWithdraw(), but that means that those who actually pledge their tokens for a long time will not receive any tokens back. updatePool() also can’t be called before pendingFit() in unstakeNFT(), because that means that those who pledge tokens and unstake their NFT for a period of time will not get any rewards. So, I’m not really sure about the mitigation, I would maybe recommend that emergencyWithdraw() should be taken out. Either that, or those that call emergencyWithdraw() should accept that they will never earn any rewards, and put updatePool() right at the end of emergencyWithdraw().
Once again, the issue is that the state change in emergencyWithdraw is not reflected fast enough in unstakeNFT() (updatePool() is called after pendingFit()), so the attacker can abuse this vulnerable sequence to gain profit from the protocol.
Phase 3: The Attack, Repeated, Lines 70-5113
Line 70-5113: The same sequence happens again over a hundred times, and each time the attacker gets more and more BNO tokens and eventually drains the pool of 767,213 BNO tokens.
Start of Attack, Attacker’s BNO balance: 277,856
Start of Attack, Pool’s BNO balance: 1,563,370
End of Attack, Attacker’s BNO balance: 1,045,069
End of Attack, Pool’s BNO balance: 796,157
Attacker’s Profit: 767,213 BNO
Phase 4: The Repayment, Lines 5114 - 5121
Nothing much happens at the end. For some reason, the attacker has to pay 296,077 BNO tokens to the COW-BNO Pancakeswap Pool, so I suspect that this is a repayment of flashloan, but I’m not so sure about that.
4. Video Explanation
(Video in Progress)
5. References
Phalcon Explorer: https://explorer.phalcon.xyz/tx/bsc/0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9
Attacker Address:
https://bscscan.com/address/0xa6566574edc60d7b2adbacedb71d5142cf2677fb
GDS Code:
https://vscode.blockscan.com/bsc/0xdca503449899d5649d32175a255a8835a03e4006
GDS Contract:
https://bscscan.com/address/0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd