The owner picks the wallet that receives the protocol's 20% cut, either in the constructor or later through changeFeeAddress. withdrawFees then sends the fees accrued in totalFees to that wallet once no players are active.
The constructor (L62) and changeFeeAddress (L168) store feeAddress without rejecting address(0), and withdrawFees (L157) has no caller restriction. withdrawFees sets totalFees to 0 (L160) and then sends the balance with a low-level call (L161). A value call to address(0) succeeds because no code lives there, so the require at L162 passes. Once feeAddress == address(0), any account can send the whole fee balance to address(0), where it is lost for good, before the owner can correct the address. As a secondary effect, the inherited OZ 3.4.0 renounceOwnership lets a bad feeAddress become permanent, because changeFeeAddress can no longer be called.
Likelihood:
The path starts only when the trusted owner makes an input error, passing address(0) as _feeAddress to the constructor or as newFeeAddress to changeFeeAddress. The README gives the owner this power, so it is a configuration mistake by a trusted role, not something an outsider can cause.
The burn needs the L158 check (address(this).balance == totalFees) to hold. That is true between rounds: after selectWinner has paid out and before new entrants arrive.
Once address(0) is in place, which anyone can see in the constructor arguments, the feeAddress() getter, or the FeeAddressChanged(address(0)) event, any account can trigger the burn with no privileges and no funds. The owner's chance to correct the mistake depends on getting changeFeeAddress mined before anyone else calls withdrawFees.
Impact:
All fees accrued in totalFees go to address(0) and cannot be recovered. In the PoC one 4-player round at a 1 ETH entrance fee burns 0.8 ETH. totalFees is reset to 0 at the same time, so the owner's later changeFeeAddress plus withdrawFees pays the intended wallet 0.
The loss is limited to the protocol's own fee revenue. Player entrance fees and the prize pool are not affected, which is why the impact is Medium rather than High.
Secondary: a non-payable feeAddress alone only makes withdrawFees revert, and nothing is lost because the totalFees = 0 write rolls back and one changeFeeAddress call recovers the funds. Fees become permanently locked only when the owner also calls the inherited renounceOwnership, which needs a second owner action.
test/PoC_PR-08.t.sol deploys PuppyRaffle, plays one 4-player round so the contract holds exactly the 0.8 ETH in fees, and covers each path. A baseline with a valid wallet pays that wallet. With address(0) set through the constructor or through changeFeeAddress (whose FeeAddressChanged event is checked), a non-owner's withdrawFees raises address(0)'s balance by 0.8 ETH, totalFees and the contract balance drop to 0, and the intended wallet gets 0 after the owner's correction. A non-payable feeAddress only reverts and is fully recoverable. After renounceOwnership the funds stay locked. Run it with: forge test --match-path test/PoC_PR-08.t.sol -vv
File test/PoC_PR-08.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_PR-08.t.sol -vv.
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.