Welcome back to the "Solodit Checklist Explained" series. We're continuing our exploration of the Solodit smart contract audit checklist, focusing on practical strategies for writing secure and resilient smart contracts.
This checklist helps you identify potential vulnerabilities and implement proactive security measures. In our previous installment, "Solodit Checklist Explained (1)", we studied three checklist items (withdrawal pattern, minimum transaction amount, and blacklisting) related to Denial-of-Service (DoS) attacks and provided solutions for developers. If you haven't already, I recommend reviewing that article before proceeding.
In this post, we'll address three additional items from the checklist, again focusing on DoS attacks. We'll delve into queue processing vulnerabilities, the challenges presented by low-decimal tokens, and the importance of handling external contract calls safely.
For the best experience, open a tab with the Solodit checklist to refer to it.
Here's what we'll cover today:
Real-world exploits have demonstrated the potential damage these vulnerabilities can inflict. Projects have suffered substantial losses due to seemingly minor oversights.
The Solodit checklist is based on audit findings, bug bounty reports, and real-world incidents. By studying these checklist items, you can learn from past mistakes and improve the security of your code.
Now, let's examine each checklist item using illustrative code examples. These examples are simplified to highlight the core vulnerabilities.
The problem: If your smart contract relies on queues for task processing, an attacker might manipulate a specific status within the queue to prevent correct processing.
The checklist question: "Can an attacker block or prevent queue processing to cause service disruption?"
The fix: Your queue processing mechanism requires robust error handling and fallback mechanisms to ensure process continuation even when issues arise.
Example:
Consider a withdrawal queue example:
resetUserStatus
after their withdrawal is enqueued and DoS other users from doing the same. // VULNERABLE FUNCTION: Can be exploited
function resetUserStatus() external {
// Anyone can reset their status while remaining in the queue
withdrawalRequested[msg.sender] = false;
// Note: User is not removed from the queue!
}
// Process the next withdrawal in the queue
function processNextWithdrawal() external {
require(withdrawalQueue.length > currentIndex, "No withdrawals to process");
// Get the next withdrawal
Withdrawal memory withdrawal = withdrawalQueue[currentIndex];
// VULNERABLE: This check can be exploited by an attacker by resetting the status
require(withdrawalRequested[withdrawal.user], "Withdrawal no longer requested");
// Process the withdrawal
uint256 amount = withdrawal.amount;
require(balances[withdrawal.user] >= amount, "Insufficient balance");
// Update balance
balances[withdrawal.user] -= amount;
// Reset withdrawal request
withdrawalRequested[withdrawal.user] = false;
// Send funds
(bool success, ) = payable(withdrawal.user).call{value:amount}("");
require(success, "Failed to send funds");
// Move to next in queue
currentIndex++;
}
To exploit this, an attacker can do the following:
resetUserStatus
function.processNextWithdrawal
function will revert, causing an ongoing DoS attack.Remediation:
withdrawalRequested
status to the admin.The problem: Tokens with a low number of decimals can lead to issues with integer division, resulting in rounding down to zero.
Imagine a token streaming contract that distributes tokens over a period. If tokensPerSecond
rounds to zero due to integer division with low-decimal tokens, the distribution function will be blocked.
The checklist question: "Can low decimal tokens cause DoS?"
The fix: Implement logic to handle low decimals that prevents breaking the transaction process due to rounding errors.
Example:
Consider a TokenStream
contract that streams a certain amount of tokens to users, where:
total_tokens
need to be transferred to the contracttoken_per_second
can be rounded to zero, since we are using a token with 1 decimaldistributeTokens
function will revert
contract TokenStream {
IERC20 public token;
uint256 public streamDuration;
uint256 public tokensPerSecond;
constructor(IERC20 _token, uint256 _streamDuration, uint256 _tokensPerSecond) {
token = _token;
streamDuration = _streamDuration;
tokensPerSecond = _tokensPerSecond;
}
function distributeTokens(address recipient) external {
uint256 balance = token.balanceOf(address(this));
uint256 amount = tokensPerSecond * streamDuration;
uint256 tokensToSend = amount > balance ? balance : amount;
require(tokensToSend > 0, "Insufficient tokens to stream");
token.transfer(recipient, tokensToSend);
}
}
contract LowDecimalToken is ERC20 {
constructor() ERC20("LowDecimalToken", "LDT") {
_mint(msg.sender, 100000 * (10 ** decimals()));
}
function decimals() public view virtual override returns (uint8) {
return 1; // Simulate a low decimal token
}
}
The testDOSWithLowDecimalTokens
test in TokenStreamTest
will revert in this case.
Remediation: Ensure the contract handles low decimal tokens correctly by scaling math formulas to mitigate integer rounding during calculations.
The problem: Many smart contracts rely on external contracts for interactions. Unexpected behaviors from external contracts can cause the whole system to revert. Failing to handle these external errors leads to a DoS vulnerability.
The checklist question: "Does the protocol handle external contract interactions safely?"
The fix: Ensure robust error handling for external contract interactions to protect protocol integrity regardless of external contract performance.
Example: Consider a contract that interacts with an external Chainlink price feed. Without proper error handling using try/catch, any revert from the external feed will cascade upward, and the contract will revert.
getPrice
function retrieves the price feed data.calculateSomethingImportant
function depends on getPrice
and will also revert.contract PriceDependentContract {
AggregatorV3Interface public priceFeed;
constructor(address _priceFeed) {
priceFeed = AggregatorV3Interface(_priceFeed);
}
// Vulnerable function that retrieves the price without handling potential Chainlink reverts
function getPrice() public view returns (uint256) {
(, int256 price, , , ) = priceFeed.latestRoundData(); // Vulnerable line: No error handling
require(price > 0, "Price must be positive");
return uint256(price);
}
function calculateSomethingImportant() public view returns (uint256) {
uint256 price = getPrice();
// ... some important calculation using the price
return price * 2;
}
Remediation: Wrap external contract calls in try/catch blocks to handle reverted errors and implement a fallback or cached value.
NOTE: There is an edge case where the external contract spends gas intentionally, which can make the catch block fail, too! We will discuss this later.
We've explored three critical checklist items designed to strengthen your smart contracts against DoS attacks: queue processing vulnerabilities, the challenges associated with low decimal tokens, and the importance of handling external contract interactions safely.
Remember, the provided examples aim to illustrate core vulnerabilities. Understanding the underlying principles and adapting these concepts to your specific use cases is essential.
By implementing these recommendations, you can significantly improve the security and resilience of your smart contracts.
Stay tuned for the next installment of the "Solodit Checklist Explained" series.