Puppy Raffle

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

Winner rejection DoS

Root + Impact

Description

The PuppyRaffle::selectWinner function forces ETH to be sent to the winner using require(success), which creates a permanent DoS vulnerability. If the winner is a contract that cannot or will not accept ETH (no receive()/fallback() function, or one that reverts), the entire raffle becomes permanently stuck and cannot be completed. All player funds remain locked in the contract indefinitely.

function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
uint256 winnerIndex = /* ... */;
address winner = players[winnerIndex];
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees = totalFees + uint64(fee);
uint256 tokenId = totalSupply();
uint256 rarity = /* ... */;
// State changes happen before transfer
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
// @> Forced ETH transfer - if winner rejects, entire function reverts!
@> (bool success,) = winner.call{value: prizePool}("");
@> require(success, "PuppyRaffle: Failed to send prize pool to winner");
_safeMint(winner, tokenId);
}

The Problem:

If the winner cannot receive ETH, the require(success) statement causes the entire transaction to revert. Since the winner has already been selected, the raffle cannot progress and becomes permanently stuck.

Risk

Likelihood: Medium - Requires winner to be a non-payable contract, but smart contract wallets and DAOs commonly enter raffles.

Impact: High - Permanent DoS preventing raffle completion, locking all player funds and fees indefinitely with no recovery mechanism.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
contract DoSWinner {
address public puppyRaffle;
constructor(address _puppyRaffle) {
puppyRaffle = _puppyRaffle;
}
// @> NO receive() or fallback() - CANNOT accept ETH
function enter(uint256 entranceFee) external payable {
address[] memory players = new address[](1);
players[0] = address(this);
(bool success,) = puppyRaffle.call{value: entranceFee}(
abi.encodeWithSignature("enterRaffle(address[])", players)
);
require(success, "Enter failed");
}
}

Attack Steps:

  1. Deploy DoSWinner contract

  2. Call enter() with entrance fee (contract enters raffle)

  3. Keep entering until DoSWinner is selected as winner (or wait for random selection)

  4. When selectWinner() is called:

    • Winner is DoSWinner contract

    • winner.call{value: prizePool} fails (no receive function)

    • require(success) causes transaction to revert

  5. Result: Raffle permanently stuck. Nobody can complete the raffle. All funds locked forever.

Alternative Attack - Conditional Rejection:

contract SelectiveRejecter {
uint256 public minimumPrize = 10 ether;
receive() external payable {
// Only accept if prize is large enough
require(msg.value >= minimumPrize, "Prize too small!");
}
}

This attacker only accepts prizes above a threshold, causing DoS for smaller raffles.

Tools Used

Manual review

Recommended Mitigation

Use a pull payment pattern instead of forcing ETH transfers. Let winners withdraw their prize:

+ mapping(address => uint256) public claimablePrizes;
function selectWinner() external {
require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");
require(players.length >= 4, "PuppyRaffle: Need at least 4 players");
uint256 winnerIndex = /* ... */;
address winner = players[winnerIndex];
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
totalFees = totalFees + uint64(fee);
uint256 tokenId = totalSupply();
uint256 rarity = /* ... */;
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ // Store prize for winner to claim
+ claimablePrizes[winner] += prizePool;
_safeMint(winner, tokenId);
}
+ function claimPrize() external {
+ uint256 prize = claimablePrizes[msg.sender];
+ require(prize > 0, "No prize to claim");
+
+ claimablePrizes[msg.sender] = 0;
+ (bool success,) = msg.sender.call{value: prize}("");
+ require(success, "Transfer failed");
+ }

Why This Works:

  • selectWinner() completes successfully regardless of winner's ability to receive ETH

  • Winner can claim prize whenever they want via claimPrize()

  • If winner cannot receive ETH, they can update their contract or transfer claim rights

  • Raffle never gets stuck

  • Other players are not affected by one winner's inability to receive ETH

Alternative (less recommended): Remove the require(success) check, but this means winners might lose their prize if they can't receive ETH.

Updates

Lead Judging Commences

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