Puppy Raffle

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

Prize Distribution Can Be Permanently Blocked by a Winner That Reverts on ETH Receipt.

Root + Impact

Description

selectWinner function (https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L125) sends the prize pool with
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L151
and immediately
https://github.com/CodeHawks-Contests/ai-puppy-raffle/blob/08e5b1fc6939b8da7792b2d13e43000c519d8897/src/PuppyRaffle.sol#L152
If the address selected as winner is a contract with no receive()/fallback() (or one that deliberately reverts), the entire selectWinner() transaction — including the delete players, raffleStartTime reset, and rarity/token bookkeeping — reverts.
Combined with the predictable RNG described above (both winnerIndex and the call succeeding/failing depend on msg.sender), an attacker can: (1) enter the raffle with one or more contracts that reject plain ETH transfers, and (2) choose a msg.sender for selectWinner() whose hash resolves winnerIndex to that malicious contract's slot, deterministically forcing a revert.
// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

Fairly Likely.

Impact:

The raffle round becomes stuck — no one can successfully call selectWinner() from a caller address that resolves to the malicious contract's index, and an attacker who wants to block the raffle indefinitely can keep re-deriving a blocking caller address whenever conditions change (new block, new timestamp), effectively performing a repeatable denial-of-service against prize distribution.

Proof of Concept

Demostrates how a player contract could ensure rejecion of eth.

contract RejectEther {
// No receive() or fallback() -- any plain ETH transfer to this contract reverts
}
contract PuppyRaffleWinnerDoSTest is Test {
PuppyRaffle puppyRaffle;
uint256 entranceFee = 1 ether;
address feeAddress = address(99);
uint256 duration = 1 days;
address playerOne = address(1);
address playerTwo = address(2);
address playerThree = address(3);
function setUp() public {
puppyRaffle = new PuppyRaffle(entranceFee, feeAddress, duration);
}
function testMaliciousWinnerBlocksPrizeDistribution() public {
RejectEther badContract = new RejectEther();
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = address(badContract);
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
// Find a caller address whose winnerIndex hash resolves to the malicious
// contract's slot (index 3) -- demonstrating the outcome is forcibly steerable
address chosenCaller;
for (uint160 i = 1; i < 5000; i++) {
address candidate = address(i);
uint256 idx =
uint256(keccak256(abi.encodePacked(candidate, block.timestamp, block.difficulty))) % players.length;
if (idx == 3) {
chosenCaller = candidate;
break;
}
}
require(chosenCaller != address(0), "no candidate found in search range");
vm.prank(chosenCaller);
vm.expectRevert("PuppyRaffle: Failed to send prize pool to winner");
puppyRaffle.selectWinner();
}
}

Recommended Mitigation

Use a pull-payment pattern instead of pushing funds to the winner. Let the winner claim their prize via a separate claimPrize() function that credits a withdrawable balance, so a reverting recipient cannot block round finalization:Use a pull-payment pattern instead of pushing funds to the winner. Let the winner claim their prize via a separate claimPrize() function that credits a withdrawable balance, so a reverting recipient cannot block round finalization:
function selectWinner() external {
// ... winner selection logic ...
pendingPrizes[winner] += prizePool;
_safeMint(winner, tokenId);
}
function claimPrize() external {
uint256 amount = pendingPrizes[msg.sender];
require(amount > 0, "PuppyRaffle: No prize to claim");
pendingPrizes[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
require(success, "PuppyRaffle: Transfer failed");
}
+ add this code
Updates

Lead Judging Commences

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

[M-03] Impossible to win raffle if the winner is a smart contract without a fallback function

## Description If a player submits a smart contract as a player, and if it doesn't implement the `receive()` or `fallback()` function, the call use to send the funds to the winner will fail to execute, compromising the functionality of the protocol. ## Vulnerability Details The vulnerability comes from the way that are programmed smart contracts, if the smart contract doesn't implement a `receive() payable` or `fallback() payable` functions, it is not possible to send ether to the program. ## Impact High - Medium: The protocol won't be able to select a winner but players will be able to withdraw funds with the `refund()` function ## Recommendations Restrict access to the raffle to only EOAs (Externally Owned Accounts), by checking if the passed address in enterRaffle is a smart contract, if it is we revert the transaction. We can easily implement this check into the function because of the Adress library from OppenZeppelin. I'll add this replace `enterRaffle()` with these lines of code: ```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++) { require(Address.isContract(newPlayers[i]) == false, "The players need to be EOAs"); players.push(newPlayers[i]); } // 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); } ```

Support

FAQs

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

Give us feedback!