Puppy Raffle

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

Missing Access Control on withdrawFees Allows Anyone to Force Fee Withdrawal

Description

The PuppyRaffle::withdrawFees function lacks access control, allowing any address to trigger fee withdrawal to the feeAddress. According to the protocol specification, the Owner role should control fee management (through changeFeeAddress), but any Player can force the protocol to withdraw fees at any time.

Expected behavior: Based on the role model, only the Owner should control when fees are withdrawn, similar to how changeFeeAddress has onlyOwner protection.

Actual behavior: Any address can call withdrawFees() and force the protocol to send accumulated fees to feeAddress at any time.

/// @notice this function will withdraw the fees to the feeAddress
function withdrawFees() external { // @> No access control
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:

  • High - Any address can call this function at any time

  • No cost or barrier to exploitation

Impact:

  • Owner loses control over fee withdrawal timing

  • Violates principle of least privilege

  • Potential tax/accounting issues if owner wants specific withdrawal timing

  • If feeAddress is a contract, unexpected ETH transfers could disrupt its logic

  • Griefing: Malicious actors can force withdrawals at inconvenient times

Note: While funds are not stolen (they go to the correct feeAddress), the loss of control over timing violates the specified role model.

Proof of Concept

Add to test/PuppyRaffleTest.t.sol:

/**
* @notice Demonstrates that anyone can call withdrawFees, violating access control
* @dev The spec implies only Owner controls fees, but any player can force withdrawal
*/
function test_anyoneCanWithdrawFees() public {
// Setup: Players enter and raffle completes
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
vm.deal(playerOne, entranceFee * 4);
vm.prank(playerOne);
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
// Fast forward and select winner to accumulate fees
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
puppyRaffle.selectWinner();
uint256 feesAccumulated = uint256(puppyRaffle.totalFees());
uint256 feeAddressBalanceBefore = address(feeAddress).balance;
emit log("=== Access Control Test ===");
emit log_named_address("Owner (deployer)", address(this));
emit log_named_address("Fee address", feeAddress);
emit log_named_uint("Fees accumulated", feesAccumulated);
emit log("");
// A random player (not owner) calls withdrawFees
address randomPlayer = address(999);
emit log_named_address("Random player calling withdrawFees", randomPlayer);
vm.prank(randomPlayer);
puppyRaffle.withdrawFees(); // No revert! Anyone can call this
emit log("");
emit log("=== Result ===");
emit log("SUCCESS: Random player withdrew fees!");
emit log_named_uint("Fee address balance after", address(feeAddress).balance);
emit log("");
emit log("=== Problem ===");
emit log("withdrawFees has no access control");
emit log("Any player can force fee withdrawal at any time");
emit log("Owner loses control over WHEN fees are withdrawn");
assertEq(address(feeAddress).balance, feeAddressBalanceBefore + feesAccumulated);
assertEq(uint256(puppyRaffle.totalFees()), 0);
}

Run: forge test --match-test test_anyoneCanWithdrawFees -vv --offline

Output:

=== Access Control Test ===
Owner (deployer): 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496
Fee address: 0x0000000000000000000000000000000000000063
Fees accumulated: 800000000000000000
Random player calling withdrawFees: 0x00000000000000000000000000000003E7
=== Result ===
SUCCESS: Random player withdrew fees!
Fee address balance after: 800000000000000000
=== Problem ===
withdrawFees has no access control
Any player can force fee withdrawal at any time
Owner loses control over WHEN fees are withdrawn

A completely random player (address 0x3E7) successfully triggered fee withdrawal despite not being the owner.

Recommended Mitigation

Add onlyOwner modifier to restrict fee withdrawal to the owner:

- 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");
}

This aligns with the role model where the Owner controls fee management.


Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 8 days 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!