src/PuppyRaffle.sol
withdrawFees requires the contract balance to exactly equal totalFees.
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.
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.
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.
High. The attack is permissionless, cheap, and does not require the attacker to be a raffle participant.
Added test: test/AuditFindings.t.sol
Run:
The test:
Four players enter.
The raffle completes and records fees.
A helper contract selfdestructs 1 wei into PuppyRaffle.
The raffle balance becomes totalFees + 1.
withdrawFees reverts with PuppyRaffle: There are currently players active!.
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:
Unexpected extra ETH should not block the fee withdrawal path.
## 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.