Denial of Service (DoS) Attack on Smart Contracts

5/5 - (3 votes)
Solidity - Denial of Service (DoS) Attack on Smart Contracts

This post is part 5 of our Smart Contract Security Series:

  1. Ownership Exploit
  2. Private Variable Exploit
  3. Reentrancy Attack
  4. tx.origin Phishing Attack
  5. Denial of Service Attack
  6. Storage Collision Attack
  7. Randomness Attack
  8. Replay Attack

The post discusses the Denial of Service or alias the DoS attack.

The Denial of Service (hence referred to as DoS) restricts legitimate users from using the smart contracts permanently or for a certain period unusable.

In the blockchain, DoS attacks are of three types, namely:

  • Unexpected Revert,
  • Block Gas Limit, and
  • Block Stuffing.

For this post, the Unexpected Revert will be in detail with an example, and for the other two, a summary highlighting how such types of attacks can be made possible. Let’s begin!

Exploit Idea – Unexpected Revert DoS

The attacker uses a smart contract to make such an exploit.

To explain how an Unexpected Revert can cause the DoS, consider the following Auction smart contract which allows the bidders to make a bid, and as soon as there is a new highest bidder, it refunds the amount back to the old bidder.

Auction.sol

contract Auction {
    address frontRunner;
    uint256 highestBid;

    function bid() public payable {
        require(msg.value > highestBid, "Need to be higher than highest bid");
        // Refund the old leader, if it fails then revert   
        require(payable(frontRunner).send(highestBid), "Failed to send Ether");
 
        frontRunner = msg.sender;
        highestBid = msg.value;
    }
}

Attack.sol

import "./Auction.sol";   
contract Attacker{
    Auction auction;

    constructor(Auction _auctionaddr){
        auction = Auction(_auctionaddr);
    }

    function attack (){
        auction.bid{value: msg.value}();
    }

}

The contracts are simple and described below.

Auction.sol

The Auction contract defines a function called bid(), in which it checks the following conditions:

  • If the bid amount is > highestBid
  • Refund the previous bid amount back to the previous bidder

If both the conditions are satisfied, then it updates the highest bid price and the highest bidder (frontRunner) with the new values.

Attack.sol

  • The Attacker contract gets the deployed address of the Auction contract and initializes it in the constructor so that the attacker can access the functions of the Auction contract.
  • The function attack() calls the bid() function of the Auction contract to make a bid.

For the compilation and deployment of contracts refer to the Truffle post here.

How did the exploit occur? Let’s see it in steps.

Let’s say there are users who start making a bid.

  1. User1 makes a bid for ‘3’ ether and thus will be the frontRunner.
  2. User2 makes a bid for ‘5’ ether and thus will take over the role of frontRunner and User1 is refunded back.
  3. User3 makes a bid for ‘6’ ether and thus will be the new frontRunner and User2 is refunded back.
  4. The attacker calls the contract attack() function and makes a bid with,say, ‘7’ ether. The attacker contract will be the new frontRunner and User3 is refunded back.
  5. Now, if any other user makes a call to bid() function, the refund to the attacker contract will fail. This is because the Attacker contract has not implemented the receive() or fallback function to receive ether. Due to this any Solidity ether transfer function such as call(), send() or transfer() will result in an exception or unexpected revert due to the require() statement, stopping the execution.

In such a scenario the attacker contract will be the undisputed king or the highest bidder all the time, thus exploiting the system.

Summary (Block Gas Limit and Block Stuffing)

Block Gas Limit

In the case of Block Gas Limit, the transaction hits a higher gas limit than the Max Limit available, resulting in the transaction failure.

If such a transaction fails, especially when you are in a loop to refund the amount back to the owners, it stops execution, resulting in the blocking of refunds at all and the funds are stuck forever.

A for loop example that can hit a Block gas limit.

This can happen unintentional or let’s say a bad actor decides to create a large number of addresses, each receiving a little amount of fund from the smart contract.

If done correctly, the transaction can be blocked permanently, potentially even preventing additional transactions.

address[] private refundAddresses;
mapping (address => uint) public refunds;
function refundAll() external onlyOwner {
   // unknown length iteration based on how many addresses participated
    for(uint i; i < refundAddresses.length; i++) {
   // doubly bad, now a single failure on send will hold up all funds
       require(refundAddresses[i].send(refunds[refundAddresses[i]]))
    }
}
Fig: Tx Block Gas Limit

For the mainnet the gas limit is 8000000, for the Ropsten network the gas limit is 5500000, for the local Ganache network (in Truffle) the default block gas limit is 90000.

Block Stuffing

An attacker can fill multiple blocks in the blockchain preventing other transactions from being included in any of the blocks.

This can be achieved if the attacker uses computationally intensive transactions and places a very high gas price to ensure that only his transactions are included in the blocks.

This technique of attack was used in the gambling app – Fomo3D.

Solution

The solution to prevent the DoS attacks, is to move from a push model to the pull model for the payment.

In the above case, the bid() function has a push model to refund the previous bidder. This can be changed to the pull model by using a withdraw() function as follows:

contract Auction {
    address frontRunner;
    uint256 highestBid;
    mapping(address => uint) public balances;

   function bid() public payable {
      require(msg.value > highestBid, "Need to be higher than highest bid");

      // update previous bidder info
      balances[frontRunner] += highestBid;

      // update new bidder
      frontRunner = msg.sender;
      highestBid = msg.value;
   }


   function withdraw() public nonReentrant {
        require(msg.sender != frontRunner, "Current frontRunner cannot withdraw");

        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;

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

The pull model can also be used in the Block Gas Limit type. The RefundAll() is replaced by the withdraw() such that only a single user is allowed to withdraw at a time.

Conclusion

This post analyzed the DoS attacks and their implications, followed by the demonstration of how the attack occurs, and finally the possible solution to prevent such attacks.

Good luck preventing such hacking attempts—I hope to have increased your odds of doing so in this post! 🙂


Learn Solidity Course

Solidity is the programming language of the future.

It gives you the rare and sought-after superpower to program against the “Internet Computer”, i.e., against decentralized Blockchains such as Ethereum, Binance Smart Chain, Ethereum Classic, Tron, and Avalanche – to mention just a few Blockchain infrastructures that support Solidity.

In particular, Solidity allows you to create smart contracts, i.e., pieces of code that automatically execute on specific conditions in a completely decentralized environment. For example, smart contracts empower you to create your own decentralized autonomous organizations (DAOs) that run on Blockchains without being subject to centralized control.

NFTs, DeFi, DAOs, and Blockchain-based games are all based on smart contracts.

This course is a simple, low-friction introduction to creating your first smart contract using the Remix IDE on the Ethereum testnet – without fluff, significant upfront costs to purchase ETH, or unnecessary complexity.