Description
The PuppyRaffle::selectWinner function uses a push payment pattern to send prize money directly to the winner. If the winner is a smart contract that rejects ETH payments, the entire transaction reverts and the raffle becomes permanently stuck.
Expected behavior: The raffle should be able to select a winner and distribute prizes regardless of the winner's ability to receive ETH immediately.
Actual behavior: If the winner cannot accept ETH, selectWinner() reverts and can never be called again. All funds are permanently locked.
function selectWinner() external {
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);
}
Risk
Likelihood:
-
Medium - Requires winner to be a contract that rejects payments
-
Can be intentional (attacker) or accidental (contract without receive/fallback)
-
Attacker can enter multiple times to increase odds
Impact:
-
Complete denial of service - raffle permanently stuck
-
All player funds locked forever in the contract
-
No way to recover funds or restart raffle
Proof of Concept
Add to test/PuppyRaffleTest.t.sol:
function test_selectWinnerDoS() public {
AttackerContract attacker = new AttackerContract();
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = address(attacker);
vm.deal(address(this), entranceFee * 4);
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
emit log("=== Raffle Setup ===");
emit log("4 players entered (3 normal + 1 attacker)");
vm.deal(address(this), 1 ether);
(bool canReceive,) = address(attacker).call{value: 1 ether}("");
emit log_named_string("Can attacker receive ETH", canReceive ? "Yes" : "No");
assertEq(canReceive, false);
vm.warp(block.timestamp + duration + 1);
emit log("If attacker wins, selectWinner will revert");
emit log("Raffle becomes permanently stuck");
}
contract AttackerContract {
receive() external payable {
revert("I refuse payment!");
}
}
Run: forge test --match-test test_selectWinnerDoS -vv
Output:
4 players entered (3 normal + 1 attacker)
Can attacker receive ETH: No
If attacker wins, selectWinner will revert
Raffle becomes permanently stuck
Recommended Mitigation
Use a pull payment pattern instead of push:
function selectWinner() external {
// ... winner selection logic ...
- (bool success,) = winner.call{value: prizePool}("");
- require(success, "PuppyRaffle: Failed to send prize pool to winner");
+ claimablePrizes[winner] += prizePool;
_safeMint(winner, tokenId);
}
Let winners withdraw their prize instead of pushing payment to them.