Finding Denial of Service Bugs At Scale With Invariant Tests
In this article, we share the techniques used to validate & find Denial of Service bugs on Size Credit's codebase, as well as ideas on how to automate this important check.
During the recent Size Credit Fuzzing Workshop with Code4rena, I shared some of the newest techniques we used to validate issues from manual reviews from Solidified and Spearbit, as well as to find new bugs during an internal review.
In this article, we will summarize these techniques and explain how they can be generalized and automated to help find critical severity vulnerabilities in smart contracts.
Defining a Denial of Service
ComposableSecurity’s SCSVS list of known security problems and vulnerabilities defines Denial of Service as anything that breaks the following high-level requirements:
The contract logic prevents influencing the availability of the contract.
In other terms, a function is prevented from executing when it should not be. The consequences of this failure can vary in terms of likelihood and impact, and thus, in severity, and should be analyzed on a case-by-case basis.
One of the subcategories of a Denial of Service, in particular, G8.11 Verify that there is no DoS caused by overflows and underflows, can be easily caught with fuzz testing techniques.
Instead of only asserting that protocol invariants hold during the success case of a function execution, we can also assert that the function does not revert unexpectedly:
If a revert occurs in important functions responsible for debt reduction or the ability to access funds, such as withdrawal, repayment, or liquidation, it may constitute a critical severity vulnerability.
How others are doing it
While researching for existing patterns for Denial of Service checks, I couldn’t find a consolidated best practice that could catch this type of bug in a scalable way.
As of the time of writing, out of the 12 public invariant test suites from 2024, only 8 implement this type of check.
Most developers that did it, used a generic try/catch block to fail in all circumstances of a handler function revert after checking that the required preconditions had been met. In other cases, developers would just ignore the failure case, since Echidna/Medusa, by default, discards revert transactions.
Preconditions or Postconditions?
Checking for preconditions and reverting on all failures can be seen as the opposite strategy of removing all preconditions and reverting only on specific error cases. If implemented correctly, both approaches would find the same bugs.
The benefit of the former approach is that, in theory, you can early exit from fuzzer calls that do not match the required input validation, making it run faster for scenarios that matter. However, this approach may lead to false negatives or lack of coverage if only the “happy path” is allowed to go through. With the latter approach, a more iterative method is usually developed. A mostly unbiased target function is implemented, failing for all reverts, and then incrementally expected errors are added for the “not happy path” cases.
Although the most common root cause for a Denial of Service is an overflow or underflow, which can be caught by Solidity’s built-in Panic(uint256)
exceptions, developers and security researchers should not focus solely on this type of error.
The reason is that libraries and base contracts can proactively prevent low-level reverts and introduce an additional layer of abstraction through high-level custom errors. This might be the case, for example, when dealing with a mock contract for ERC20 tokens. By using OpenZeppelin’s ERC20 implementation as a test token, you will never see a Panic
error code in your invariant tests. However, this does not mean you are not experiencing a Denial of Service; for instance, funds might be locked in a contract, and it may revert with ERC20InsufficientAllowance
.
Generalization and automation
After considering these two approaches for validating Denial of Service bugs, we suggest an algorithm that can catch this type of issue at scale. In broad terms, we can create the following property:
Denial of Service: Functions should not revert if preconditions are met
As we know, these preconditions are usually part of the input validation piece of code from the “checks-effects-interactions” best practice, or the “function requirements” assertions from the FREI-PI pattern.
By using static analysis tools such as Slither, one can extract all revert strings or Solidity custom errors and construct the ExpectedErrors.sol contract, which can be used as a modifier on the handler’s target functions. After that, it suffices to run the fuzzer and add more errors as they appear on the expected errors array, manually reviewing them one by one and considering if they are valid or not.
Conclusion
In this article, we share the techniques used to validate and find Denial of Service issues on Size Credit’s codebase, as well as ideas on how to automate this important check. We expect fuzzing tools such as Recon to soon support this technique in addition to generic catch-all boilerplates. Subscribe to learn more, as we continue developing new strategies to discover and fix common bugs and vulnerabilities on smart contracts.