Dexfolio’s Re-Entrancy Loophole Explained
In the short history of EVM/Solidity smart contracts, we have seen a plethora of reentrancy attacks. On August 15, 2021, we identified yet another one in Dexfolio’s LP Farming contract running on the BSC network and reported our findings through the bug bounty platform, ImmuneFi. Since there’s no public post-mortem released from the dev team in the last 120 days, we elaborate on the details in this blog post.
Dexfolio’s LPFarming contract allows users to stake assets for liquidity farming. For each asset that the user chooses to stake, there are four public functions to handle the staking task. For example, the payable LPFarming.stake() function allows the user to pay BNB into the contract, and half of those BNB would be exchanged to DEXF tokens for minting DEXF-WBNB LP tokens.
As shown in the code snippet above, newBalance is derived from the newly minted amount of DEXF-BNB LP tokens (line 555). Afterwards, the _stakes[ ] array is appended with the new staking records (line 560), which would be used for farming rewards calculations.
In all four staking functions, the stakeToken() function is a special one which allows users to pay in an arbitrary erc20 token for exchanging the DEXF-BNB LP tokens as the staking assets. However, we noticed that there’s no nonReentrant modifier here to prevent the reentrancy. Since the user is allowed to invoke stakeToken() with an arbitrary fromTokenAddress, any erc20 handler (e.g., transfer(), transferFrom(), approve(), etc.) could be exploited to hijack the control flow and re-enter stakeToken().
In line 647 above, the initialBalance is retrieved for calculating the newly minted amount of DEXF-BNB LP tokens in line 651. However, the swapAndLiquifyFromToken() call in line 649 invokes fromTokenAddress.approve() internally for swapping tokens at PancakeSwap. This allows bad actors to embed another staking operation inside the body of the original stakeToken() call, which results in a greater newBalance in line 651. In brief, the attacker could double stake the same batch of LP tokens.
For example, the attacker calls stakeToken(10) and embeds another stakeToken(90) through fromTokenAddress.approve() with another account. Eventually, the first account has 10 + 90 = 100 LP tokens staked and the second account has 90. The latter 90 is double counted here.
At first glance, the isContract check in line 638 prevents the reentrancy due to the fact that the second account needs to run some code to reenter stakeToken(). However, the implementation of isContract fails to cover all the cases such that we could have all maliciousthings inside the constructor to bypass the check.
To exploit the reentrancy bug, we need an malicious erc20 contract for hijacking the approve() call. As shown in the code snippet below, the Ftoken overrides the approve() function of OpenZeppelin’s ERC20 implementation with an _optIn switch. When it is opted in, the Exp contract would be created. Inside the constructor of the Exp contract, we embed a stakeToken() call as mentioned earlier to get rid of the buggy isContract check.
Since the LPFarming’s isContract modifier only checks the extcodesize of the given address, we could invoke LPFarming.stakeLPToken() inside the constructor of Exp contract to bypass the protection mechanism as shown below.
Make sure you check tx.origin == msg.sender if you really want to avoid contract accounts.
There’s one missed part here. Since stakeToken() exchanges the fromTokenAddress assets to DEXF-BNB LP tokens at PancakeSwap. We need to create the Ftoken-BNB pair and add liquidity into it. We have another Lib contract to achieve that. The Lib.trigger() function below helps us to create the Ftoken-BNB pair at PancakeSwap and put amount of WBNB with same amount of Ftoken into the liquidity pool. When everything is done, the Lib.sweep() allows the “owner” to sweep all remaining WBNB in the liquidity pool.
With the three contracts prepared, we could set up the experiment to prove our theory. As shown in the eth-brownie screenshot below, we start with 21 WBNB and get Ftoken and Lib contracts deployed. As we mentioned earlier, we would use the constructor of the Exp contract to re-stake some LP tokens. Therefore, we use the LPFarming.getStakes() view function to check the amount of shares that the Exp contract currently holds. Since Exp would be created by the Ftoken.approve(), we use the address of Ftoken contract to pre-calculate the address Exp with eth-utils as suggested here .
After Ftoken and Lib are prepared and the Ftoken-BNB pair is created by Lib.trigger(), we could perform the first stakeToken() to launch the reentrancy attack.
As shown above, we invoke Ftoken.optIn() before and after the stakeToken() call to activate the hijacking mechanism in Ftoken.approve().
In the last part, we use LPFarming.emergencyWithdraw() to withdraw the amount of LP tokens and exchange them into WBNB. Also, we invoke Lib.sweep() to get the remaining WBNB in the Ftoken-BNB liquidity pool. Eventually, we get 19.36 WBNB back and leave a staking record of the Exp contract inside the LPFarming contract. Since the Exp contract has been deployed in the Ftoken.approve() call, we can’t re-initialize the contract and perform LPFarming.emergencyWithdraw() inside the constructor again so that the attacker seems to make no profit. However, the CREATE2 instruction allows us to literally re-initialize the Exp contract.
The CREATE2 instruction, which came with the Ethereum Constantinople fork, allows users to pre-calculate the contract address with a given contract’s bytecode and salt. As a side effect, if the contract suicides with the SELFDESTRUCT instruction, the exact same bytecode with the same salt could be re-deployed on the same address.
In our scenario, we could let the Ftoken.approve() deploy the Exp contract and perform the reentrancy thing followed by a SELFDESTRUCT. Later on, we could re-deploy the Exp contract and perform the LPFarming.emergencyWithdraw() to withdraw the double-staking LP tokens.
The altered Ftoken.approve() is shown above. There are four parameters passed into the CREATE2 call. The first 0 means we pay in 0 eth when we create the contract and the last 0 is the salt which should be identical to the one when we re-create the contract. Both the second and the third parameters are related to the bytecode we are going to deploy.
Besides the altered Ftoken.approve(), we added a view function getAddress() to pre-calculate the address of the Exp contract. Here, we simply follow the EIP-1014 to hash the bytecode with 0xff, the creator address, and the 32-bytes salt.
We also tweaked the Exp contract to either stake or withdraw LP tokens based on the state kept by Ftoken contract and suicide itself with SELFDESTRUCT to allow the next re-deployment.
With the enhancement with CREATE2, our Exp contract can make profits as shown in the screenshots below:
We successfully took 1,558 LP tokens from the LPFarming contract and converted them into WBNB at block height 10181384.
0x04: Timeline & Acknowledgement
We reported the issue to ImmuneFi  on August 15, 2021 and the Dexfolio team asked users to withdraw their assets on August 20, 2021 . ImmuneFi told us that this might be a duplicate bug report but needed to confirm with the Dexfolio team right after our report was created. Because we got no response for more than 90 days from ImmuneFi and Dexfolio, we chose to disclose the details as independent research. After we stated that we intended to disclose it, ImmuneFi rewarded us $1,000 equivalent ETH for this valid report and the proper disclosure on November 26, 2021. Moreover, ImmuneFi helped us to contact the white hat who first reported the same loophole — — lucash-dev.
With the help from lucash-dev, we polished our exploit and proved that this finding was a critical one (i.e., the bad actor could drain the LPFraming pool). lucash-dev was the superhero who saved Dexfolio users!
The information contained in this post (the “Information”) has been prepared solely for informational purposes, is in summary form, and does not purport to be complete. The Information is not, and is not intended to be, an offer to sell, or a solicitation of an offer to purchase, any securities. 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.