Summary
The game contract can enter a state where it becomes unplayable due to insufficient funds in its balance. This results in the inability to process payouts during the endGame function, leading to a denial of service (DoS) for future games.
Vulnerability Details
The endGame function relies on the contract’s balance to pay out winnings. If the balance drops below the required payout amount, subsequent games cannot be completed. This leads to a DoS scenario, effectively halting the game for all players. Such a situation can arise if multiple winning payouts deplete the contract’s funds.
PoC
Add this test to the TwentyOne.t.sol file.
function testDos_DueDrainedFunds() public{
deal(address(twentyOne), 2 ether);
uint256 initialBalance = address(twentyOne).balance;
console.log("this is the balance of twentyOne before game:", initialBalance);
vm.startPrank(player1);
twentyOne.startGame{value: 1 ether}();
console.log("Balance after player1 initializes first game:", address(twentyOne).balance);
vm.mockCall(
address(twentyOne),
abi.encodeWithSignature("dealersHand()"),
abi.encode(21)
);
uint256 player1Hand = twentyOne.playersHand(player1);
while (player1Hand <= 10) {
twentyOne.hit();
console.log("Player1's hand total after hitting:", player1Hand);
}
twentyOne.call();
console.log("this is the balance of twentyOne after player1 firstgame", address(twentyOne).balance);
twentyOne.startGame{value: 1 ether}();
console.log("this is the balance of twentyOne after player1 secondgame was intialized", address(twentyOne).balance);
vm.mockCall(
address(twentyOne),
abi.encodeWithSignature("dealersHand()"),
abi.encode(21)
);
while (player1Hand <= 10) {
twentyOne.hit();
console.log("Player1's hand total after hitting:", player1Hand);
}
twentyOne.call();
require(address(twentyOne).balance < 1 ether,"Test failed: Contract should not have enough balance to process payouts.");
console.log("this is the balance of twentyOne after player1 secondgame", address(twentyOne).balance);
vm.stopPrank();
vm.startPrank(player2);
twentyOne.startGame{value: 1 ether}();
console.log("this is the balance of twentyOne after player2 firstgame was intialized", address(twentyOne).balance);
vm.mockCall(
address(twentyOne),
abi.encodeWithSignature("dealersHand()"),
abi.encode(21)
);
uint256 player2Hand = twentyOne.playersHand(player2);
while (player2Hand <= 10) {
twentyOne.hit();
console.log("Player1's hand total after hitting:", player2Hand);
}
twentyOne.call();
require(address(twentyOne).balance == 1 ether,"Test failed: Contract should not have enough balance to process payouts.");
console.log("this is the balance of twentyOne after", address(twentyOne).balance);
vm.stopPrank();
}
Run forge test --match-test testDos_DueDrainedFunds -vvv on your terminal.
Result:
Ran 1 test for test/TwentyOne.t.sol:TwentyOneTest
[PASS] testDos_DueDrainedFunds() (gas: 3404940)
Logs:
this is the balance of twentyOne before game: 2000000000000000000
Balance after player1 initializes first game: 3000000000000000000
this is the balance of twentyOne after player1 firstgame 1000000000000000000
this is the balance of twentyOne after player1 secondgame was intialized 2000000000000000000
this is the balance of twentyOne after player1 secondgame 0
this is the balance of twentyOne after player2 firstgame was intialized 1000000000000000000
this is the balance of twentyOne after 1000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.65ms (2.61ms CPU time)
Ran 1 test suite in 40.67ms (3.65ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
Since the transfer is handled internally by the endGame function the player might not be payed.
This inconsistency in staye may affect other future games, in the case of several multiple wins.
Tools Used
Foundry 0.2.0 and the console functionality.
Recommendations
require(
address(this).balance >= 1 ether,
"Contract balance too low to start a new game."
);
Introduce a dynamic payout cap based on the contract’s current balance, ensuring that payouts are always possible without draining the contract.