Preventing Re-Entrancy Attacks — Lessons from History

Amber Group
Amber Group
Published in
13 min readSep 27, 2021
Re-entrancy attacks are a bit like Trojan horses. Embedding evils calls inside the body of legit call.

In the short history of crypto/blockchains, re-entrancy attacks have undoubtedly become one of the most well-known type of exploits. TheDAO, which caused the hard-fork of Ethereum in the early days, led to the creation of the Ethereum Classic (ETC). After the TheDAO incident, developers were educated to use the Checks-Effects-interactions [1] pattern and/or ReentrancyGuard [2] to prevent similar attacks. However, re-entrancy attacks keep happening. And sophistication of attack methods continue to evolve; from single-function re-entrancy to cross-function re-entrancy. In this article, Dr. Chiachih Wu, Head of Amber Group’s blockchain security team, gives an in-depth breakdown of three re-entrancy attacks:

  1. The UniswapV1 re-entrancy attack in April 2020
  2. The DeFiPIE incident in July 2021 on Binance Smart Chain (BSC)
  3. Cream Finance’s exploit caused by the AMP token integration [8].

Before diving into details, we’d like to start from the concept of re-entrancy. The code snippet below is the simplified TheDAO contract on the Solidity website [3] - the withdraw() function of the Fund contract sends ETH to the caller (msg.sender) with msg.sender.send(). The amount to be sent is the shares[] array indexed by the caller address (msg.sender). After the ETH transfer, “shares[msg.sender]” is set to zero, which is reasonable and straight-forward to most developers.

However, the caller mentioned above could be a malicious contract. If the “fallback” function were implemented in that malicious contract, Fund.withdraw() could be hijacked through the msg.sender.send() call. Moreover, if Fund.withdraw() were called again inside the fallback function, shares[msg.sender] of ETH would be sent to msg.sender again before the storage slot was set to zero. The above code flow is illustrated in the figure below:

As shown in the figure above, the Evil contract deployed by the bad actor has a receive() function (i.e., fallback function) which checks the ETH balance of the Fund contract and invokes Fund.withdraw() when the balance is greater or equal to shares[msg.sender]. With the re-entrancy mechanism, shares[msg.sender] would not be zeroed until the last call of Fund.withdraw(), which enables the bad actor to drain the Fund contract.

In the simple example, the key to the successful exploit is setting shares[msg.sender] to zero after the ETH transfer. There’s no problem updating the book-keeping record “before” the transfer. A failed transfer would perfectly revert the state changes. In addition, making effects (i.e., clearing the shares[] storage slot) before interactions (i.e., ETH transfers) could successfully get rid of re-entrancy attacks. Specifically, if Fund.withdraw() is re-entered, no ETH would be sent to the malicious contract as “shares[msg.sender]” is zeroed in the first entry of Fund.withdraw(). That’s how the Checks-Effects-Interactions pattern works.

UniswapV1 re-entrancy attack in April 2020

Next, we’re going to talk about a similar case with a more complicated exploit. On the afternoon of April 18, 2020 (HKT), tweets about the attack of Uniswap imBTC pool were spreading [4]:

The founder of Uniswap, Hayden Adams, tweeted about the incompatibility of UniswapV1 and ERC-777 with a link to ConsenSys Diligence’s blog [5]. The incident matched a scenario mentioned in a blog post which was released around a year prior (April 20, 2019). A one-year-old bug was exploited. How? Let’s start with the UniswapV1 pair contract.

In UniswapV1’s tokenToInput() function below, we can see that the “token_reserve” is retrieved by the balanceOf() call in line 204. Later on, the “wei_bought” is derived in line 206 and that amount of ETH is sent to the “recipient”. After that, the “tokens_sold” amount of “self.token” is transferred to the “buyer” in line 210. There’s no explicit “effects” here such that it seems to follow the Checks-Effects-Interactions pattern. However, the transferFrom() call itself (line 209) could have an “Effects After Interactions” scenario, which may destroy the DeFi lego.

In the transferFrom() handler, _transferFrom(), of an ERC-777 token contract below, the _callTokensToSend() function (line 866) notifies the “holder” by calling the “tokensToSend()” function of the “holder” if the callback function is registered through ERC-1820 standard, which is an “interactions”. After that callback, _move() is called (line 868) to literally move the assets from “holder” to “recipient”, which updates the token balances (i.e., effects). So, if UniswapV1’s tokenToEthInput() is re-entered through the “tokensToSend()” callback function, the token balances would leave unchanged, leading to a never decreased “token_reserve”.

In short, when re-entrancy happens, the “token_reserve” value will not be updated in consecutive token exchanges, leading to the violation of the “xy=k” setting of Uniswap. The attacker could sell tokens at a much better rate to drain the liquidation pool.

In the following section, we will explain how we use eth-brownie with an Ethereum archive node to reproduce the incident at block height 9,488,451 mined on Feb 15, 2020.

The first step of reproducing the hack is registering the tokensToSend() callback function through the ERC-1820 contract. After the successful registration, all corresponding token transfers are hijacked.

Then, we can launch the attack. In the trigger() function, all ETH are swapped into tokens in line 38. That’s another operation preparing the exploit contract so that it has enough token balance. The “tokenToEthSwapInput()” call in line 39 is the real thing, which swaps 1/32 of tokens to ETH. The other 31/32 tokens are swapped by re-entrancy calls. After that, we use quite a few ETH to swap for tokens in line 40 for draining the liquidity pool. And finally, we collect the profit by sending all ETH and tokens to the “owner” (i.e., the attacker address).

Inside the callback function, tokenToSend(), the “entry” variable ensures that the exploit contract re-enters Uniswap exactly 31 times for swapping the other 31/32 tokens but with the same rate. This breaks the “xy=k” invariant. After those re-entrancy calls, most of ETH in the liquidation pool would be consumed such that each ETH could swap for a large amount of tokens. Therefore, in the earlier mentioned trigger() function at line 40, we could use a small amount of ETH to swap out most of the tokens. Example below:

The victim contract held 718 ETH + 19.59 imBTC tokens before the attack. After 32 re-entrancy swaps, the victim contract (i.e., UniswapV1 imBTC pair) is left with only 0.013 ETH + 0.019 imBTC in residual funds. Both the above UniswapV1 + ERC-777 case and TheDAO were caused by single-function re-entrancy.

Now we’ll move on to a multiple-function re-entrancy case — the DeFiPIE exploit on BSC.

DeFiPIE incident in July 2021 on Binance Smart Chain (BSC)

At first glance, the codebase of DeFiPIE looks very similar Compound Finance. Hence, one may think this exploit is similar to the Lendf.Me (another lending platform) exploit which happened on April 19, 2020 (where the attacker left an embedded message: “Better future”)[6]. But further analysis shows that the DeFiPIE incident is way more complex than the Lendf.Me one in which the attacker only exploited the loopholes in supply() and withdraw().

In DeFiPIE’s PToken contract, borrowFresh() updates the states (line 802–804) after transferring assets to the “borrower” (line 799), which is kind of common in all Compound forks. As we learned from the Lendf.Me incident, the supported tokens should be whitelisted to ensure that no hijacking mechanism could be implemented such as ERC-777. Otherwise, the “Effects-After-Interactions” implementation could be exploited. The borrowFresh() function in question allows the attacker to borrow multiple sets of assets with the same set of collateral in reentrant borrowFresh() calls. The reason is that the states reflecting the borrowing operations have not been synced into the storage until the final level of reentrant borrowFresh() calls is finished. In the end, the attacker liquidates the debt which is created in those re-entrant borrows to make profits.

In the Lendf.Me incident, the bad actor hijacks the transferFrom() calls through the built-in ERC-777 mechanism of imBTC. In DeFiPIE, there’s no whitelist/blacklist of the supported tokens, which means the attacker can arbitrarily create a malicious token contract for hijacking and re-entrancy. In the following paragraphs, we will show you how to reproduce the DeFiPIE hack from scratch.

Let’s start with the malicious token contract. As shown in the code snippet above, we use OpenZeppelin’s template [7] to create a simple ERC20 token, X. In line 234, we use the “optIn’’ switch to control if we need to hijack the transfer. When (optIn == true), X.transfer() invokes Lib.shellcode() to execute the re-entrancy mission. Besides, we have some external functions for easily controlling the X token such as mint(), setup(), and start().

The second component is the Lib.shellcode() function which is called by X.transfer() mentioned earlier. In our experiment, we reenter the borrow() function three times by calling pX[1].borrow() and pX[2].borrow() separately. When pX[2].borrow() is hijacked, Lib.shellcode() invokes pBUSD.borrow() to literally borrow 21k BUSD, which creates an unhealthy loan that is not backed by enough collateral.

The third component is the key to making profit, the liquidator. In the Liquidator.trigger() function above, X tokens are used to liquidate the loan to get the collateral backs (i.e., pCAKE). After that, in line 66–67, pCAKE tokens are converted to CAKE and sent to the owner (i.e., the Lib contract). Besides, mint() is used to provide enough X tokens to the pX contract, which enables the Lib contract to invoke pX.borrow().

Now, the three components are prepared. We can put together all of them and use flashloan to make profits. In the Exp contract above, three X tokens and a Lib contract are created. Inside the constructor of Lib, an instance of Liquidator is created. After minting X tokens (line 272–278) and associating Lib with X tokens (line 280–284), Lib.trigger() is invoked followed by a WBNB transfer to collect profits.

Inside the Lib.trigger(), two consecutive PancakeSwap flash-loans are launched for borrowing 154.5 WBNB + 2,900 CAKE. The real exploit procedure is in the bottom-half of the second pancakeCall().

In the second pancakeCall(), the three X tokens (i.e., x[0], x[1], x[2]) are used to create three pTokens (i.e., pX[0], pX[1], pX[2]). To achieve that, we need to first add liquidity into Uniswap (line 136–142) which could be withdrawn later (line 149). When pTokens are created, Liquidator.mint() is invoked to deposit enough X tokens for later pX.borrow() calls (line 152).

Now, we have all three pTokens prepared. We need to activate them in the DeFiPIE system. Since we will use pCAKE as the collateral, we also activate pCAKE with one Controller.enterMarkets() call (line 162). In line 166, we deposit the 2,900 CAKE borrowed from flash-loan into pCAKE contract as the collateral. From now on, the attacker could borrow assets from DeFiPIE backed by the 2,900 CAKE.

Here, the “optIn” switches of three X tokens are turned on (line 170–172) followed by a pX[0].borrow() call (line 173). With the Lib.shellcode() mentioned earlier, pX[1].borrow(), pX[2].borrow(), and pBUSD.borrow() are reentered consecutively. Eventually, we get the 21k BUSD and create the debt.

Next, we wake up the Liquidator to liquidate the debt and get CAKE back.

After paying back the flash loan, we end up with 66 WBNB.

Cream Finance’s exploit caused by the AMP token integration [8]

On the afternoon of Aug 30, 2021, as we were finalizing this article, CREAM was reported to have been exploited for a loss of $18M [9]. After a preliminary analysis, we find that the attack is similar to the DeFiPIE incident.

Since the logic behind the exploit is the same as that of DeFiPIE, we won’t go into the details here. The attacker exploited the built-in hijacking mechanism of the AMP token, similar to ERC-777, in order to re-enter the borrowing function handler to borrow more assets than allowed, and then liquidate his/her own debt.

The first thing is to register the callback function. Similar to UniswapV1, ERC-1820 is used to register the tokensReceived() function. Whenever someone transfers AMP tokens to the exploit contract, the callback function would be invoked for hijacking.

Inside the tokensReceived() callback function, crETH.borrow() is invoked to get 355 ETH, which are the surplus borrowed assets.

The third component is the Liquidator contract. Similar to the Liquidator used in the DeFiPIE hack, the code snippet above shows how AMP is used to liquidate the debt created by the attacker and get the collateral crETH back (line 60). Later on, crETH is converted into ETH (line 61) and sent to the owner (i.e., the exploit contract) (line 62).

With the above three components prepared, we can launch the attack. In the Exp.trigger() function above, UniswapV2 flash-loan is initiated for 500 WETH (line 94) and uniswapV2Call() is invoked for the real attack:

First of all, we need to prepare a couple of things. crETH requires ETH for minting; whereas what we get from the loan is WETH. In line 105, we unwrap all WETH to ETH. After that, we send all ETH to the crETH contract to mint cTokens. Similar to the DeFiPIE attack, Comptroller.enterMarkets() should be invoked to activate crETH for later steps.

Secondly, AMP tokens are borrowed through crAMP.borrow() backed by the 500 ETH deposited. Due to the ERC-1820 mechanism, the tokensReceived() function would be involved when crAMP transfers AMP tokens to the Exp contract, which borrows another 355 ETH.

Thirdly, the Liquidator contract liquidates part of the debt and gets the collateral back. As shown in the code snippet above, half of the borrowed AMP tokens are sent to the Liquidator for getting just enough ETH to pay back the flash-loan.

In the end, all ETH are wrapped into WETH to pay for the flash-loan. The bad actor walks away with 41 WETH + 9.94M AMP.

Re-entrancy attacks are not new — luckily we have plenty of case studies to learn from in order to reduce the attack surface of blockchain applications. Re-entrancy attacks can be entirely prevented by implementing Checks-Effects-Interactions patterns and re-entrancy guards.

Special thanks to Peiyuan Liao, a STAR-X intern at Amber, for participating in the above Uniswap V1 re-entrancy attack research.

References

[1] https://docs.soliditylang.org/en/v0.4.21/security-considerations.html#use-the-checks-effects-interactions-pattern

[2] https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard

[3] https://docs.soliditylang.org/en/v0.4.21/security-considerations.html#re-entrancy

[4] https://twitter.com/_prestwich/status/1251382098188877824

[5] https://medium.com/consensys-diligence/uniswap-audit-b90335ac007

[6] https://peckshield.medium.com/uniswap-lendf-me-hacks-root-cause-and-loss-analysis-50f3263dcc09

[7] https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol

[8] https://twitter.com/CreamdotFinance/status/1432249771750686721

[9] https://twitter.com/ICO_Analytics/status/1432234014878879744

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.

--

--