The withdrawFees() function uses strict equality check (==) instead of greater-than-or-equal (>=) when comparing the contract balance to totalFees. An attacker can send 1 wei to the contract via selfdestruct, permanently preventing fee withdrawal at minimal cost.
Describe the normal behavior in one or more sentences:
The fee address should be able to withdraw accumulated fees when no raffle is active
The withdrawal should succeed when the contract has sufficient balance to cover the fees
Explain the specific issue or problem in one or more sentences:
The withdrawFees() function requires address(this).balance == uint256(totalFees) with strict equality
An attacker can create a contract with 1 wei and call selfdestruct(payable(puppyRaffle)) to forcibly send ETH
This makes address(this).balance > totalFees, causing the equality check to fail permanently
The attack costs only 1 wei plus gas, while locking all accumulated fees
Likelihood:
Reason 1: Can be executed by anyone with minimal cost (1 wei plus gas)
Reason 2: Can be repeated indefinitely to maintain the lock
Impact:
Impact 1: All accumulated fees become permanently locked in the contract
Impact 2: Asymmetric griefing attack where attacker spends pennies to lock potentially large amounts
Place the following test in test/AuditTest.t.sol:
Run with: forge test --mt testGriefingWithdrawFees -vv
The test passes, confirming the griefing attack works.
Use greater-than-or-equal comparison and check for active players properly:
## 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.