Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Strict equality check in withdrawFees can be permanently bricked via selfdestruct

# Strict equality check in `PuppyRaffle::withdrawFees` can be permanently bricked via `selfdestruct`
## Description
The `withdrawFees` function uses a strict equality check `address(this).balance == uint256(totalFees)` to verify there are no active player deposits before allowing fee withdrawal. However, any user can forcefully send ETH to the contract using `selfdestruct`, which bypasses `receive`/`fallback` functions and cannot be rejected.
Once even 1 wei of extra ETH is force-sent, `address(this).balance` permanently exceeds `uint256(totalFees)`, and the strict equality check always fails. This locks all accumulated protocol fees in the contract forever.
This vulnerability is independent of access control -- even if `withdrawFees` had an `onlyOwner` modifier, the `selfdestruct` griefing attack would still work.
```solidity
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");
}
```
## Risk
**Likelihood**:
* Anyone can force-send ETH to any contract via `selfdestruct` -- this cannot be prevented or rejected by the receiving contract
* The attack costs only 1 wei plus gas, making it practically free to execute
**Impact**:
* All accumulated protocol fees become permanently locked in the contract
* The `feeAddress` owner loses all revenue with no path to recovery
* The attack is irreversible -- once the balance is desynchronized, there is no mechanism to re-align it with `totalFees`
## Proof of Concept
1. Four players enter the raffle and we advance time past the duration. We call `selectWinner`, which distributes 80% to the winner and records 20% as `totalFees`. At this point, `address(this).balance == uint256(totalFees)` holds true and `withdrawFees` would succeed.
2. The attacker deploys a `SelfDestructAttacker` contract and calls `attack{value: 1 wei}(address(puppyRaffle))`. The `selfdestruct` opcode forcefully sends 1 wei to `PuppyRaffle`, bypassing any `receive`/`fallback` function -- there is no way for the contract to reject this ETH.
3. Now `address(puppyRaffle).balance` is `totalFees + 1 wei`, but `uint256(totalFees)` hasn't changed. The strict equality check `address(this).balance == uint256(totalFees)` fails permanently.
4. The test confirms that `withdrawFees` reverts with "There are currently players active!" -- even though there are no active players. The protocol's accumulated fees are locked forever.
5. This attack costs only 1 wei plus gas, making it extremely cheap to execute.
```solidity
contract SelfDestructAttacker {
function attack(address target) external payable {
selfdestruct(payable(target));
}
}
function testSelfDestructGriefing() public playersEntered {
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
puppyRaffle.selectWinner();
// At this point, withdrawFees should work
// But attacker force-sends 1 wei to the contract
SelfDestructAttacker attacker = new SelfDestructAttacker();
attacker.attack{value: 1 wei}(address(puppyRaffle));
// Now the balance check fails and fees are locked forever
vm.expectRevert("PuppyRaffle: There are currently players active!");
puppyRaffle.withdrawFees();
}
```
## Recommended Mitigation
Remove the strict equality check on `address(this).balance` entirely. Instead, rely on the internally tracked `totalFees` variable and simply verify there are fees to withdraw:
```diff
function withdrawFees() external {
- require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");
uint256 feesToWithdraw = totalFees;
+ require(feesToWithdraw > 0, "PuppyRaffle: No fees to withdraw");
totalFees = 0;
(bool success,) = feeAddress.call{value: feesToWithdraw}("");
require(success, "PuppyRaffle: Failed to withdraw fees");
}
```
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] Slightly increasing puppyraffle's contract balance will render `withdrawFees` function useless

## 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"); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!