The withdrawFees() function uses a strict equality check (==) to verify that no active raffle is in progress. However, an attacker can permanently break this function by force-sending a small amount of ETH to the contract via selfdestruct.
The vulnerability exists because:
ETH can be force-sent to any contract via selfdestruct(targetAddress) - the target cannot reject it
After force-sending ETH: address(this).balance > totalFees
The strict == check will always fail
Accumulated fees become permanently locked
Even sending just 1 wei is enough to break the function forever.
Likelihood: Medium
Requires an attacker willing to spend gas on the attack
Attack cost is minimal (just deployment gas + 1 wei)
No special timing or conditions required
Impact: High
All accumulated fees are permanently locked
The feeAddress can never withdraw fees
Protocol loses all revenue from completed raffles
Only mitigation is deploying a new contract and migrating
The following test demonstrates how 1 wei permanently locks all fees:
Run with: forge test --mt test_selfDestructBreaksWithdrawFees -vvv
Output:
Replace the strict equality check with a comparison that accounts for potential extra ETH:
Note: This change alone may not be sufficient as the original check was intended to prevent withdrawal during an active raffle. A better approach would be to track active players explicitly:
## 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.