PuppyRaffle.selectWinner() in src/PuppyRaffle.sol works out the prize and the fees from players.length, even though refunded players are left in that array as address(0). The refunded players have already taken their entrance fee back, so the contract holds less ether than the calculation assumes, and the prize it tries to pay can be larger than the balance available.
The vulnerable code is:
refund() does not shrink the array or reduce any total. It only replaces the refunded entry with address(0):
So players.length counts every player who ever entered, including everyone who has already been refunded. The prize is 80% of the entrance fees of all of them, while the contract only still holds the fees of the players who have not refunded. There is no function that can remove a refunded entry, compact the array, or correct the total, so once the numbers cross the threshold there is no way to fix a raffle round in progress.
Likelihood: High - refunding is a normal, documented feature of the contract, and no attack is needed. Any raffle in which more than 20% of the players refund before the winner is chosen will hit this problem, which is an entirely ordinary outcome if players change their minds or if the raffle runs for a while.
Impact: High - when the prize exceeds the balance, selectWinner() reverts on every call and the raffle can never finish. No winner is chosen, no puppy is minted, and the prize is never paid. The remaining players can only get their money back by refunding one by one; if they do not notice, or if they wait for a winner that can never be chosen, their funds stay locked in the contract with no recovery function.
Manual trace - no tests were run; this is a step-by-step reading of the code, with a worked example.
Worked example: suppose the entrance fee is 1 ether and four players enter, so the contract holds 4 ether.
One player calls refund() for their index. They receive 1 ether back, their slot becomes address(0), and the contract balance falls to 3 ether. players.length is still 4.
The raffle duration passes and someone calls selectWinner().
The contract computes totalAmountCollected = 4 * 1 ether = 4 ether, prizePool = (4 ether * 80) / 100 = 3.2 ether, and fee = 0.8 ether.
It then tries to send prizePool, 3.2 ether, to the winner, but the contract only holds 3 ether. A call that tries to send more ether than the contract holds reverts, so selectWinner() reverts and the require(success, ...) is never even reached cleanly.
Calling selectWinner() again changes nothing: the balance is still 3 ether, players.length is still 4, and the prize is still 3.2 ether, so it reverts again, every time. The threshold is simple: the prize is 80% of the original total, so the raffle is bricked whenever refunds exceed 20% of the players. With five players, two refunds are enough; with ten players, three refunds are enough.
The only way out is for the remaining three players to refund individually. If any of them does not, their entrance fee remains in a contract that can never select a winner.
Note that the fee is overstated in the same way: totalFees is increased by 20% of the original total, including fees on entrance fees that have already been refunded, so the recorded fees are also higher than the money actually held.
Base the prize and the fee on the money the contract actually holds for active players, not on players.length. Count only non-zero entries, or track an active player count that is reduced on refund, and derive the prize from that count.
Alternatively, remove refunded players from the array, or keep a running total of collected fees that is reduced when a refund is paid, so the prize can never exceed the balance available.
## Description `PuppyRaffle::refund` is supposed to refund a player and remove him from the current players. But instead, it replaces his index value with address(0) which is considered a valid value by solidity. This can cause a lot issues because the players array length is unchanged and address(0) is now considered a player. ## Vulnerability Details ```javascript players[playerIndex] = address(0); @> uint256 totalAmountCollected = players.length * entranceFee; (bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner"); _safeMint(winner, tokenId); ``` If a player refunds his position, the function `PuppyRaffle::selectWinner` will always revert. Because more than likely the following call will not work because the `prizePool` is based on a amount calculated by considering that that no player has refunded his position and exit the lottery. And it will try to send more tokens that what the contract has : ```javascript uint256 totalAmountCollected = players.length * entranceFee; uint256 prizePool = (totalAmountCollected * 80) / 100; (bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner"); ``` However, even if this calls passes for some reason (maby there are more native tokens that what the players have sent or because of the 80% ...). The call will thankfully still fail because of the following line is minting to the zero address is not allowed. ```javascript _safeMint(winner, tokenId); ``` ## Impact The lottery is stoped, any call to the function `PuppyRaffle::selectWinner`will revert. There is no actual loss of funds for users as they can always refund and get their tokens back. However, the protocol is shut down and will lose all it's customers. A core functionality is exposed. Impact is high ### Proof of concept To execute this test : forge test --mt testWinnerSelectionRevertsAfterExit -vvvv ```javascript function testWinnerSelectionRevertsAfterExit() public playersEntered { vm.warp(block.timestamp + duration + 1); vm.roll(block.number + 1); // There are four winners. Winner is last slot vm.prank(playerFour); puppyRaffle.refund(3); // reverts because out of Funds vm.expectRevert(); puppyRaffle.selectWinner(); vm.deal(address(puppyRaffle), 10 ether); vm.expectRevert("ERC721: mint to the zero address"); puppyRaffle.selectWinner(); } ``` ## Recommendations Delete the player index that has refunded. ```diff - players[playerIndex] = address(0); + players[playerIndex] = players[players.length - 1]; + players.pop() ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.