Normal behavior: The withdrawFees function is intended to allow the protocol’s feeAddress to withdraw accumulated raffle fees once a raffle round has finished and no active players remain.
Under normal conditions, the function should reliably transfer all collected protocol fees to feeAddress.
Problem: The withdrawFees function relies on the contract’s ETH balance as a proxy for internal accounting state by requiring address(this).balance == totalFees.
However, ETH can be forcibly sent to the contract via mechanisms such as selfdestruct, bypassing all contract logic and without triggering receive or fallback functions.
Once the contract balance is modified externally, this balance-based invariant is permanently broken, causing the withdrawFees require check to always revert.
As a result, protocol fees become irrecoverably locked, even though no active players remain and the raffle continues to operate normally.
Likelihood:
The protocol exposes a publicly callable fee withdrawal function that relies on the contract’s ETH balance as an implicit indicator of internal state, making it inherently sensitive to external balance changes.
ETH can be forcibly sent to the contract at any time via standard EVM mechanisms such as selfdestruct, without requiring any interaction with or permissions from the PuppyRaffle contract.
Impact:
Accumulated protocol fees become permanently locked, as withdrawFees() will consistently revert due to a broken balance-based invariant.
The protocol loses access to its revenue stream, creating an irreversible operational and financial impact even though core raffle functionality continues to operate.
ForcedETH.sol attacker contract in src/
Add import to PuppyRaffleTest.t.sol:
Test function:
Avoid using address(this).balance as a proxy for protocol state, since ETH can be forcibly sent to the contract (e.g., via selfdestruct), breaking balance-based invariants.
Instead, track raffle activity explicitly (e.g., via an activePlayersCount variable or a round state flag) and allow fee withdrawal based on internal accounting rather than the raw ETH balance. For example, maintain separate accounting for:
total fees accrued (totalFees)
total deposits owed to active players (or an explicit “raffle active” state)
Then gate withdrawFees() using those internal variables (e.g., require(activePlayersCount == 0)), and withdraw fees independently of any additional ETH that may have been force-sent to the contract.
Optionally, provide an admin recovery mechanism for unexpected ETH (excessETH = address(this).balance - trackedLiabilities) if such behavior is acceptable for the protocol’s trust model.
## 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.