Preventing Denial of Service Attacks in Solidity

Posted on

Solidity security is a critical concern for anyone building smart contracts on the Ethereum blockchain. One of the biggest security risks for contracts is the potential for Denial of Service (DoS) attacks. Malicious users can intentionally cause a smart contract to consume more resources than intended, or make it unresponsive, unavailable or cause calls to it to fail. These attacks can be costly and can disrupt the operations of a contract, leading to financial losses and other negative consequences.

In this post, we’ll cover best practices to prevent DoS attacks in Solidity and ensure the security and stability of the contracts that you’ll use, so these attack vectors are avoided. We’ll explore some examples of DoS attacks in Solidity and how to prevent them.

Recursive or unbounded calls

Recursive calls are a common type of attack in Solidity. They occur when a contract calls a function that in turn calls the same or other functions or statements sequentially, creating an infinite loop that consumes all of the caller’s gas. This type of attack can cause a contract to work unexpectedly, since the original call never concludes. However, if a transaction reverts due to an out-of-gas error, the Ethereum network does not reimburse the caller for the drained funds.

For example:

function drain() public {
    uint256 i = 0;
    // this will cause all of the remaining gas units to be spent and the transaction to fail
    while (true) {
        i += 1;
    }
}

Even if your contract does not implement such a call, an external contract called by it can, causing your contract logic to fail.

In order to prevent recursive calls on your own contracts, see our post on Solidity Re-Entrancy Prevention.

Contract Logic exploitation

Most DoS attacks however, will come in the form of exploiting flaws in contract logic or its limitations in order to make it unusable.

For example: if some contract functions can only be called by a single address, and the owner of this account refuses to act based on malicious motives, the contract can become unusable. Similarly, a privileged actor can act in bad faith to halt the contract by changing its parameters, pausing its logic, and so on.

However, some attacks do not rely on authorization, but rather on the way the contract code is written. See an example of this down below.

Preventing DoS Attacks

Contract Trust

Callers can attempt to detect hidden recursive calls or statements that will cause a revert by inspecting the contract that they are calling, and evaluating their level of trust in them. Tools like Etherscan provide smart contract code verification, but they require a good understanding of the language.

In general, you should not call external contracts that you do not fully trust. Similarly, avoid granting privileges to accounts that are untrusted. Alternatively, consider using a multisig wallet for admin-like actions.

Setting a gas limit

One way to prevent unexpected gas drainage through recursive calls is to use a gas limit. A gas limit sets a maximum amount of gas units that a transaction can consume. In this case, even if a malicious function is designed to drain the gas of the call that was invoked, it cannot spend any more gas units than what the caller specified.

See also  PayPal Dives Into Crypto: Support for Stablecoin Transactions Announced

Older tools allowed you to set specific gas limits for each transaction, but these needed to be invoked at a lower level (see gasLimit field in the example here). More modern tools like hardhat allow you to configure specific gas limits for calls globally, to ensure that no more than a determined amount of gas units can be spent in the entire transaction. If there is not enough gas unit, calls will revert and prevent the transactions from consuming more gas than intended. However, the Ethereum network still deducts the gas spent up to that point, resulting in a loss of gas.

Example: Preventing a DoS attack on the King of the Hill Smart Contract

King of the Hill is a simple game built on top of a smart contract. Players have the ability to add funds to the contract, with the player who deposits the highest amount becoming the current king and the previous king receiving their funds back. While the game may appear to be straightforward, malicious actors can exploit potential security risks.

contract KingOfTheHill {
    address public king;
    uint256 public pot;

    function play() external payable {
        require(msg.value > pot, "Must pay more to become king of the hill");

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

        pot = msg.value;
        king = msg.sender;
    }
}

One attack scenario involves a malicious player creating a contract that blocks anyone else from becoming a new king. Here’s how it works:

  1. A player named Alice adds 1 ether, and becomes king.
  2. A player named Bob adds 2 ether, becoming the new king, and giving Alice her ether back.
  3. A malicious player deploys a contract called “Attack” with the address of the KingOfTheHill smart contract.
  4. The malicious player calls the play function on the contract from the “Attack” contract, depositing 3 ether.
  5. Since the “Attack” contract does not have a fallback function, it cannot receive ether. Any other player attempting to call play with more ether will cause a revert, indefinitely halting the game.

To prevent this attack, developers can modify the King of the Hill smart contract to utilize a withdrawal pattern that enables players to withdraw their winnings instead of having them sent directly from the play function. Here’s an example implementation:

contract KingOfTheHill {
    address public king;
    uint public pot;
    mapping(address => uint) public balances;

    function play() external payable {
        require(msg.value > pot, "Must pay more to become king of the hill");

        king = msg.sender;
        balances[king] += msg.value;
        pot = balances[king];
    }

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

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

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

In this implementation, instead of being refunded directly from the contract when other players become kings, they can withdraw their winnings after being dethroned by calling the play function.

By using this implementation, the contract is more secure and less susceptible to attacks.

Conclusion

DoS attacks can be costly and disruptive for Solidity contracts. However, there are steps that you can take to prevent them. By using gas limits, calling trusted contracts and applying coding principles, you can mitigate the risk of DoS attacks and ensure the security and stability of your contracts.

Posted in Blockchain, Ethereum, Smart Contract, Solidity, TechnologiesTagged , ,

Martin Liguori
linkedin logo
twitter logo
instagram logo
By Martin Liguori
I have been working on IT for more than 20 years. Engineer by profession graduated from the Catholic University of Uruguay, and I believe that teamwork is one of the most important factors in any project and/or organization. I consider having the knowledge both developing software and leading work teams and being able to achieve their autonomy. I consider myself a pro-active, dynamic and passionate person for generating disruptive technological solutions in order to improve people's quality of life. I have helped companies achieve much more revenue through the application of decentralized disruptive technologies, being a specialist in these technologies. If you want to know more details about my educational or professional journey, I invite you to review the rest of my profile or contact me at martin@infuy.com