The PuppyRaffle::withdrawFees function uses a strict equality check (==) to verify that the contract only contains fee money before allowing withdrawal. This creates a permanent DoS vulnerability where anyone can send a tiny amount of ETH directly to the contract (via selfdestruct or as a coinbase recipient), making the equality check always fail and permanently preventing fee withdrawal.
The Problem:
The check assumes contract balance == totalFees means no active raffle. However:
Anyone can send ETH directly to the contract via selfdestruct or force-sending
Contract can receive block rewards as coinbase recipient
This makes address(this).balance > totalFees
The equality check == fails forever
Fees can never be withdrawn
Likelihood: Medium - Requires attacker to deliberately send ETH to contract, but costs minimal amount (1 wei is enough).
Impact: High - Permanent loss of all accumulated protocol fees. Owner cannot recover funds.
Attack Steps:
Setup:
PuppyRaffle deployed
Several raffles completed
totalFees = 10 ETH
contract.balance = 10 ETH
Fee withdrawal works: 10 == 10 ✓
Attack:
Attacker deploys FeeWithdrawalDoS with 1 wei
Calls attack(puppyRaffleAddress)
Contract self-destructs, forcing 1 wei to PuppyRaffle
Now: contract.balance = 10 ETH + 1 wei
But: totalFees = 10 ETH
Result:
Owner calls withdrawFees()
Check: 10 ETH + 1 wei == 10 ETH ❌ FALSE
Transaction reverts with "There are currently players active!"
Fees permanently locked - no way to withdraw
Cost of Attack: 1 wei + gas fees (~30k gas) = negligible
Manual review
Use >= instead of == to allow withdrawal when contract has at least the fee amount:
Why This Works:
If balance >= totalFees, there's no active raffle (or extra ETH was sent)
Fee withdrawal succeeds regardless of extra ETH sent to contract
Any excess ETH beyond totalFees remains in contract (minor, but acceptable)
Cannot be DoS'd by force-sending ETH
Alternative: Track player deposits separately:
Best Practice: Use >= comparison as it's the simplest fix that prevents the DoS attack.
## 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.