Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Lack of Access Control in `withdrawFees()` Allows Unauthorized Fee Withdrawal

Description

The PuppyRaffle::withdrawFees function is intended to allow the owner to withdraw accumulated fees to the feeAddress. However, the function lacks any access control modifier (such as onlyOwner). This means any external account can call withdrawFees() at any time, provided the condition address(this).balance == uint256(totalFees) is met (i.e., when there are no active players and the contract holds exactly the fee amount). This violates the role specification documented in the project, where only the Owner should have the power to manage fee withdrawals.

// Root cause: no access control on withdrawFees()
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");
}

Risk

Likelihood: Medium – The function can only be called when the players array is empty (after a raffle has finished and selectWinner has reset it). However, once that condition is met, any attacker can front‑run or simply invoke the function before the owner does.

Impact: High – The owner loses control over the timing of fee withdrawals. An attacker can withdraw fees prematurely, potentially disrupting operational plans (e.g., if the feeAddress is a multisig that requires preparation). In a worst‑case scenario where the feeAddress is compromised or maliciously changed, an attacker could instantly drain the fee balance. Moreover, this contradicts the documented role of the Owner as the sole entity responsible for fee management.

Proof of Concept

The following test demonstrates that an account other than the owner can successfully call withdrawFees() after the raffle ends:

function testUnauthorizedWithdrawFees() public {
// 1. Four players enter the raffle
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
// 2. Finish the raffle (select winner)
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
puppyRaffle.selectWinner();
// 3. An attacker (not the owner) calls withdrawFees()
address attacker = address(0x1234);
vm.prank(attacker);
puppyRaffle.withdrawFees();
// 4. Assertion: fees are successfully sent to feeAddress
uint256 expectedFee = (entranceFee * 4 * 20) / 100;
assertEq(address(feeAddress).balance, expectedFee);
}

Run the test:

forge test --match-test testUnauthorizedWithdrawFees -vvvv

Output (relevant section):

[PASS] testUnauthorizedWithdrawFees() (gas: 317758)
Traces:
...
[0] VM::prank(0x0000000000000000000000000000000000001234)
[33002] PuppyRaffle::withdrawFees()
├─ [0] 0x0063::fallback{value: 800000000000000000}()
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped

The test passes, confirming that an arbitrary address (attacker) can withdraw the fees without being the owner.

Recommended Mitigation

Add the onlyOwner modifier to the withdrawFees() function, matching the access control already used in changeFeeAddress(). This ensures that only the contract owner can trigger fee withdrawals, aligning with the documented roles.

- function withdrawFees() external {
+ function withdrawFees() external onlyOwner {
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");
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!