selectWinner() calculates the total prize pool and fees using players.length, which includes refunded players whose slots are address(0):
When a player calls refund(), they receive their entranceFee back, but their slot in the players array is only set to address(0) — the array length stays the same.
This means totalAmountCollected is inflated beyond the actual ETH held by the contract, creating two failure modes:
selectWinner() reverts — If enough players refund, prizePool exceeds the contract balance, causing winner.call{value: prizePool} to fail
Phantom fees — If prizePool barely fits within balance, totalFees is incremented by fee that has no ETH backing, permanently locking withdrawFees()
Likelihood:
Any raffle round where at least one player refunds triggers the accounting mismatch
With many participants, even a single refund creates phantom fee debt
Impact:
High — selectWinner() can permanently revert, locking ALL remaining player funds in the contract
Even when selectWinner() succeeds, the fee accounting is wrong: totalFees records fees that don't exist as ETH
withdrawFees() becomes permanently DOSed because address(this).balance != uint256(totalFees)
How the attack works:
5 players enter the raffle, each paying 1 ETH entrance fee (contract balance = 5 ETH)
1 player calls refund() and receives 1 ETH back (contract balance = 4 ETH, but players.length is still 5)
The raffle duration expires and someone calls selectWinner()
totalAmountCollected = 5 * 1 ETH = 5 ETH (wrong — only 4 ETH in contract)
prizePool = 4 ETH (80% of 5 ETH), fee = 1 ETH (20% of 5 ETH)
winner.call{value: 4 ETH} succeeds (4 ETH available), leaving 0 ETH in contract
totalFees += uint64(1 ETH) — but there is 0 ETH left to back this fee!
withdrawFees() requires address(this).balance == uint256(totalFees) → 0 != 1 ETH → permanently reverts
Catastrophic scenario — 2+ refunds:
5 players enter (5 ETH), 2 refund (balance = 3 ETH)
totalAmountCollected = 5 ETH, prizePool = 4 ETH
winner.call{value: 4 ETH} — only 3 ETH available → REVERTS
selectWinner() is permanently DOSed — no winner can ever be selected
Remaining 3 players' funds are locked forever (no refund mechanism after duration expires either, as refund still works but doesn't fix the accounting)
PoC code:
Expected outcome: selectWinner() permanently reverts because it tries to send more ETH than the contract holds, locking all remaining player funds. Even with only 1 refund, the fee accounting breaks and withdrawFees() becomes permanently DOSed.
The root cause is that selectWinner() uses players.length (which includes refunded address(0) slots) to calculate ETH amounts, while the actual contract balance reflects only the non-refunded entries. The fix must ensure the prize/fee calculation uses only the ETH that is actually held by the contract.
Primary fix — Track active player count for accurate accounting:
Why this works:
activePlayerCount precisely tracks the number of entries whose entrance fee is still held by the contract. It increments on entry and decrements on refund, so activePlayerCount * entranceFee always equals the ETH available for distribution.
The require(activePlayerCount >= 4) check replaces the implicit assumption that players.length >= 4, preventing selectWinner() from running when too many players have refunded.
prizePool and fee are now calculated from the actual ETH held, eliminating both the revert scenario (insufficient balance) and the phantom fee scenario (fees recorded without backing ETH).
Additional hardening: Consider resetting the raffle state (clearing the players array and activePlayerCount) at the end of selectWinner() to prevent stale state from carrying into future rounds:
## 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.