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.
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:
- A player named Alice adds 1 ether, and becomes king.
- A player named Bob adds 2 ether, becoming the new king, and giving Alice her ether back.
- A malicious player deploys a contract called “Attack” with the address of the KingOfTheHill smart contract.
- The malicious player calls the
play
function on the contract from the “Attack” contract, depositing 3 ether. - 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, Technologies