Table of contents
No headings in the article.
I embark on my journey to become a smart contract auditor with Cyfrin Updraft,
I have learned about various attack vectors, with Denial of Service (DoS) being my favorite. Imagine being an attacker who completely disrupts a protocol's functionality—while it might seem satisfying from an attacker's perspective, our job as auditors is to prevent such attacks.
Let's consider an example involving an unbounded loop that leads to a DoS attack.
In this smart contract, users call the enter function, which checks if their address is already in an array. If the address is not in the array, the function adds it.
Initially, this works fine for a few users. However, as the number of users grows into the thousands, the cost of running the loop increases significantly. Each time the loop executes, it becomes more expensive for new users to enter.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract DoS {
address[] entrants;
function enter() public {
// Check for duplicate entrants
for (uint256 i; i < entrants.length; i++) {
if (entrants[i] == msg.sender) {
revert("You've already entered!");
}
}
entrants.push(msg.sender);
}
}
To illustrate this, we conducted a test where we added 1,000 users after two addresses, A and B, and then added address C. The gas fees for adding addresses A and B (the first and second users) were much lower compared to the gas fees for adding address C (after 1,000 users).
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {DoS} from "../../src/denial-of-service/DoS.sol";
contract DoSTest is Test {
DoS public dos;
address warmUpAddress = makeAddr("warmUp");
address personA = makeAddr("A");
address personB = makeAddr("B");
address personC = makeAddr("C");
function setUp() public {
dos = new DoS();
}
function test_denialOfService() public {
// We want to warm up the storage stuff
vm.prank(warmUpAddress);
dos.enter();
uint256 gasStartA = gasleft();
vm.prank(personA);
dos.enter();
uint256 gasCostA = gasStartA - gasleft();
uint256 gasStartB = gasleft();
vm.prank(personB);
dos.enter();
uint256 gasCostB = gasStartB - gasleft();
for (uint256 i; i < 1000; i++) {
vm.prank(address(uint160(i)));
dos.enter();
}
uint256 gasStartC = gasleft();
vm.prank(personC);
dos.enter();
uint256 gasCostC = gasStartC - gasleft();
console2.log("Gas cost A: %s", gasCostA);
console2.log("Gas cost B: %s", gasCostB);
console2.log("Gas cost C: %s", gasCostC);
// The gas cost will just keep rising, making it harder and harder for new people to enter!
assert(gasCostC > gasCostB);
assert(gasCostB > gasCostA);
}
}
This difference in gas fees makes the function prohibitively expensive to use, leading to a Denial of Service attack. Essentially, no one will use the function because the gas fees are too high, effectively rendering the contract unusable.
To mitigate this, you can use a mapping
instead of an array
to keep track of whether an address has been entered or not. This way, checking for duplicates becomes an O(1) operation rather than O(n), eliminating the risk of a DoS attack due to high gas costs.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract DoS {
mapping(address => bool) public hasEntered;
function enter() public {
// Check if the sender has already entered
require(!hasEntered[msg.sender], "You've already entered!");
// Mark the sender as having entered
hasEntered[msg.sender] = true;
}
}
Now see that gas is much more efficiently handled by using a mapping
.