Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

withdrawFees requires address(this).balance == totalFees, which an attacker can break forever by force-sending ETH (selfdestruct), locking all fees

Forced ETH transfer permanently locks fees by breaking withdrawFees strict balance check

Description

PuppyRaffle::withdrawFees (src/PuppyRaffle.sol:158) requires address(this).balance == uint256(totalFees) before paying out. An attacker can permanently break this exact equality by force-sending ETH into the contract via selfdestruct (or a pre-funded create2 address), since forced transfers bypass receive/fallback and increase the balance without updating totalFees.

require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!"); // @> strict equality is grief-able

Risk

Likelihood:

Medium. selfdestruct-forced ETH is a well-known, low-cost griefing primitive any address can execute. The attacker needs only a small amount of ETH and no special relationship to the protocol.

Impact:

Medium. Once address(this).balance exceeds totalFees, the equality can never be satisfied again, so withdrawFees reverts permanently and all accumulated fees are locked in the contract with no recovery path. The fee address is denied its rightful funds.

Proof of Concept

A tiny attacker contract self-destructs into the raffle, after which withdrawFees always reverts.

contract ForceFunder {
constructor(address payable target) payable {
selfdestruct(target); // @> pushes ETH; balance now != totalFees
}
}
function test_lockFees() public {
new ForceFunder{value: 1 wei}(payable(address(puppyRaffle)));
vm.expectRevert("PuppyRaffle: There are currently players active!");
puppyRaffle.withdrawFees();
}

Recommended Mitigation

Track withdrawable fees explicitly instead of comparing against the contract's raw balance.

- require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");
- uint256 feesToWithdraw = totalFees;
+ uint256 feesToWithdraw = totalFees;
totalFees = 0;
(bool success,) = feeAddress.call{value: feesToWithdraw}("");
require(success, "PuppyRaffle: Failed to withdraw fees");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!