Root cause: PuppyRaffle::selectWinner calls _safeMint — which invokes onERC721Received on the winner — before it clears players and resets raffleStartTime, so both entry guards still pass during the callback.
Impact: a contract winner re-enters selectWinner from the mint hook and the protocol pays out two full prize pools for a single round, draining the fees accumulated from previous rounds.
A round must distribute exactly one prize pool. delete players and raffleStartTime = block.timestamp are what close the round and make a second draw impossible.
Those two writes happen at lines 148-149, after _safeMint at line 136. _safeMint calls onERC721Received on a contract winner, and at that moment players is still populated and raffleStartTime still holds the old value. The two require statements at the top of selectWinner therefore still pass, and the whole draw runs a second time inside the first.
Likelihood:
Occurs whenever the drawn winner is a contract implementing onERC721Received, which it must implement anyway for _safeMint to succeed. Combined with the predictable draw, an attacker chooses when to be that winner.
It needs the contract to hold more than the current round's stakes, which is the normal state: withdrawFees is a manual, separate call, so fees pile up across rounds.
Impact:
Two prize pools leave the contract in one round. In the run below, 6.4 ether is paid out for a round that collected 4 ether, consuming the fees of three earlier rounds.
totalFees is credited twice while the ETH backing it is gone, so withdrawFees — which requires address(this).balance == totalFees — can never succeed again.
Three ordinary rounds run first so fees accumulate, then the attacker wins the fourth:
Control test — the same winner with the re-entry disabled takes exactly one prize pool, so the doubling is caused by the callback and nothing else:
Both tests pass on the audited commit with forge test.
Note: the inner draw recomputes winnerIndex with msg.sender equal to the attacker contract, so the second payment may land on another player. The protocol still loses two prize pools; an attacker who enters with several addresses they control captures both.
Close the round before minting, and follow checks-effects-interactions throughout: take the draw state down first, then mint, then pay.
Adding a nonReentrant modifier to selectWinner closes the same hole and also protects against any future callback introduced above the state writes.
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.