After a draw, withdrawFees should send the accumulated totalFees to feeAddress. The balance check is meant to confirm that no raffle is in progress, so that player deposits are not mixed up with fees.
withdrawFees uses the contract's raw ETH balance as a stand-in for "no active players" and requires it to equal totalFees exactly (L158). ETH can reach the contract outside the raffle accounting even though it has no receive/fallback: through SELFDESTRUCT (which still moves the balance under EIP-6780) or a transfer to the address before deployment. One extra wei therefore makes the check false at every later point. Each normal draw adds the same amount to both sides, so the gap never closes and every withdrawFees call reverts.
Likelihood:
Any unprivileged account can trigger it at any time for 1 wei plus gas. It can force the wei in with SELFDESTRUCT (from a constructor or from a deployed contract), or by sending it to the contract's predicted address before deployment.
The griefer gains nothing. Only an actor willing to spend money purely to harm the protocol has a reason to do it, which keeps the likelihood Low.
The same lock also arises without an attacker, but only for unusual entrance fees where players.length * entranceFee is not a multiple of 5 wei: L132 and L133 round down separately and leave dust. The deploy value of 1e18 never produces dust.
Impact:
All fees accrued so far, and the fees from every later round, become permanently unwithdrawable. In the PoC, six rounds accrue 4.8 ETH of totalFees against a balance of 4.8 ETH + 1 wei, every withdrawFees call reverts, and neither the original nor a replacement fee address receives anything.
There is no recovery path. The contract has no receive/fallback and no sweep function, and the owner's only lever, changeFeeAddress, does not touch the balance check. The PoC shows withdrawal still reverts after the owner changes the fee address.
The loss is the protocol's entire fee revenue. Entering, drawing and refunds keep working, so player deposits and prizes are not affected.
The PoC runs four players through a normal draw, then forces 1 wei into PuppyRaffle with a SELFDESTRUCT from a contract constructor. It asserts that withdrawFees then reverts with "There are currently players active!" and that changing the fee address does not help. Over five further clean rounds it asserts that the gap stays exactly 1 wei and that all 4.8 ETH of fees stay frozen. Further tests confirm the same lock through SELFDESTRUCT from a deployed contract, through 1 wei pre-funded to the predicted address, and through rounding dust with an entrance fee of 1e18+1. They also show that a fee of 1e18 produces no dust, and that a live entry only delays withdrawal until the entrant refunds. Run it with forge test --match-path test/PoC_PR-06.t.sol -vv; the verified run used forge 1.7.1, solc 0.7.6 and EVM version osaka, so EIP-6780 rules applied.
File test/PoC_PR-06.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_PR-06.t.sol -vv.
## 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.