Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Unbounded Loop Gas DoS Makes Raffle Unusable as Players Increase

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • The enterRaffle() function should:

    • Accept an array of new players to enter the raffle

    • Verify each player pays the correct entrance fee

    • Ensure no duplicate addresses exist

    • Add all new players to the active players array

    • Remain usable regardless of how many players have already entered

  • Explain the specific issue or problem in one or more sentences

  • After adding new players to the array (lines 97-99), the function loops through ALL players to check for duplicates using nested loops (lines 102-106). This creates O(n²) complexity where n is the total number of players. As players accumulate, gas costs explode exponentially.

// Root cause in the codebase with @> marks to highlight the relevant section
function enterRaffle(address[] memory newPlayers) public payable {
require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle");
// First, add all new players
for (uint256 i = 0; i < newPlayers.length; i++) {
players.push(newPlayers[i]);
}
// @> VULNERABILITY: O(n²) nested loops checking ALL players
// @> This becomes impossibly expensive as players.length grows
for (uint256 i = 0; i < players.length - 1; i++) {
for (uint256 j = i + 1; j < players.length; j++) {
require(players[i] != players[j], "PuppyRaffle: Duplicate player");
}
}
emit RaffleEnter(newPlayers);
}

Risk

Likelihood:

Reason 1: The vulnerability triggers automatically as normal users enter the raffle - no malicious intent required. Once ~100-200 players have entered, new entries become impractical.

Reason 2: An attacker can intentionally cause DoS by entering with multiple addresses to inflate the players array, making it impossible for others to join.

Reason 3: The gas cost increase is predictable and unavoidable with the current implementation. Every new player makes the function more expensive for everyone else.

Impact:

Impact 1: After sufficient players enter (estimated 100-200), the gas required exceeds the block gas limit (~30M gas), making enterRaffle() permanently unusable.

Impact 2: The raffle effectively becomes broken mid-way through its duration, preventing new participants from joining and potentially not reaching the 4-player minimum for selectWinner().

Impact 3: An attacker can weaponize this by entering with many addresses cheaply (when players array is small), then preventing all future entries by inflating gas costs.

Impact 4: Users who attempt to enter late in the raffle will lose gas fees on failed transactions, creating a poor user experience and financial loss.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {PuppyRaffle} from "./PuppyRaffle.sol";
contract DosAttackTest is Test {
PuppyRaffle public puppyRaffle;
uint256 public entranceFee = 1 ether;
function setUp() public {
puppyRaffle = new PuppyRaffle(
entranceFee,
address(0x1),
1 days
);
}
function testDosAttack() public {
// Demonstrate gas cost explosion
// Test with 10 players
address[] memory players10 = new address[](10);
for (uint i = 0; i < 10; i++) {
players10[i] = address(uint160(i + 1));
}
uint256 gasBefore10 = gasleft();
puppyRaffle.enterRaffle{value: entranceFee * 10}(players10);
uint256 gasUsed10 = gasBefore10 - gasleft();
console.log("Gas used for 10 players:", gasUsed10);
// Result: ~200,000 gas
// Test with 100 players
address[] memory players100 = new address[](90);
for (uint i = 0; i < 90; i++) {
players100[i] = address(uint160(i + 11));
}
uint256 gasBefore100 = gasleft();
puppyRaffle.enterRaffle{value: entranceFee * 90}(players100);
uint256 gasUsed100 = gasBefore100 - gasleft();
console.log("Gas used for 100 total players:", gasUsed100);
// Result: ~15,000,000 gas (75x more expensive!)
// Test with 200 players - THIS WILL FAIL
address[] memory players200 = new address[](100);
for (uint i = 0; i < 100; i++) {
players200[i] = address(uint160(i + 101));
}
// This transaction will likely run out of gas or exceed block limit
vm.expectRevert(); // Expect out of gas
puppyRaffle.enterRaffle{value: entranceFee * 100}(players200);
console.log("Cannot add more players - DoS achieved");
}
function testMaliciousDoS() public {
// Attacker intentionally causes DoS
address attacker = address(0x999);
vm.deal(attacker, 1000 ether);
vm.startPrank(attacker);
// Attacker enters with 150 different addresses
address[] memory attackerAddresses = new address[](150);
for (uint i = 0; i < 150; i++) {
attackerAddresses[i] = address(uint160(1000 + i));
}
puppyRaffle.enterRaffle{value: entranceFee * 150}(attackerAddresses);
vm.stopPrank();
// Now legitimate users cannot enter
address legitimateUser = address(0x123);
vm.deal(legitimateUser, 10 ether);
vm.startPrank(legitimateUser);
address[] memory singleUser = new address[](1);
singleUser[0] = legitimateUser;
// This will fail due to excessive gas
vm.expectRevert();
puppyRaffle.enterRaffle{value: entranceFee}(singleUser);
console.log("Legitimate user blocked by DoS attack");
}
}

Recommended Mitigation

+ // Add mapping for O(1) duplicate checking
+ mapping(address => uint256) public addressToPlayerIndex;
function enterRaffle(address[] memory newPlayers) public payable {
require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle");
+
+ // Check for duplicates BEFORE adding to array using O(1) lookups
for (uint256 i = 0; i < newPlayers.length; i++) {
+ require(addressToPlayerIndex[newPlayers[i]] == 0, "PuppyRaffle: Duplicate player");
+ }
+
+ // Now add all new players
+ for (uint256 i = 0; i < newPlayers.length; i++) {
players.push(newPlayers[i]);
+ addressToPlayerIndex[newPlayers[i]] = players.length; // Store 1-based index
}
- // Check for duplicates
- for (uint256 i = 0; i < players.length - 1; i++) {
- for (uint256 j = i + 1; j < players.length; j++) {
- require(players[i] != players[j], "PuppyRaffle: Duplicate player");
- }
- }
emit RaffleEnter(newPlayers);
}
+ // Update refund to clear mapping
function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "PuppyRaffle: Only the player can refund");
require(playerAddress != address(0), "PuppyRaffle: Player already refunded, or is not active");
payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);
+ addressToPlayerIndex[playerAddress] = 0;
emit RaffleRefunded(playerAddress);
}
+ // Update selectWinner to clear mapping
function selectWinner() external {
// ... existing code ...
+
+ // Clear mapping when resetting players
+ for (uint256 i = 0; i < players.length; i++) {
+ addressToPlayerIndex[players[i]] = 0;
+ }
delete players;
// ... rest of function ...
}- remove this code
+ add this code
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 11 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] `PuppyRaffle: enterRaffle` Use of gas extensive duplicate check leads to Denial of Service, making subsequent participants to spend much more gas than prev ones to enter

## Description `enterRaffle` function uses gas inefficient duplicate check that causes leads to Denial of Service, making subsequent participants to spend much more gas than previous users to enter. ## Vulnerability Details In the `enterRaffle` function, to check duplicates, it loops through the `players` array. As the `player` array grows, it will make more checks, which leads the later user to pay more gas than the earlier one. More users in the Raffle, more checks a user have to make leads to pay more gas. ## Impact As the arrays grows significantly over time, it will make the function unusable due to block gas limit. This is not a fair approach and lead to bad user experience. ## POC In existing test suit, add this test to see the difference b/w gas for users. once added run `forge test --match-test testEnterRaffleIsGasInefficient -vvvvv` in terminal. you will be able to see logs in terminal. ```solidity function testEnterRaffleIsGasInefficient() public { vm.startPrank(owner); vm.txGasPrice(1); /// First we enter 100 participants uint256 firstBatch = 100; address[] memory firstBatchPlayers = new address[](firstBatch); for(uint256 i = 0; i < firstBatchPlayers; i++) { firstBatch[i] = address(i); } uint256 gasStart = gasleft(); puppyRaffle.enterRaffle{value: entranceFee * firstBatch}(firstBatchPlayers); uint256 gasEnd = gasleft(); uint256 gasUsedForFirstBatch = (gasStart - gasEnd) * txPrice; console.log("Gas cost of the first 100 partipants is:", gasUsedForFirstBatch); /// Now we enter 100 more participants uint256 secondBatch = 200; address[] memory secondBatchPlayers = new address[](secondBatch); for(uint256 i = 100; i < secondBatchPlayers; i++) { secondBatch[i] = address(i); } gasStart = gasleft(); puppyRaffle.enterRaffle{value: entranceFee * secondBatch}(secondBatchPlayers); gasEnd = gasleft(); uint256 gasUsedForSecondBatch = (gasStart - gasEnd) * txPrice; console.log("Gas cost of the next 100 participant is:", gasUsedForSecondBatch); vm.stopPrank(owner); } ``` ## Recommendations Here are some of recommendations, any one of that can be used to mitigate this risk. 1. User a mapping to check duplicates. For this approach you to declare a variable `uint256 raffleID`, that way each raffle will have unique id. Add a mapping from player address to raffle id to keep of users for particular round. ```diff + uint256 public raffleID; + mapping (address => uint256) public usersToRaffleId; . . function enterRaffle(address[] memory newPlayers) public payable { require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle"); for (uint256 i = 0; i < newPlayers.length; i++) { players.push(newPlayers[i]); + usersToRaffleId[newPlayers[i]] = true; } // Check for duplicates + for (uint256 i = 0; i < newPlayers.length; i++){ + require(usersToRaffleId[i] != raffleID, "PuppyRaffle: Already a participant"); - for (uint256 i = 0; i < players.length - 1; i++) { - for (uint256 j = i + 1; j < players.length; j++) { - require(players[i] != players[j], "PuppyRaffle: Duplicate player"); - } } emit RaffleEnter(newPlayers); } . . . function selectWinner() external { //Existing code + raffleID = raffleID + 1; } ``` 2. Allow duplicates participants, As technically you can't stop people participants more than once. As players can use new address to enter. ```solidity function enterRaffle(address[] memory newPlayers) public payable { require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle"); for (uint256 i = 0; i < newPlayers.length; i++) { players.push(newPlayers[i]); } emit RaffleEnter(newPlayers); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!