Cross-Contract Reentrancy Attack

Inspex
7 min readMar 31, 2022

--

Reentrancy attack is one of the most common attacks on EVM-based smart contracts. It is an attack with devastating damages, which can be seen in many past incidents such as:

Most reentrancy attacks are done by reentering the same function (Single Function Reentrancy) it is called from; however, there are also other variations that are harder to discover and prevent. In this article, we will show you what Cross-Contract Reentrancy is, how impactful can it be, and how can you prevent it. We also have a hands-on lab that you can follow along to learn about this vulnerability more in detail.

Type of Reentrancy

  • Single Function Reentrancy
  • Cross-Function Reentrancy
  • Cross-Contract Reentrancy

The first two variations are commonly found, the examples can be discovered in Consensys’s Ethereum Smart Contract Best Practices. What we are going to focus on is the third one, Cross-Contract Reentrancy.

Cross-Contract Reentrancy

Cross-contract reentrancy can happen when a state from one contract is used in another contract, but that state is not fully updated before getting called.

The conditions required for the cross-contract reentrancy to be possible are as follows:

  1. The execution flow can be controlled by the attacker to manipulate the contract state.
  2. The value of the state in the contract is shared or used in another contract.

This kind of vulnerability has been used in multiple past attacks, for example:

Example: Simple Vault and an ICO

As an example, take a look at this simple Vault contract we implemented as a demonstration.

Note: The router smart contract is implemented using UniswapV2’s implementation

Vault.sol

The users can deposit the base token and get VaultToken ($VT) that act as the users’ shares of the tokens in the vault.

Without the work() and harvest() functions implemented, the ratio between the number of shares and the amount of base token in the vault will always be 1:1 unless the base token is manually transferred into the vault.

In the Vault contract, the contract itself is safe from reentrancy attack, as a mutex lock (nonReentrant modifier) is used, so no attacker can do anything to drain the tokens from this contract.

We have also implemented another simple ICO contract that allows the users to exchange $VT to mint a new token, $GOV. The number of token gained is determined by the value of $VT and the price of token specified in the smart contract.

ICOGov.sol

Can you see the flaw in these smart contracts?

Hint: Is it possible to manipulate the share value of $VT?

You can stop here and read through the smart contracts above carefully, or scroll down to see the explanation of the vulnerability.

Let’s start going deep into the code!

Hijacking the Execution Flow

One of the first things that we should notice in the Vault contract is that we can control the _srcToken address and _destToken address parameters in the swapAndDeposit() and withdrawAndSwap() functions respectively.

Vault.sol

The address is then passed through the path parameter into the router.swapExactTokensForTokens() function, which is a common token swapping function for a UniswapV2-based router smart contract. The share ($VT) is then minted or burned after the swapping is done.

Moreover, in the swapAndDeposit() function, the _srcToken.approve() function is called. As we can control the address of _srcToken, it is possible to hijack the execution flow before and after the token is swapped!

Using States from Another Contract

From the ICOGov contract, the shareToAmount() function from the Vault contract is called in the buyToken() function to determine the value of $VT in terms of the base token.

ICOGov.sol

The shareToAmount() function calculates the token amount using the balance of the base token in the contract and the total supply.

Vault.sol

In this case, if we can inflate the balance of the base token inside the Vault contract without increasing the total supply, we can inflate the value for each share.

Directly transferring the base token into the contract is one of the options; however, by transferring directly, the token will be shared among the $VT holder, so a part of the funds will be lost.

Combining the Two

Remember what we found out in the previous part? We can hijack the execution flow before and after the swapping of the token!

What if we use the swapAndDeposit() function and take control of the execution flow after the base token is successfully swapped and sent into the Vault, but $VT is not yet minted? The value of $VT will be inflated at that moment, and we can call ICOGov.buyToken() to mint more token using the inflated value!

Let’s take a look at this code again.

Vault.sol

At line 48 (12 in the gist), the token is swapped and transferred back to the vault, this means that after this line, the base token balance will increase, while the total supply is not updated, as $VT is not minted yet.

And at line 50 (14 in the gist), the approve() function of _srcToken is called. As we can control the address of the token, we can implement a malicious token with a special approve() function that performs a reentrant calling to ICOGov.buyToken() when called at this specific point.

Let’s Try Hacking It!

As we want to hijack the execution flow using a malicious token contract, let’s implement it!

We have implemented the smart contracts, you can follow along using this repository: https://github.com/InspexCo/cross-contract-reentrancy

Starting as a standard ERC20 token, we can create a contract and inherit OpenZeppelin’s ERC20 contract implementation.

EvilERC20.sol

We want to implement a custom approve() function that triggers at a specific point in the Vault contract, so we can start with the baseline, the original implementation of the approve() function.

EvilERC20.sol

We want it to trigger here at line 50 (14 in the gist), and we know that the Vault contract will be the caller, and the approval amount will be zero; therefore, these can be used as the condition to trigger the reentrancy to the ICOGov contract.

Vault.sol

We can implement the conditional trigger by checking that the address of the msg.sender matches the vault address, and the approval amount is equal to zero.

EvilERC20.sol

With the contract above, we can now hijack the execution flow at the location we want to. Next, we need to prepare the $VT needed to buy the token, and call the ICOGov.buyToken() function. We can do that by allowing the contract to transfer $VT from attacker’s wallet to EvilERC20, approve the transfer, then buy the token.

EvilERC20.sol

Don’t forget to transfer the tokens bought back to the attacker’s wallet! And as we also need to prepare the liquidity pool, let’s mint some token to the attacker’s wallet too!

EvilERC20.sol

We are now done with the evil token contract. We can deploy it and perform the attack.

Assuming that the baseToken is $USDT, the steps to perform are as shown in the following diagram (some minor details are omitted):

Cross-Contract Reentrancy Attack diagram
  1. Deploy the EvilToken contract
  2. Prepare $USDT and call Router.addLiquidity() to create a $EVIL-USDT Pair (Approve the transfer of $EVIL and $USDT for Router first)
  3. Call Vault.deposit() to get $VT for buying $GOV (Approve the transfer of $USDT for Vault first)
  4. Call Vault.swapAndDeposit() with the EvilToken as the _srcToken (Approve the transfer of $VT for EvilToken first)

With these steps, the value of $VT will be inflated depending on the amount deposited in step 4, allowing the attacker to buy $GOV at a lower cost.

Furthermore, using a similar method, a flashloan can also be used to largely increase the impact of this vulnerability, for example:

  1. Flashloan a large sum of $USDT
  2. Deposit $USDT
  3. Inflate the value of $VT and perform a reentrant calling to buy $GOV
  4. Sell $GOV in the open market
  5. Withdraw $USDT used in the inflation of $VT value
  6. Return the flashloaned $USDT and profit from the $GOV sold

Solutions to Prevent Reentrancy

For the first two variations, Single Function Reentrancy and Cross-Function Reentrancy, a mutex lock can be implemented in the contract to prevent the functions in the same contract from being called repeatedly, thus, preventing reentrancy. A widely used method to implement the lock is inheriting OpenZeppelin’s ReentrancyGuard and use the nonReentrant modifier.

The better solution is to check and try updating all states before calling for external contracts, or the so-called “Checks-Effects-Interactions” pattern. This way, even when a reentrant calling is initiated, no impact can be made since all states have finished updating.

Another alternative choice is to prevent the attacker from taking over the control flow of the contract. A set of whitelisted addresses can prevent the attacker from injecting unknown malicious contracts into the contract in this lab.

Nevertheless, the contracts that integrate with other contracts, especially when the states are shared, should be checked in detail to make sure that the states used are correct and cannot be manipulated.

About Inspex

Inspex is formed by a team of cybersecurity experts highly experienced in various fields of cybersecurity. We provide blockchain and smart contract professional services at the highest quality to enhance the security of our clients and the overall blockchain ecosystem.

For any business inquiries, please contact us via Twitter, Telegram, contact@inspex.co

--

--

Inspex

Cybersecurity professional service, specialized in blockchain and smart contract auditing https://twitter.com/InspexCo