The withdrawFees() function uses a strict equality check between the contract's ETH balance and the totalFees variable to guard against withdrawal while players are active:
This check assumes the only way ETH enters the contract is through enterRaffle(). However, there are two additional ways ETH can be forced into a contract that bypass all payable restrictions:
selfdestruct: Any contract can call selfdestruct(address(puppyRaffle)) to forcefully send ETH to the raffle contract. This cannot be rejected.
Pre-funding before deployment: ETH can be sent to the contract address before it is deployed using a CREATE2-predictable address.
Once any amount of extra ETH enters the contract — even 1 wei — the strict equality address(this).balance == uint256(totalFees) can never be satisfied again. This permanently bricks withdrawFees(), trapping all accumulated fees in the contract forever with no recovery mechanism.
Likelihood:
Any attacker with any amount of ETH (as little as 1 wei) can execute this attack using selfdestruct
The attack is irreversible — once executed, there is no way to restore the balance equality
A griefing attacker gains nothing financially but can permanently destroy the protocol's fee revenue
A competing protocol operator could perform this attack to harm a rival
The cost to the attacker is negligible (just gas + 1 wei)
Impact:
withdrawFees() is permanently bricked — the feeAddress can never withdraw any fees again
All ETH tracked by totalFees is trapped in the contract with no recovery path
The protocol owner loses 100% of all future and past fee revenue
There is no emergency function, no owner override, and no upgrade mechanism to fix this
If the totalFees overflow bug (separate finding) is also triggered, the combination makes the situation even worse
The Certora Prover rule withdrawFees_always_possible was formally verified and returned VIOLATED, providing mathematical proof that withdrawFees() reverts whenever nativeBalances[contract] != totalFees:
Replace the strict equality check with a greater-than-or-equal comparison, and withdraw only the tracked totalFees amount rather than the entire balance:
This change allows withdrawFees() to succeed even if extra ETH has been forced into the contract, while still correctly sending only the tracked fee amount to feeAddress. Any excess ETH from selfdestruct attacks remains in the contract but does not block fee withdrawal.
## 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.