Exploiting Primitive Finance’s Approval Flaws
On Feb 24th, the post-mortem describing three white-hacks on Primitive Finance was released. More than one month after the post-mortem release, we identified a vulnerable user with ~$1M (500 WETH) at risk on April 14. By reproducing the white-hacks, we demonstrated our findings to Primitive Finance via ImmuneFi and helped the victim at risk reset the allowance. Below, we outline how we exploited the loophole on a simulated platform and identified the victim using blockchain data analytics.
Background
In interacting with EVM-based smart contracts, there is no callback mechanism for a smart contract to catch an ERC-20 transfer event programmatically. For example, when Alice sends 100 XYZ tokens to Bob, the XYZ balance of Bob would be updated in the XYZ token contract. But how would Bob know that the 100 XYZ tokens are received? Bob needs to check his balance on Etherscan or his wallet app to retrieve the latest XYZ balance from the XYZ token contract. What if Alice sends 100 XYZ to a smart contract? Let’s call this smart contract Charlie.
Charlie cannot retrieve this balance while receiving 100 XYZ due to the fact that the token transfer context is on the XYZ token contract. An alternative solution is the approve()/transferFrom() mechanism, which is widely deployed on EVM-based smart contracts.
For example, let’s say Alice needs to deposit 100 XYZ tokens into the Charlie contract for some reason. Alice could approve() Charlie to spend her XYZ tokens in advance such that the Charlie.deposit(100 XYZ) call would take 100 XYZ from Alice through transferFrom() and update the corresponding states in one transaction. In order to reduce user friction, many DApps let users approve unlimited amounts of XYZ spend, allowing all transferFrom() calls to execute. This is equivalent to whitelisting Charlie for spending any arbitrary amount of Alice’s XYZ assets. But, what if Charlie is compromised?
BancorNetwork’s safeTransferFrom()
This incident which happened on 18 June 2020 shows how a compromised or flawed smart contract could be exploited to cause a loss. As shown in the below code snippet, the safeTransferFrom() function was accidentally declared as a public function such that anyone could transfer any arbitrary _value of _token from a vulnerable _from address to an arbitrary _to address.
Specifically, if Alice happens to approve the buggy Bancor contract to spend her DAI, any villain could steal her assets whenever her balance >0.
Primitive Finance’s flashMintShortOptionsThenSwap()
As described in this postmortem, the external function has a similar loophole to the above example. But in this case, the villain needs to craft two ERC-20 contracts; 1) craft two ERC-20 contracts; 2) create a Uniswap pair, and 3) a Flash Swap to bypass the msg.sender == address(this) access control. For experienced EVM-based smart contract hackers — not difficult.
So, why did Primitive Finance have the flashMintShortOptionsThenSwap() function implemented? There is a legit use case for opening option positions through the openFlashLong() function. As shown in the code snippets below, the external function allows users to embed a flashMintShortOptionsThenSwap() call inside the flash-swap call (line 1371). The last parameter is set to msg.sender (line 1360), which means only assets of the caller would be sent to the Uniswap pool. A villain, however, could bypass this by creating a Uniswap pool and do something similar to line 1371 but with crafted params (i.e., replacing msg.sender with the victim address).
Identifying the potential victim(s)
If a villain happens to know of a rich guy who has allowed a buggy smart contract to spend his assets, he/she could steal all funds by exploiting this loophole. However, this is not an easy task, especially if the contract has already been deployed for some time. There would be lots and lots of transactions, and it would be pretty hard to find one of interest by simply parsing through Etherscan.
The Google Cloud Public Datasets comes to use. Due to the fact that every successful approve() call generates an Approval() event on Ethereum, one could use the BigQuery service to extract only the events which passes some filtering logic. Example: the _spender is the Primitive connector contract.
Below is the SQL we used to find all possible victims with Google Cloud’s public datasets. Line 5 shows the database and the specific table we are working on. Line 7 means we’re only interested in the Approval() events while the _spender is specified in line 8.
We then arrive at a list of possible victims at risk. We further improve the SQL filter above by scanning for candidates who have reset the approvals by calling approve(_spender, 0), in order to generate a final list. We then run a script periodically checking the address balances in that final list, triggering alerts whenever a substantial amount of asset come at risk.
500 WETH
The filter sent out alerts in an early Wednesday morning. A possible victim just received ~500 WETH. Not a trivial amount! Compared to the known white-hacks prior, this represented a potential financial loss greater than the total sum in the earlier three cases.
At 9:32 AM (GMT+8) we contacted ImmuneFi through Twitter and Telegram and showed them a screenshot re-exploiting the loophole on a forked Ethereum mainnet.
With the help of the Primitive team, the victim at risk was notified and the problem was resolved by resetting the approvals at 10:03 AM.
Later on, the Primitive team kindly awarded us with a bounty, publicly acknowledging this finding.
The Exploit
To exploit the loophole, we need a pair of (Redeem, Option) tokens which are ERC20s with some extra functions.
The Redeem token is a standard ERC20 with a public mint() function which enables us to control the amount of tokens. Therefore, we simply use the OpenZeppelin ERC20 implementation like this:
As for the Option token, we need some more public variables/functions which would be called by Primitive Connector contract. Furthermore, we need to pass in three values to initiate the Option token:
redeemToken: the address of the crafted Redeem contractunderlyingToken: the address of the asset we’re going to transferFrom() from the victimbeneficiary: the address which we’re going to transfer the stolen assets to
Note that the mintOptions() public function is kind of special here. It transfers the total balance of underlyingToken to the beneficiary address. The reason is that the internal function mintOptionWithUnderlyingBalance() (shown below) called by flashMintShortOptionsThenSwap() transfers quantity of underlyingToken to the Option contract and invokes the mintOptions() function. We simply let the mintOptions() call withdraw those tokens to the beneficiary address, which would be used to repay the flash loan.
After preparing the Redeem and Option tokens, we could use them to create the Uniswap pair which would be set as the destination address of stolen funds transfer. Specifically, the pair is created with the victim’s asset (e.g., WETH) and the Redeem (i.e., Option.redeemToken()). Furthermore, we need to add liquidity into that newly created pool. For Redeem, we can simply mint unlimited tokens. But how about the victim’s asset?
With the help of a flash loan, we could leverage (almost) any arbitrary amount of any asset with fees for any purpose. Here, we use Aave V2’s flashLoan() function to borrow ~99.7% of victim’s asset and put the leveraged funds into the liquidity pool mentioned above.
The real thing is done by the executeOperation() handler which allows the Aave contract to take the (leveraged + premium) amount of assets back in the end.
Conclusion
The approve()/transferFrom() is a long-term problem in the EVM-based smart contracts world. Above is just one example. Similar things happen all the time. Always check and reset your approvals after interacting with DeFi apps.
DISCLAIMER
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.