Alright, security seekers! Hans here, ready to dive into our first tasty slice of the "Solodit Checklist Explained" pie?
If you're new to the feast, welcome! This series is about turning the Solodit smart contract audit checklist, all 380+ items, into something you can actually use.
In the Preface (Part 0), we set the stage, promising a journey packed with practical examples and stories from the trenches.
Today, we're looking at one of those sneaky issues that makes security experts facepalm during audits or after contests because it's so common yet so easily overlooked when you're deep in the code.
I'm talking about Denial-of-Service (DoS) attacks!
We'll be dissecting three checklist items:
So, grab your favorite brain-boosting beverage, buckle up, and let's kick this show into high gear! For the best experience, open a tab with the Solodit checklist to refer to it.
Aren’t DoS attacks just a problem for traditional, centralized servers? Something for the big corporations to fret about, right? Wrong!
The truth is that in decentralized finance (DeFi), DoS attacks can be devastating. Think of this nightmare scenario: your painstakingly crafted staking contract, designed to reward your loyal users generously, suddenly… it stops.
It grinds to a halt because some malicious actor is flooding it with transactions. Users can't withdraw their funds, and the entire system becomes frustratingly unusable. What about your reputation? Well, it takes a nosedive faster than a memecoin after a rug pull (and we all know how that feels).
DoS attacks are about exploiting vulnerabilities in your code, turning your smart contract unusable, either for specific users or for everyone. They're like digital roadblocks, preventing legitimate folks from accessing your protocol's well-intentioned services! In a space where trust is everything, a successful DoS attack can be absolutely catastrophic.
To illustrate, think of it like this: You've poured your heart and soul into building a beautiful coffee shop. It's the best coffee shop in the world. But then someone comes and keeps ordering thousands of empty cups of coffee, clogging up the entire system and preventing real, paying customers from getting their caffeine fix. Infuriating, right? That's a DoS attack in a nutshell!
DoS attacks are usually about leveraging design flaws in your smart contract logic. It's not brute force. They are using subtle manipulation to beat you.
Here's a breakdown of the general idea:
Now that we've briefly covered the high-level view (and hopefully convinced you that DoS attacks are a real threat), let's plunge into some concrete ways to mitigate them.
Translation: Are you using the "pull" instead of the "push" pattern for withdrawals?
The classic mistake (and one I've definitely seen way too many times in audit contests) is to push ETH to users in a withdrawal function. Seems simple enough, right? Dead wrong! Let's look at some code:
// Anti-pattern: Pushing ETH
function batchWithdraw() public {
address[] memory users = getUsers(); // imaginary function to get users
for (uint i = 0; i < users.length; i++) {
uint amount = balances[users[i]];
if (amount > 0) {
balances[users[i]] = 0;
(bool success, ) = users[i].call{value: amount}(""); // Potential DoS!
require(success, "Transfer failed"); // If any transfer fails, the entire batch reverts
}
}
}
Why is this so bad?
If msg.sender
is a contract that reverts on receiving ETH (for whatever reason – maybe they're malicious), the entire withdraw
function will revert for everyone! This creates a real DoS situation, as legitimate users are unable to withdraw funds because of a single failing address. Imagine the chaos!
The fix: the pull pattern
Instead of pushing, let users pull their tokens. They initiate the transfer from the contract. It's like giving them the key to the vault. It looks like this:
// Pull Pattern: Much Safer!
mapping(address => uint) public withdrawableBalances;
// Step 1: Admin marks funds as withdrawable without actually sending them
function startBatchWithdrawal() public {
address[] memory users = getUsers(); // imaginary function to get users
for (uint i = 0; i < users.length; i++) {
uint amount = balances[users[i]];
if (amount > 0) {
balances[users[i]] = 0;
withdrawableBalances[users[i]] += amount;
}
}
}
// Step 2: Each user withdraws their own funds individually
function withdraw() public {
uint amount = withdrawableBalances[msg.sender];
require(amount > 0, "No funds to withdraw");
withdrawableBalances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Now, if a user's withdrawal fails, it only affects them. The contract continues to function for everyone else. That's decentralization done right! The require statement checks if the transfer was successful. If unsuccessful, the transaction is reverted only for the requesting user, having a minimal impact on ongoing operations.
Example: The example provided in the checklist item details a scenario where fees are transferred to the owner before the user's withdrawal. If the owner's address is accidentally set to the zero address, or if the owner is, ahem, a mischievous contract that reverts on token transfer, user withdrawals will fail. Ouch. That's a nasty one to debug! The minimal example and PoC written in Foundry are available here.
Checklist power: Simply asking, "Are we using the pull pattern?" during a code review could have flagged this DoS vulnerability before it went live. Prevention is always better (and cheaper) than cure.
Translation: Are you preventing "dust" transactions (tiny, insignificant amounts) from clogging up your contract and making everything miserable?
This one's all about preventing spam, plain and simple. If your contract allows users to interact with it for any amount (even tiny, insignificant fractions of tokens), attackers can flood it with countless zero-value or near-zero-value transactions. All these transactions take up gas and make legitimate operations far more expensive. This increased cost can potentially reach the block gas limit. If that happens, legitimate users wouldn't be able to interact with your protocol. Here’s how vulnerable code looks like:
// Vulnerable to dust attacks
struct WithdrawalRequest {
address user;
uint amount;
}
WithdrawalRequest[] public withdrawalRequests;
// Anyone can submit withdrawal requests for ANY amount (even 1 wei!)
function requestWithdrawal(uint amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
// Add to the global withdrawal queue - no minimum amount check!
withdrawalRequests.push(WithdrawalRequest(msg.sender, amount));
}
function processWithdrawals() external onlyOwner {
for (uint256 i = 0; i < withdrawalRequests.length; i++) { // This amplifies the attack even more because it tries to handle all the requests at once
WithdrawalRequest memory request = withdrawalRequests[i];
request.user.transfer(request.amount);
}
withdrawalRequests = new WithdrawalRequest[](0);
}
The fix: enforce a minimum
A simple require
statement can improve a lot here. Enforce a threshold with a require
statement and act like a bouncer checking an ID. We also need to make it possible to handle the withdrawal requests in batches. Here’s how:
uint public minimumWithdrawal = 0.1 ether;
function requestWithdrawal(uint amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
require(amount >= minimumWithdrawal, "Amount below minimum withdrawal threshold"); // Bouncer!
balances[msg.sender] -= amount;
withdrawalRequests.push(WithdrawalRequest(msg.sender, amount));
}
function processWithdrawals(uint count) external onlyOwner { // We can process in batches now
for (uint256 i = 0; i < count; i++) {
WithdrawalRequest memory request = withdrawalRequests[i];
request.user.transfer(request.amount);
}
for (uint i = 0; i < withdrawalRequests.length - count; i++) {
withdrawalRequests[i] = withdrawalRequests[i + count];
}
withdrawalRequests.length = withdrawalRequests.length - count;
}
Example:
The checklist example highlights a contract where users can request withdrawals of literally any amount (even zero), no matter how small. An attacker could exploit this to create a huge queue of zero-value withdrawal requests. This makes processing legitimate withdrawals prohibitively expensive, costing legitimate users a whole lot of gas. The minimal example and PoC written in Foundry are available here.
Checklist power: The simple question "Is there a minimum transaction amount enforced?" forces you to stop, think about this specific attack vector, and implement pretty straightforward safeguards.
Translation: Are you considering the implications of using tokens (looking at you, USDC and similar) that can blacklist addresses?
This is a far more nuanced issue and super important in today's evolving world of regulated stablecoins. Some tokens (not naming any names, but cough USDT cough USDC cough) have blacklisting capabilities. This means a central authority can freeze or outright block specific addresses from using the token.
Why is this a potential DoS time bomb?
Picture a community staking contract where friends, family members, or investment partners pool their tokens together in a "staking group." Sounds cooperative and efficient, right? Each member gets assigned a percentage of the rewards based on their contribution.
But here's where things get dangerously interesting: What happens if just one member of your staking group gets blacklisted by the token contract? Perhaps your cousin ended up on some regulatory watchlist, or your friend's address was flagged due to some completely unrelated transaction. No big deal for the rest of the group, right? Wrong!
When your group tries to withdraw its rewards, the entire transaction fails spectacularly! Why? Because the contract attempts to distribute rewards to all members in a single transaction. If one transfer fails, the entire operation reverts! Your group's combined 100 ETH worth of tokens? Completely locked! Your planned withdrawals? Impossible! All because of one member's blacklisting that might have nothing to do with your staking activity!
Do you know what’s even worse? There's typically no way to remove the blacklisted member or redistribute the shares. The group's funds are effectively frozen until the token's blacklist is updated - something that might never happen if the blacklisting was for regulatory reasons.
This isn't some theoretical concern - it's happening in real contracts today! A single point of failure affecting multiple users simultaneously. A collective punishment mechanism that nobody asked for! Scary, isn't it?
Here’s how it looks in code:
contract GroupStaking {
IERC20 public token;
struct StakingGroup {
uint256 id;
uint256 totalAmount;
address[] members;
uint256[] weights;
bool exists;
}
// Mapping from group ID to group data
mapping(uint256 => StakingGroup) public stakingGroups;
// Current group ID counter
uint256 public nextGroupId = 1;
constructor(IERC20 _token) {
token = _token;
}
// Create a new staking group
function createStakingGroup(address[] calldata _members, uint256[] calldata _weights) external returns (uint256) {
require(_members.length > 0, "Empty members list");
require(_members.length == _weights.length, "Members and weights length mismatch");
// Validate weights sum to 100%
uint256 totalWeight = 0;
for (uint256 i = 0; i < _weights.length; i++) {
totalWeight += _weights[i];
}
require(totalWeight == 100, "Weights must sum to 100");
uint256 groupId = nextGroupId;
stakingGroups[groupId] = StakingGroup({
id: groupId,
totalAmount: 0,
members: _members,
weights: _weights,
exists: true
});
nextGroupId++;
return groupId;
}
// Stake tokens to a group
function stakeToGroup(uint256 _groupId, uint256 _amount) external {
require(stakingGroups[_groupId].exists, "Group does not exist");
require(token.transferFrom(msg.sender, address(this), _amount), "Transfer failed");
stakingGroups[_groupId].totalAmount += _amount;
}
// Withdraw tokens from a group with rewards distributed according to weights
function withdrawFromGroup(uint256 _groupId, uint256 _amount) external {
StakingGroup storage group = stakingGroups[_groupId];
require(group.exists, "Group does not exist");
require(group.totalAmount >= _amount, "Insufficient group balance");
// Only a group member can initiate a withdrawal
bool isMember = false;
for (uint256 i = 0; i < group.members.length; i++) {
if (group.members[i] == msg.sender) {
isMember = true;
break;
}
}
require(isMember, "Not a group member");
// Update the group's total amount
group.totalAmount -= _amount;
// Distribute the withdrawn amount to all members according to their weights
// VULNERABLE: If any member is blacklisted, the entire distribution fails
for (uint256 i = 0; i < group.members.length; i++) {
uint256 memberShare = (_amount * group.weights[i]) / 100;
if (memberShare > 0) {
token.transfer(group.members[i], memberShare);
}
}
}
// Get group info
function getGroupInfo(uint256 _groupId) external view returns (
uint256 id,
uint256 totalAmount,
address[] memory members,
uint256[] memory weights
) {
StakingGroup storage group = stakingGroups[_groupId];
require(group.exists, "Group does not exist");
return (
group.id,
group.totalAmount,
group.members,
group.weights
);
}
}
The fix: Account for blacklisting. It depends on your tolerance.
There's no single "right" answer here, unfortunately. Each situation is so specific. It really depends on your protocol's design, risk tolerance, and even your legal agreements with users. At least, keep this kind of possibility in mind and design a fallback mechanism while ensuring regulatory compliance.
Checklist power: This checklist item forces you to think critically about external dependencies and their potential impact on your protocol's core functionality. It's really about anticipating the worst-case scenarios and having at least some backup plan.
The minimal example and PoC written in Foundry are available here.
Denial-of-Service (DoS) attack: An attack that attempts to disrupt or make a smart contract or its functions unavailable to legitimate users. This can be achieved through consuming excessive gas, causing contract reverts, or exploiting vulnerabilities to block critical operations.
Withdrawal pattern: A secure smart contract design pattern that emphasizes a "pull" model for withdrawals. Instead of a contract pushing tokens to users, users initiate the withdrawal process, mitigating potential issues like DoS vulnerabilities caused by failing recipient transfers.
Dust transactions: These are extremely small-value transactions, often used maliciously to clog a network or smart contract, making legitimate transactions more expensive or hindering their processing.
Token blacklisting: A feature implemented by some token contracts (e.g., certain ERC-20 implementations) that allows specific addresses to be blocked from transferring tokens. This effectively freezes their funds or prevents them from interacting with the token.
We talked about the pull pattern (embracing user control), minimum transaction amounts (checking before accepting), and the potential minefield of token blacklisting (knowing your dependencies).
You're not just writing code by actively considering these checklist items during your development process. You're creating a safer, more secure, and more trustworthy ecosystem. And that, my friends, is something truly worth striving for! Trust and transparency are your most valuable assets.
Stay tuned for the next installment! If you found this helpful, feel free to share it with your fellow code warriors. Let's make the DeFi space a safer place, one checklist item at a time!