Puppy Raffle

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

Two refunds leave duplicate address(0) entries and block every new enterRaffle call

Root + Impact

Description

  • Players should be able to keep entering the raffle after other players refund.

  • A refund leaves address(0) in players. After two refunds the array contains address(0) twice, and the duplicate check in enterRaffle loops over the whole array, so every later enterRaffle call reverts with Duplicate player, no matter who enters.

function enterRaffle(address[] memory newPlayers) public payable {
...
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"); // two address(0) slots
}
}

Attack path:

  1. Four players enter (round is open).

  2. Player at index 0 calls refund(0) -> players[0] = address(0).

  3. Player at index 1 calls refund(1) -> players[1] = address(0).

  4. Any new user calls enterRaffle([newUser]). The nested loop reaches i = 0, j = 1, finds address(0) == address(0) and reverts with Duplicate player.

  5. Nobody can enter until the round ends; a griefer only needs two tickets that are refunded afterwards (net cost: gas).

Risk

Likelihood:

  • Any two refunds in one round trigger it; a single user with two tickets can do it on purpose.

Impact:

  • New entries are blocked for the rest of the round (denial of service of the main feature). Combined with the refunded-slot accounting bug the round may never finish.

Proof of Concept

All tests run in a test contract that deploys PuppyRaffle(1e18, address(99), 1 days) and uses the helpers _players(start,n) (distinct addresses) and _enter(p) (pays 1e18 * p.length). Full file: test/AuditPoC.t.sol, forge test --mc AuditPoC passes.

function test_M1_twoRefundsBlockAllNewEntries() public {
address[] memory p = _players(1, 4);
_enter(p);
vm.prank(p[0]);
r.refund(0);
vm.prank(p[1]);
r.refund(1);
vm.expectRevert("PuppyRaffle: Duplicate player");
_enter(_players(100, 1));
}

Recommended Mitigation

function refund(uint256 playerIndex) public {
...
- players[playerIndex] = address(0);
+ players[playerIndex] = players[players.length - 1];
+ players.pop();
payable(msg.sender).sendValue(entranceFee);

Alternatively keep the slots but skip empty entries in the check:

- require(players[i] != players[j], "PuppyRaffle: Duplicate player");
+ if (players[i] != address(0)) {
+ require(players[i] != players[j], "PuppyRaffle: Duplicate player");
+ }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 9 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] `PuppyRaffle::refund` replaces an index with address(0) which can cause the function `PuppyRaffle::selectWinner` to always revert

## Description `PuppyRaffle::refund` is supposed to refund a player and remove him from the current players. But instead, it replaces his index value with address(0) which is considered a valid value by solidity. This can cause a lot issues because the players array length is unchanged and address(0) is now considered a player. ## Vulnerability Details ```javascript players[playerIndex] = address(0); @> uint256 totalAmountCollected = players.length * entranceFee; (bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner"); _safeMint(winner, tokenId); ``` If a player refunds his position, the function `PuppyRaffle::selectWinner` will always revert. Because more than likely the following call will not work because the `prizePool` is based on a amount calculated by considering that that no player has refunded his position and exit the lottery. And it will try to send more tokens that what the contract has : ```javascript uint256 totalAmountCollected = players.length * entranceFee; uint256 prizePool = (totalAmountCollected * 80) / 100; (bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner"); ``` However, even if this calls passes for some reason (maby there are more native tokens that what the players have sent or because of the 80% ...). The call will thankfully still fail because of the following line is minting to the zero address is not allowed. ```javascript _safeMint(winner, tokenId); ``` ## Impact The lottery is stoped, any call to the function `PuppyRaffle::selectWinner`will revert. There is no actual loss of funds for users as they can always refund and get their tokens back. However, the protocol is shut down and will lose all it's customers. A core functionality is exposed. Impact is high ### Proof of concept To execute this test : forge test --mt testWinnerSelectionRevertsAfterExit -vvvv ```javascript function testWinnerSelectionRevertsAfterExit() public playersEntered { vm.warp(block.timestamp + duration + 1); vm.roll(block.number + 1); // There are four winners. Winner is last slot vm.prank(playerFour); puppyRaffle.refund(3); // reverts because out of Funds vm.expectRevert(); puppyRaffle.selectWinner(); vm.deal(address(puppyRaffle), 10 ether); vm.expectRevert("ERC721: mint to the zero address"); puppyRaffle.selectWinner(); } ``` ## Recommendations Delete the player index that has refunded. ```diff - players[playerIndex] = address(0); + players[playerIndex] = players[players.length - 1]; + players.pop() ```

Support

FAQs

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

Give us feedback!