Summary
The claimThrone
function remains accessible to players even after the grace period has elapsed, as long as no one calls the declareWinner
function. This allows users to continue making throne claims beyond the intended game duration. The game is only effectively concluded, preventing further claims when declareWinner
is explicitly invoked in which the global gameEnded
flag is then set to true. Until then, the contract does not enforce the grace period, allowing unintended gameplay continuation.
Description
The claimThrone
function uses the gameNotEnded
modifier to restrict access based on the game's state. However, this modifier checks only the gameEnded
boolean flag to determine whether the game has concluded, rather than verifying whether the grace period has elapsed. The gameEnded
flag is only set to true when the declareWinner
function is called. As a result, if no one invokes declareWinner
, the game can continue indefinitely even after the grace period has expired, allowing players to keep claiming the throne beyond the intended end of the game.
<@@> function claimThrone() external payable gameNotEnded nonReentrant {...}
modifier gameNotEnded() {
<@@> require(!gameEnded, "Game: Game has already ended. Reset to play again.");
_;
}
function declareWinner() external gameNotEnded {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
require(
block.timestamp > lastClaimTime + gracePeriod,
"Game: Grace period has not expired yet."
);
<@@> gameEnded = true;
pendingWinnings[currentKing] = pendingWinnings[currentKing] + pot;
pot = 0;
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
}
Risk
Likelihood:
Depending on player behavior and familiarities of the contract’s game rules, the exploitation can be moderate to high. Since the grace period is not automatically enforced and requires a manual call to declareWinner
, a player or group of players with knowledge of this design flaw could intentionally delay this call to continue participating in the game beyond the intended time limit to have bigger pot value cumulated towards the end. Furthermore, as there is no built-in mechanism to trigger declareWinner
after the grace period, the game remains vulnerable to extended, unintended gameplay unless an external actor intervenes.
Impact:
This vulnerability allows players to continue claiming the throne even after the grace period has ended. As a result, the game duration can be extended indefinitely, which may lead to unfair gameplay advantages, disruption of expected game flow, and potential economic implications depending on the value associated with final winning pot. Additionally, it introduces uncertainty for participants expecting a definitive end to the game based on time constraints, which could erode trust in the contract's integrity.
Proof of Concept
In test/Game.t.sol
, add the following test:
function test_audit_gracePeriodIssue() public {
vm.prank(player1);
game.claimThrone{value: INITIAL_CLAIM_FEE}();
vm.warp(game.lastClaimTime() + GRACE_PERIOD + 1 minutes);
bool gameEnded = game.gameEnded();
assertFalse(gameEnded);
vm.prank(player2);
uint256 player2claimAmount = INITIAL_CLAIM_FEE * 110 / 100;
game.claimThrone{value: player2claimAmount}();
assertEq(game.currentKing(), player2);
vm.warp(game.lastClaimTime() + GRACE_PERIOD + 1 minutes);
game.declareWinner();
assertTrue(game.gameEnded());
vm.prank(player3);
vm.expectRevert("Game: Game has already ended. Reset to play again.");
game.claimThrone{value: player2claimAmount * 110 / 100}();
}
In terminal, run forge test --match-test test_audit_gracePeriodIssue -vvv
will generate the following results:
$ forge test --match-test test_audit_gracePeriodIssue -vvv
...
Ran 1 test for test/Game.t.sol:GameTest
[PASS] test_audit_gracePeriodIssue() (gas: 244130)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 847.79µs (127.67µs CPU time)
Ran 1 test suite in 118.97ms (847.79µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
All assertion tests and expectRevert pass indicating that player can still claimThrone
even though grace period elapsed and only failed to claim when someone called the declareWinner
function.
Recommended Mitigation
To implement time constraints check in a new function isGameEnded
, update/remove the gameEnd
flag accordingly, so the isGameEnded
function which replaces the gameEnd
flag will be consistent across all calls without depending on either modifier or the declareWinner
function:
// Game Core State
address public currentKing; // The address of the current "King"
...
- bool public gameEnded;
constructor(
uint256 _initialClaimFee,
uint256 _gracePeriod,
uint256 _feeIncreasePercentage,
uint256 _platformFeePercentage
) Ownable(msg.sender) { // Set deployer as owner
require(_initialClaimFee > 0, "Game: Initial claim fee must be greater than zero.");
...
// Initialize game state for the first round
claimFee = initialClaimFee;
gracePeriod = initialGracePeriod;
lastClaimTime = block.timestamp; // Game starts immediately upon deployment
gameRound = 1;
- gameEnded = false;
// currentKing starts as address(0) until first claim
}
+ function isGameEnded() public view returns (bool) {
+ return block.timestamp >= lastClaimTime + gracePeriod;
+ }
modifier gameNotEnded() {
- require(!gameEnded, "Game: Game has already ended. Reset to play again.");
+ require(!isGameEnded(), "Game: Game has already ended. Reset to play again.");
_;
}
modifier gameEndedOnly() {
- require(gameEnded, "Game: Game has not ended yet.");
+ require(isGameEnded(), "Game: Game has not ended yet.");
_;
}
...
- function declareWinner() external gameNotEnded {
+ function declareWinner() external gameEndedOnly {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
- require(
- block.timestamp > lastClaimTime + gracePeriod,
- "Game: Grace period has not expired yet."
- );
- gameEnded = true;
pendingWinnings[currentKing] = pendingWinnings[currentKing] + pot;
pot = 0; // Reset pot after assigning to winner's pending winnings
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
}
function resetGame() external onlyOwner gameEndedOnly {
currentKing = address(0);
lastClaimTime = block.timestamp;
pot = 0;
claimFee = initialClaimFee;
gracePeriod = initialGracePeriod;
- gameEnded = false;
...
}
function getRemainingTime() public view returns (uint256) {
- if (gameEnded)
+ if (isGameEnded()) {
return 0; // Game has ended, no remaining time
}
- uint256 endTime = lastClaimTime + gracePeriod;
- if (block.timestamp >= endTime) {
- return 0; // Grace period has expired
- }
return endTime - block.timestamp;
}