Denial of service in a smart contract can happen when bad actors prevent your contract from being able to complete it’s coding logic.
Attackers can do this by taking advantage of external calls and having the calls do unexpected things.
In this scenario, never allow the vulnerable contract to send it ETH.
Code – https://github.com/derekarends/solidity-vulnerabilities/tree/main/denial-of-service
Demo – https://youtu.be/eqI4smy-SGk
Vulnerable Contract
pragma solidity ^0.8.0; /// This contract is vulnerable to a denial of service (DoS) attack contract VulnerableContract { address payable leader; uint256 public highestBid; /// Place a bid to the contract and if it is the highest bid /// send the previous leader the current bid and set the leader /// to the sender with the new highest bid function bid() external payable { require(msg.value > highestBid); // Refund the old leader, if it fails then revert require(leader.send(highestBid)); leader = payable(msg.sender); highestBid = msg.value; } /// Helper function to check leader function getLeader() external view returns (address) { return leader; } }
Here we are assuming the previous leader is good actor and will allow us to refund the ETH they had passed in. However, this assumption can be exploited by an attacker preventing ETH to be sent to the attacker’s contract.
By preventing the ETH from being sent, the vulnerable contract is no longer able to update new leaders even if they pass in more ETH.
Attacker Contract
pragma solidity ^0.8.0; /// This contract will attack the vulnerable contract to demo DoS contract AttackerContract { VulnerableContract vulnerableContract; constructor(VulnerableContract _vulnerableContract) { vulnerableContract = _vulnerableContract; } /// Prevent this contract from taking ETH fallback() external payable { revert(); } /// Place a bid to the vulnerable contract function placeBid() external payable { require(msg.value > vulnerableContract.highestBid()); vulnerableContract.bid{value: msg.value}(); } }
The attacker contract is pretty straight forward. It will allow the attacker to place a bid but not accept any ETH to be sent to it.
By adding the fallback function and having it revert the transaction any time ETH is sent to this contract it will revert the transaction, not accepting any ETH.
Preventing DoS
To prevent your contracts from being vulnerable to a denial of service attack you can implement the concept of pull payments.
Pull payments are used to allow users to withdraw their refunds instead of having the contract send them out. This will keep attackers from being able to exploit the contract and do a DoS on the contract.
Fixed Contract
pragma solidity ^0.8.0; /// This contract is demostrates how to prevent a denial of service (DoS) attack contract SecureContract { address payable leader; uint256 public highestBid; mapping(address => uint256) refunds; /// Place a bid to the contract and if it is the highest bid /// send the previous leader the current bid and set the leader /// to the sender with the new highest bid function bid() external payable { require(msg.value > highestBid); refunds[leader] += highestBid; leader = payable(msg.sender); highestBid = msg.value; } /// Helper function to check leader function getLeader() external view returns (address) { return leader; } /// Allow senders to retrieve their refunds function withdrawRefund() external payable { require(refunds[msg.sender] > 0, "No refunds available"); (bool success, ) = msg.sender.call{value: refunds[msg.sender]}(""); require(success, "Failed to transfer refund"); } }
This contract implements the pull payment system, where users will have to call the withdrawRefund
function to get their refunds instead of having them sent when a new leader takes over.
One Last Note
Do not have the withdraw do any type of looping over addresses that have refunds.
// Store all addresses for refund address[] refundAddresses; /// Allow all senders to retrieve their refunds function withdrawAllRefunds() external payable { for(uint256 i; i < refundAddresses.length; i++) (bool success, ) = refundAddresses[i].call{value: refunds[refundAddresses[i]}(""); require(success, "Failed to transfer refund"); } }
By looping over all the addresses, if a single contract reverts the transaction, all transactions are reverted and the funds are now stuck in the contract.