Normally, a fee-withdrawal guard should check whether player funds are still custodied — not demand that the balance match an internal counter down to the last wei, since ETH can arrive in a contract without going through any function. However, the current implementation uses strict equality, so a single unexpected wei bricks fee withdrawal forever.
The vulnerability exists in PuppyRaffle.sol#L157-L163 (dust trigger at PuppyRaffle.sol#L132-L133).
Root Cause: Denial of service — strict-equality balance check vulnerable to force-feeding; aggravated by integer-division dust.
Vulnerable Code:
Why This Is Exploitable:
ETH can be forced into any contract via selfdestruct(target) without triggering any code. One wei suffices to make address(this).balance permanently greater than totalFees — no function can remove a sub-entranceFee amount, and selectWinner only ever moves the balance and totalFees in lockstep, so the offset persists across rounds. Separately, the prize/fee split uses truncating division (* 80 / 100, * 20 / 100): with entranceFee = 3 wei and 4 players, prize = 9 wei and fee = 2 wei, leaving 1 wei of dust in the balance — the equality fails from the first round with zero attacker involvement.
Likelihood:
Path A (griefing): costs the attacker exactly 1 wei and one transaction; can be executed any time the equality currently holds.
Path B (dust): triggers on its own whenever the deployer chooses an entranceFee that makes players.length * entranceFee * 80 / 100 or * 20 / 100 non-exact.
Impact:
Permanent freezing of the protocol's accumulated fee revenue — the entire totalFees balance becomes unwithdrawable forever.
Griefing against the protocol/fee recipient; no direct profit path for the attacker.
Run: forge test --match-test "testPoC_WithdrawFeesLockedByForceFeed|testPoC_RoundingDustLocksFeesNaturally" -vv
Output (actual run):
Four players enter (4 ETH pot), duration elapses, selectWinner runs. Balance == totalFees == 0.8 ETH.
The attacker deploys a contract, funds it with 1 wei, and selfdestructs it into the raffle. Or (B): deploy the raffle with entranceFee = 3 wei and settle a 4-player round — 1 wei of dust remains.
withdrawFees() reverts with "There are currently players active!" in both cases, and no state transition can ever restore equality.
Gate on whether any players are active rather than on exact balance equality, and accrue the split dust to the fee side so the two never diverge:
Proposed Fix:
References:
## 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.