PuppyRaffle.withdrawFees() in src/PuppyRaffle.sol will only pay out the fees if the contract balance is exactly equal to the recorded totalFees. If the contract holds even one wei more than that, the withdrawal reverts, and there is no other way to take the fees out, so they are locked forever.
The vulnerable code is:
The check is meant to make sure no raffle is still running, but it confuses two different things: the balance of the contract and the amount of fees owed. The balance can be larger than totalFees for reasons that have nothing to do with active players. The contract has no receive function, so it cannot accept a plain ether transfer, but ether can still be forced into it with selfdestruct from another contract, which bypasses any receive check. A single wei sent that way is enough. The same mismatch can also arise from rounding: the prize is 80% of the total collected and the fee is 20%, and both divisions round down, so if the total collected is not divisible by 100, up to 1 wei of dust is left behind in each round and the balance no longer matches totalFees. Once either of those things has happened, the equality can never become true again on its own, because nothing in the contract can remove the extra ether.
Likelihood: Medium - forcing ether into a contract with selfdestruct is a well-known, cheap technique that needs no permission and costs only a few wei, and the rounding dust arises from normal use whenever the entrance fee and player count do not divide neatly.
Impact: Medium - the fee address permanently loses all the fees it has earned, including fees from every past raffle round, because they can never be withdrawn. The ether is not sent to the wrong person; it is stuck in the contract with no rescue function, so it is lost to the project for good. An attacker can also use this deliberately to deny the project its income at almost no cost to themselves.
Manual trace - no tests were run; this is a step-by-step reading of the code, with a worked example.
Worked example: suppose one raffle round has finished, the prize has been paid, and the fees recorded are totalFees = 0.8 ether, with the contract balance also exactly 0.8 ether.
An attacker deploys a small contract that holds 1 wei and calls selfdestruct, naming the PuppyRaffle contract as the recipient. The 1 wei is forced into PuppyRaffle, even though it has no receive function.
The contract balance is now 0.8 ether + 1 wei, while totalFees is still 0.8 ether.
The owner, or the fee address, calls withdrawFees(). The first line checks address(this).balance == totalFees, which is now 0.8 ether + 1 wei == 0.8 ether, so it is false and the call reverts with "There are currently players active!", even though no players are active at all.
There is no function that can remove the extra wei, return it, or correct the balance. Calling withdrawFees() again changes nothing, and the 0.8 ether of fees stays locked in the contract permanently.
The same outcome follows from rounding dust alone: if a round collects an amount that is not divisible by 100, the prize and the fee together are 1 wei less than the total, so 1 wei of dust stays in the contract, the balance exceeds totalFees by that wei, and every later withdrawal fails in the same way.
Do not require the balance to equal the fees exactly. Check that no raffle is active in a way that does not depend on the balance, for example by checking that the players array is empty, and withdraw only the recorded fee amount, allowing any extra ether to stay in the contract or be handled separately.
Alternatively, keep the balance check but make it >= instead of ==, so extra ether does not block the withdrawal, and add a separate function to recover stray ether.
## 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.