PuppyRaffle::withdrawFees strict balance check can be bricked by force-feeding ETH, locking feesLikelihood: High — Anyone can force ETH into the contract via selfdestruct (or a pre-computed address funded before deployment); it bypasses receive/payable and cannot be prevented.
Impact: Medium — The accrued protocol fees become permanently un-withdrawable (locked). No user principal is stolen, but legitimately-earned fees are frozen.
Severity: Medium (Medium impact × High likelihood).
withdrawFees gates withdrawal on a strict equality between the contract's ETH balance and totalFees:
The contract's balance can be increased without going through enterRaffle by force-feeding ETH (e.g. selfdestruct(payable(puppyRaffle))), which bypasses all function logic and does not update totalFees. Once address(this).balance > totalFees, the strict == check can never be satisfied again.
An attacker sends as little as 1 wei via selfdestruct to permanently break withdrawFees; the fee recipient can never withdraw the accrued fees. Denial of service / permanent lock of protocol funds.
After a normal raffle where balance == totalFees, a ForceFeeder contract selfdestructs 1 wei into the raffle; withdrawFees() then reverts with "There are currently players active!".
Do not rely on the contract's raw balance for accounting. Withdraw based on the tracked totalFees directly (and drop the brittle strict equality):
This pays out the exact fees owed regardless of any extra (force-fed) ETH sitting in the contract.
## 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.