Puppy Raffle

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

Prize Can Be Sent to address(0)

Root + Impact

Description

The selectWinner() function selects a winner by indexing into the players array at a pseudo-randomly chosen position. However, the refund() function zeroes out player slots instead of removing them from the array:

// @> refund sets the slot to address(0) but leaves it in the array
function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "...");
require(playerAddress != address(0), "...");
payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0); // @> slot becomes address(0)
emit RaffleRefunded(playerAddress);
}

When selectWinner() later picks an index that corresponds to a refunded slot, winner is assigned address(0):

uint256 winnerIndex = ... % players.length;
address winner = players\[winnerIndex]; // @> winner = address(0) if slot was refunded
// @> ETH is sent to address(0) — permanently lost
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
// @> NFT is minted to address(0) — also permanently lost
\_safeMint(winner, tokenId);

Sending ETH to address(0) via a low-level .call succeeds silently in Solidity — the ETH is not returned but simply destroyed. The entire prize pool (80% of all entrance fees) is permanently lost with no way to recover it.

Risk

Likelihood:

  • Any player who calls refund() creates a zero slot in the array, and selectWinner() does not skip zero slots

  • With many participants, the probability of landing on a zero slot is (refunded players) / (total players)

  • A griefing attacker can deliberately enter with many addresses, wait until near the end of the raffle, refund all of them, and maximize the probability of a zero-slot win

  • This requires no special technical knowledge — just calling refund() before selectWinner() is called

Impact:

  • The entire prize pool (80% of all ETH collected from entrance fees) is sent to address(0) and permanently destroyed

  • All honest players lose their entrance fees with no winner receiving the prize

  • An NFT is minted to address(0) making it unowned and unrecoverable

  • The previousWinner state variable is set to address(0), corrupting protocol state

  • Repeated attacks make the protocol completely unusable and cause indefinite loss of user funds

Proof of Concept

// Attack scenario:
// 1. Attacker enters raffle with 4 addresses (minimum required)
// 2. Attacker refunds 3 of the 4 slots → players = [0x0, 0x0, 0x0, attackerAddr]
// 3. No other players participate
// 4. selectWinner() is called
// → winnerIndex = random % 4
// → 75% chance winner = address(0)
// → prize pool is sent to address(0) and destroyed
// Even without a deliberate attack:
// If 1 out of 4 players refunds:
// players = [addr1, 0x0, addr3, addr4]
// selectWinner picks index 1 → 0x0 wins → prize lost

Formal Verification Evidence (Certora Prover):

The Certora Prover rule prize_never_sent_to_zero was formally verified and returned VIOLATED, providing mathematical proof that previousWinner can be address(0) after selectWinner():

Rule: prize\_never\_sent\_to\_zero
Property: previousWinner() != address(0) after selectWinner()
Result: VIOLATED
Prover output: prover.certora.com/output/3088449/d25650413fc1466280b1d4664a294a41

Recommended Mitigation

Replace the zeroing pattern in refund() with a swap-and-pop approach that removes the player from the array entirely, ensuring no zero slots ever exist:

function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "...");
require(playerAddress != address(0), "...");
payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);
+ players[playerIndex] = players[players.length - 1];
+ players.pop();
emit RaffleRefunded(playerAddress);
}

Additionally, add a zero-address check in selectWinner() as a defense-in-depth measure:

address winner = players[winnerIndex];
+ require(winner != address(0), "PuppyRaffle: Winner slot was refunded");
Updates

Lead Judging Commences

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

[H-01] Potential Loss of Funds During Prize Pool Distribution

## Description In the `selectWinner` function, when a player has refunded and their address is replaced with address(0), the prize money may be sent to address(0), resulting in fund loss. ## Vulnerability Details In the `refund` function if a user wants to refund his money then he will be given his money back and his address in the array will be replaced with `address(0)`. So lets say `Alice` entered in the raffle and later decided to refund her money then her address in the `player` array will be replaced with `address(0)`. And lets consider that her index in the array is `7th` so currently there is `address(0)` at `7th index`, so when `selectWinner` function will be called there isn't any kind of check that this 7th index can't be the winner so if this `7th` index will be declared as winner then all the prize will be sent to him which will actually lost as it will be sent to `address(0)` ## Impact Loss of funds if they are sent to address(0), posing a financial risk. ## Recommendations Implement additional checks in the `selectWinner` function to ensure that prize money is not sent to `address(0)`

Support

FAQs

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

Give us feedback!