Puppy Raffle

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

Strict balance equality lets anyone force 1 wei into the contract and permanently block fee withdrawals

Strict balance equality lets anyone force 1 wei into the contract and permanently block fee withdrawals

Affected Contract

src/PuppyRaffle.sol

Affected Code

withdrawFees requires the contract balance to exactly equal totalFees.

require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");

Description

The contract assumes its ETH balance can only change through raffle entry, refunds, winner payout, and fee withdrawal. That assumption is false. ETH can be forcibly sent to a contract, for example through selfdestruct, without executing any function on the receiving contract.

After a raffle completes, the contract balance should equal totalFees. An attacker can force-send 1 wei to the raffle before withdrawFees is called. The strict equality check then fails forever because the balance is totalFees + 1.

Risk

Fee withdrawal depends on an exact ETH balance that the contract cannot enforce. Any external account can cheaply desynchronize the balance from totalFees, freezing the protocol's earned fees and making fee collection unreliable.

Impact

Medium. An attacker can permanently freeze protocol fees with a griefing cost of 1 wei. This does not steal player prizes, but it prevents the fee address from collecting earned fees.

Likelihood

High. The attack is permissionless, cheap, and does not require the attacker to be a raffle participant.

Proof of Concept

Added test: test/AuditFindings.t.sol

Run:

forge test --match-test testForcedEtherPermanentlyBlocksFeeWithdrawal -vvv

The test:

  1. Four players enter.

  2. The raffle completes and records fees.

  3. A helper contract selfdestructs 1 wei into PuppyRaffle.

  4. The raffle balance becomes totalFees + 1.

  5. withdrawFees reverts with PuppyRaffle: There are currently players active!.

Recommended Mitigation

Do not use strict equality with address(this).balance as the active-player check. Track active deposits separately and allow withdrawing exactly totalFees when there are no active players.

For example:

require(activePlayerCount == 0, "PuppyRaffle: There are currently players active!");
uint256 feesToWithdraw = totalFees;
totalFees = 0;

Unexpected extra ETH should not block the fee withdrawal path.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!