Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: low
Likelihood: medium
Invalid

Missing address(0) Validation in enterRaffle() — Allows Unrefundable and DoS-Causing Entries

Root + Impact

Description

  • enterRaffle() does not validate that participant addresses are non-zero:

function enterRaffle(address[] memory newPlayers) public payable {
require(msg.value == entranceFee * newPlayers.length, ...);
for (uint256 i = 0; i < newPlayers.length; i++) {
players.push(newPlayers[i]); // @audit No address(0) check
}
// ... duplicate check ...
}
  • An address(0) entry in the raffle has two problematic properties:

    1. Unrefundablerefund() requires playerAddress == msg.sender, and no one can have msg.sender == address(0). The entrance fee for that slot is permanently locked.

    2. selectWinner() DoS — If address(0) is selected as the winner, _safeMint(address(0), tokenId) reverts because OpenZeppelin's ERC721 _mint has require(to != address(0)).

  • Additionally, an address(0) entry conflicts with refunded player slots (which are also address(0)), contributing to the duplicate detection issue described in M-004.

Risk

Likelihood:

  • Requires intentional action (passing address(0) in the newPlayers array)

  • Primarily a griefing vector — attacker pays entrance fee they can never recover

Impact:

  • Low — entrance fee for address(0) slot is permanently locked (self-griefing)

  • If address(0) is selected as winner by the RNG, selectWinner() reverts for that particular msg.sender

Proof of Concept

How the issue manifests:

  1. An attacker (or confused user) calls enterRaffle([address(0)]) with msg.value = entranceFee

  2. address(0) is pushed to the players array — the function succeeds without validation

  3. No one can call refund() for this slot because require(playerAddress == msg.sender) will never pass for address(0)

  4. If selectWinner() picks the address(0) slot as winner, _safeMint(address(0), tokenId) reverts with "ERC721: mint to the zero address"

PoC code:

function testExploit_ZeroAddressEnterRaffle() public {
// Enter address(0) as a player — should not be allowed but succeeds
address[] memory players = new address[](4);
players[0] = address(0); // Zero address entry
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
// Verify address(0) is in the array
assertEq(puppyRaffle.players(0), address(0));
// No one can refund the address(0) slot
vm.expectRevert("PuppyRaffle: Only the player can refund");
vm.prank(playerTwo); // Even another player can't refund slot 0
puppyRaffle.refund(0);
// The entrance fee for slot 0 is permanently locked
// If address(0) wins, _safeMint reverts
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
// Find a caller that makes winnerIndex = 0 (the address(0) slot)
for (uint256 i = 1; i <= 200; i++) {
address caller = address(uint160(i + 9000));
uint256 winnerIndex = uint256(
keccak256(abi.encodePacked(caller, block.timestamp, block.difficulty))
) % 4;
if (winnerIndex == 0) {
vm.prank(caller);
vm.expectRevert("ERC721: mint to the zero address");
puppyRaffle.selectWinner();
break;
}
}
}
// forge test --match-test testExploit_ZeroAddressEnterRaffle -vvv
// Result: PASS — address(0) entry is unrefundable and causes selectWinner DoS

Expected outcome: address(0) can be entered as a raffle participant. The entrance fee for that slot is permanently locked (no one can refund it), and if the RNG selects that slot as winner, selectWinner() reverts with "ERC721: mint to the zero address".

Recommended Mitigation

The root cause is that enterRaffle() does not validate input addresses before pushing them to the players array. address(0) is a special sentinel value used by refund() to mark empty slots, so allowing it as a participant creates confusion between "empty slot" and "zero-address entry" — and triggers downstream failures in both refund() (no one can be msg.sender == address(0)) and selectWinner() (ERC721 rejects minting to zero address).

Primary fix — Validate addresses at entry:

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++) {
require(newPlayers[i] != address(0), "PuppyRaffle: Invalid player address");
players.push(newPlayers[i]);
}
// ... duplicate check ...
emit RaffleEnter(newPlayers);
}

Why this works: The require check prevents address(0) from entering the players array at all, which:

  1. Eliminates the unrefundable-slot problem (no address(0) entries exist to be stuck).

  2. Eliminates the selectWinner() DoS (no address(0) can be selected as winner).

  3. Prevents collision with the address(0) sentinel used by refund(), reducing the interaction surface with M-004 (double-refund duplicate detection).

Additional context: This check alone does not fully resolve M-004 or M-002 — those issues require separate fixes (mapping-based dedup and active-player tracking respectively). However, this validation is a necessary foundational check that should be present regardless of other fixes. Input validation at system boundaries (where user-supplied data enters the contract) is a defense-in-depth best practice.

Gas cost: The require check adds ~200 gas per player entry — negligible compared to the existing storage operations (~20,000 gas per SSTORE).

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 8 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!