Puppy Raffle

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

DoS via unbounded O(n²) duplicate check in enterRaffle locks out honest players

[M-01] Unbounded O(n²) duplicate-check loop in enterRaffle permanently locks out honest players past ~548 entries

Description

enterRaffle performs duplicate detection by nested-iterating every pair in the players array AFTER appending the new entries:

// PuppyRaffle.sol:86-90
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");
}
}

Cost grows as N(N−1)/2 SLOAD pairs, ~200 gas per warm iteration → ~100·N² gas total. Solving against the 30M block gas limit:

30,000,000 ≤ 100·N² → N ≥ ~548

Once players.length exceeds ~548, every subsequent enterRaffle call (even a single new player) reverts out-of-gas. Honest users are silently locked out until selectWinner resets players[].

Risk

  • Likelihood: Medium — exploit is mechanically trivial but attacker must pay ~500× entranceFee to seed the array.

  • Impact: Medium — honest entries are blocked but no funds are stolen and the contract self-heals on the next draw.

  • Risk = Medium

Impact

An attacker calls enterRaffle([...500 fresh addresses...]) paying ~500× entranceFee to push players.length past the threshold. From that point until selectWinner runs, only the attacker's entries can win. The protocol's "open-entry" property is broken for the affected raffle round.

Proof of Concept

Foundry test: at players.length = 500, a victim's gas-capped (30M) enterRaffle call returns success = false.

function test_DoS_unbounded_duplicate_check() public {
// ARRANGE: attacker seeds 500 unique players in one transaction.
address[] memory batch = new address[](500);
for (uint256 i = 0; i < 500; i++) {
batch[i] = address(uint160(0x1000 + i));
}
vm.prank(attacker);
raffle.enterRaffle{value: ENTRANCE_FEE * 500}(batch);
// ACT: victim attempts to enter with 30M gas cap.
address[] memory onePlayer = new address[](1);
onePlayer[0] = victim;
bytes memory callData = abi.encodeWithSelector(
IPuppyRaffle.enterRaffle.selector, onePlayer
);
(bool success, ) = address(raffle).call{
value: ENTRANCE_FEE,
gas: 30_000_000
}(callData);
// ASSERT: the call MUST fail — victim is DoS'd out.
assertFalse(success, "DoS confirmed at N=500 within 30M gas");
}

A second test (test_GasGrowth_quadratic_trend) measures gas at N = 50, 100, 200 and asserts each doubling exceeds 2× — observed ratios 2.92× and 3.27×, consistent with O(n²). Both tests pass (forge exit 0).

Recommended Mitigation

Replace the O(n²) nested loop with an O(1) mapping + per-round nonce (avoids the O(N) cost of clearing a flat mapping at draw time):

uint256 public currentRoundId;
mapping(uint256 => mapping(address => bool)) public isRegisteredInRound;
function enterRaffle(address[] memory newPlayers) public payable {
require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough");
for (uint256 i = 0; i < newPlayers.length; i++) {
require(!isRegisteredInRound[currentRoundId][newPlayers[i]], "PuppyRaffle: Duplicate player");
isRegisteredInRound[currentRoundId][newPlayers[i]] = true;
players.push(newPlayers[i]);
}
emit RaffleEnter(newPlayers);
}
// in selectWinner, after `delete players;`:
++currentRoundId;

refund should also clear the round-keyed flag for the refunded address. Both enterRaffle and selectWinner become constant-overhead per player — no quadratic blowup.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 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!