Solidity Attack Vectors: #6 - Denial Of Service With Unexpected Revert(Pull Over Push Design Pattern)
Table of contents
In my last article in this series, I introduced the DoS(Denial Of Service) attack on smart contracts and blockchain. In this article, I will be discussing another form of DoS attack on solidity smart contracts- "DoS With Unexpected Revert".
Reverts In Smart Contracts
Reverts happen when an error occurs in the execution of smart contract execution, this error can include failed logic or smart contracts bugs. The EVM handles these errors with two OPCODES, "REVERT"
- "0xFD"
and "INVALID"
- "0xFE"
. The REVERT opcode stops the execution of the smart contract and returns back the remaining gas of the call. The INVALID opcode also stops execution but doesn't return any gas.
Unexpected Reverts
There are several reasons why a smart contract may fail, and an attacker can exploit vulnerabilities in your smart contract if you don't implement thorough checks and bug-free logic. This can cause unexpected reverts and denial of service for users.
Now let's take a look at the following contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
contract Bidder {
uint256 highestBid;
address currentHighestBidder;
function bid() external payable {
require(msg.value > highestBid, "Bid higher");
require(payable(currentHighestBidder).transfer(highestBid)); // ensure the bid is sent back, else revert the transaction
highestBid = msg.value;
currentHighestBidder = msg.sender;
}
}
The "Bidder" contract allows users to make a bid. It checks if the bid is higher than the previous bid amount and reverts if it is not. It then transfers the previous highest bid to the previous highest bidder and sets the user with the highest bid as the currentHighestBidder
and their bid as the highestBid
. It's simple logic, but there is a flaw in this design. What if the highest bidder is a contract that does not have a payable receive or fallback function? This means that any value transfer (Ether) to it will fail. An attacker can bid with a contract that does not accept value, and when another user tries to outbid them, the Ether transfer will fail, causing the contract to "revert" and the Ether stuck.
Precautions
Instead of directly sending Ether to the contract, it is better to use the "Pull Over Push" design pattern. This means that users can withdraw their value (pull) through a withdraw function, rather than having the value sent to them (push).
Our Bidder contract can be rewritten in this way:
contract BidderTwo {
uint256 highestBid;
address currentHighestBidder;
mapping(address => uint) bidBalances;
function bid() external payable {
require(msg.value > highestBid, "Bid higher");
bidBalances[msg.sender] += msg.value;
highestBid = msg.value;
currentHighestBidder = msg.sender;
}
function withdraw() external {
require(msg.sender == currentHighestBidder, "Not highest bidder");
payable(currentHighestBidder).transfer(highestBid);
}
function withdrawLessBid() external {
require(
msg.sender != currentHighestBidder,
"Highest Bidder can't withdraw"
);
uint256 balance = bidBalances[msg.sender];
require(balance > 0, "No bid amount");
payable(msg.sender).transfer(balance);
}
}
Now, we have introduced a new variable and a function in our contract. The bidBalances
variable is used to keep track of each user's bids, and the withdrawLessBid
function allows users with fewer bids to take their bids, but it restricts the current highest bidder from making their bids. With this method, even if the previous highest bidder is a contract that cannot accept Ether since we are using the "pull over push" design pattern, their Ether is left in our contract but it doesn't affect our function flow or cause any reverts in the bid function.
Conclusion
Unexpected reverts can occur for various reasons when working with smart contracts. It is important to implement good design patterns and favor the "push over pull" design pattern when handling value transfer.
I will continue this series in the next article. See you then!