refund returns a player's ETH but only zeroes their slot; players.length does not change. selectWinner then computes the pot as players.length * entranceFee, which still counts every refunded slot as a paid ticket. After a single ordinary refund, the draw does one of three things:
pays the winner out of already-earned protocol fees, which permanently breaks withdrawFees;
reverts because the contract doesn't hold the computed prize;
lands on the zeroed slot, and _safeMint(address(0)) reverts.
src/PuppyRaffle.sol:103, in refund: the slot is zeroed and the array is not shortened.
src/PuppyRaffle.sol:130-134, in selectWinner:
Each refund leaves an entranceFee gap between the ETH actually held for the round and totalAmountCollected. The prize (:151) is paid from the contract's whole balance, which includes accrued totalFees. The booked fee is also inflated. From then on, address(this).balance < totalFees holds for good, and the strict equality in withdrawFees (:158) can never be satisfied again.
Likelihood: High — refunds are a normal user flow, so any round with a refund triggers it.
Impact: High
Refunding is a documented, normal user action (README rule 3), so any round with a refund triggers one of the following:
Protocol fees stolen and locked. Round 1 leaves 0.8 ETH of fees. In round 2, 5 players enter and 1 refunds, so the balance is 4.8 ETH. The draw pays 4 ETH (80% of 5) instead of 3.2 ETH, taking 0.8 ETH of fees. The final balance is 0.8 ETH, but totalFees = 1.8 ETH. withdrawFees reverts forever, so every past and future fee is locked.
Draw reverts. With 4 players and 1 refund, the balance is 3 ETH but the prize is 3.2 ETH, so selectWinner reverts with "Failed to send prize pool to winner".
Zero-address winner. If winnerIndex hits a refunded slot, the prize call to address(0) succeeds, then _safeMint(address(0)) reverts with "ERC721: mint to the zero address".
test/PuppyRaffleAudit.t.sol:
test_PoC_RefundedSlotOverpaysWinnerFromFeesAndLocksWithdraw
test_PoC_RefundedSlotMakesDrawRevert
test_PoC_RefundedZeroSlotCanWinAndRevertsMint
Run:
The output is in poc.txt: balance 0.8e18, totalFees 1.8e18.
Remove refunded players from the array instead of zeroing them (swap-and-pop), so that players.length always equals the number of paid, active tickets. Alternatively, keep an explicit active-player counter and use it for the pot, and never select an empty slot.
## 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.