totalFees is incremented during selectWinner() to track protocol fees.
withdrawFees() enforces strict equality between address(this).balance and totalFees.
Any residual ETH (from active players, refunds, or rounding) breaks the equality condition.
Fees remain locked in the contract, even though they are accounted for.
The withdrawFees() function requires that the contract’s balance exactly equals totalFees before allowing withdrawal.
This accounting assumption is flawed. Because the contract balance also includes entrance fees from active players or refunded tickets, the equality check can fail even when fees are legitimately available. As a result, the feeAddress may be permanently unable to withdraw accumulated fees.
Likelihood:
Impact:
Fee withdrawal lock: Owner cannot withdraw fees if contract balance includes more than totalFees.
Entrance fee is set to 1 ether.
Four players enter the raffle, sending 4 ether total.
Contract balance = 4 ether.
totalFees = 0 initially.
Raffle runs, a winner is selected.
totalFees is incremented by 20% of the pool = 0.8 ether.
Winner receives 3.2 ether.
Contract balance now = 0.8 ether.
Execution:
One player calls refund() before withdrawFees().
Player receives 1 ether back.
Contract balance increases temporarily due to entrance fees, then decreases after refund.
Balance no longer equals totalFees.
Example: address(this).balance = 1.8 ether, totalFees = 0.8 ether.
Result:
Owner calls withdrawFees().
Fails due to strict equality check:
Condition not met → revert.
Fees (0.8 ether) remain locked in contract.
Owner cannot withdraw, even though fees are legitimately accrued.
Relax the equality check to ensure fees can be withdrawn independently of player balances:
Track player funds and fees separately.
## 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.