Randomness Vulnerabilities
Randomness is fundamentally incompatible with blockchain's deterministic, public execution model. Every node running the EVM must reach the same result for the same transaction. This means any "random" value derived from block state — timestamp, difficulty, hash — is either predictable by miners/validators, or knowable by other nodes before your transaction is included. Games, lotteries, NFT trait assignment, and any protocol using on-chain randomness are vulnerable.
There is no true randomness on a public blockchain. Every value derived from block state (block.timestamp, block.prevrandao, blockhash) can be influenced by validators or predicted by sophisticated attackers. The only secure source of on-chain randomness is Chainlink VRF — all other approaches are exploitable.
Why Blockchain Randomness Is Broken
| Source | Who Can Predict/Manipulate? | Manipulation Window | Security |
|---|---|---|---|
| block.timestamp | Validators (within ~12 sec) | ±12 seconds per block | None |
| block.prevrandao | Validators (can choose to skip) | One block ahead | Low |
| blockhash(n) | Miners (pre-Merge), predictable after 256 blocks | Up to 256 blocks | None |
| keccak256(block vars) | Same as the variables used | Same as inputs | None |
| Commit-reveal | Participants can grief (not reveal) | Between commit and reveal | Medium |
| Chainlink VRF | Would require corrupting Chainlink oracle network | None practical | High |
Vulnerable Pattern 1: block.timestamp as Randomness
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TimestampLottery {
uint256 public ticketPrice = 0.1 ether;
// ❌ block.timestamp is set by the validator who proposes the block
// Validators can choose timestamps within ~12 seconds of the parent block
// A validator can keep trying timestamps until block.timestamp % 2 == 0
function buyTicket() external payable {
require(msg.value == ticketPrice, "Wrong price");
// ❌ "Random" outcome based on manipulable block.timestamp
if (block.timestamp % 2 == 0) {
// Winner! Gets 2x back
(bool ok,) = msg.sender.call{value: 0.2 ether}("");
require(ok);
}
// Validator who wins the lottery: just propose a block with winning timestamp
}
// ❌ Also wrong: keccak256 of timestamp + sender — all inputs are known
function badRandom() external view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
block.timestamp, // ← Manipulable
msg.sender, // ← Known
block.number // ← Known
)));
// An attacker can compute this before submitting their transaction
// and only transact when the result is favorable
}
}Vulnerable Pattern 2: blockhash
contract BlockhashLottery {
mapping(address => uint256) public commitBlock;
function commit() external payable {
require(msg.value == 0.1 ether);
commitBlock[msg.sender] = block.number;
}
function reveal() external {
uint256 committed = commitBlock[msg.sender];
require(committed > 0, "Must commit first");
require(block.number > committed, "Wait one block");
// ❌ Bug 1: blockhash returns 0 for blocks older than 256
// If user waits >256 blocks to reveal, blockhash = 0
// keccak256(0) is predictable — always produces the same result
bytes32 randomHash = blockhash(committed);
// ❌ Bug 2: Even within 256 blocks, miners (pre-Merge) could
// choose to withhold a winning block and try again
// (Less practical post-Merge but still a design flaw)
if(uint256(randomHash) % 2 == 0) {
(bool ok,) = msg.sender.call{value: 0.2 ether}("");
require(ok);
}
commitBlock[msg.sender] = 0;
}
}Commit-Reveal Scheme: How It Works and Its Limits
contract CommitRevealRandom {
mapping(address => bytes32) public commits;
uint256 public combinedEntropy;
bool public revealed;
// PHASE 1: Each participant commits to a secret number
// They submit: keccak256(secret) — the number is hidden
function commit(bytes32 commitment) external {
require(commits[msg.sender] == bytes32(0), "Already committed");
commits[msg.sender] = commitment;
}
// PHASE 2: Each participant reveals their secret
// Contract verifies it matches the commitment, XORs into combined entropy
function reveal(uint256 secret) external {
bytes32 commitment = keccak256(abi.encodePacked(secret));
require(commits[msg.sender] == commitment, "Wrong secret");
combinedEntropy ^= secret; // XOR all secrets together
commits[msg.sender] = bytes32(0);
}
// ⚠️ LIMITATION 1: Last revealer controls the outcome
// They can see everyone else's reveals and choose whether to reveal
// Only reveal if combinedEntropy XOR theirSecret gives a winning number
// If not favorable, don't reveal (forfeit deposit, grief the system)
// ⚠️ LIMITATION 2: Participants can grief by never revealing
// Forces a timeout and fallback mechanism
// ⚠️ LIMITATION 3: Two-transaction UX friction — bad for games
}Chainlink VRF: The Right Way
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
contract SecureLottery is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface immutable coordinator;
uint64 immutable subscriptionId;
bytes32 immutable keyHash;
uint32 constant CALLBACK_GAS_LIMIT = 100000;
uint16 constant REQUEST_CONFIRMATIONS = 3;
uint32 constant NUM_WORDS = 1;
mapping(uint256 => address) public requestToPlayer;
mapping(uint256 => uint256) public requestToResult;
constructor(address _coordinator, uint64 _subscriptionId, bytes32 _keyHash)
VRFConsumerBaseV2(_coordinator) {
coordinator = VRFCoordinatorV2Interface(_coordinator);
subscriptionId = _subscriptionId;
keyHash = _keyHash;
}
// Step 1: Player enters, VRF request is sent to Chainlink
function enter() external payable returns (uint256 requestId) {
require(msg.value == 0.1 ether);
// Request random words from Chainlink VRF
requestId = coordinator.requestRandomWords(
keyHash,
subscriptionId,
REQUEST_CONFIRMATIONS,
CALLBACK_GAS_LIMIT,
NUM_WORDS
);
requestToPlayer[requestId] = msg.sender;
}
// Step 2: Chainlink calls this with verified random number (1-3 blocks later)
// This is called by the VRF Coordinator — cannot be front-run or manipulated
function fulfillRandomWords(
uint256 requestId,
uint256[] memory randomWords
) internal override {
address player = requestToPlayer[requestId];
uint256 randomResult = randomWords[0] % 100; // 0-99
requestToResult[requestId] = randomResult;
// ✅ Player wins if result < 50 (50% chance)
// randomWords[0] is generated off-chain with verifiable proof
// The Chainlink oracle network must be compromised to manipulate this
if (randomResult < 50) {
(bool ok,) = player.call{value: 0.2 ether}("");
require(ok);
}
}
}Using VRF doesn't automatically fix all randomness issues. Common mistakes: (1) Requesting VRF randomness and making a decision in the same transaction — the request and fulfillment are separate. (2) Not validating that requestId in fulfillRandomWords came from your contract. (3) Re-using the same randomness word for multiple independent decisions — derive separate values from the single random word by hashing it with different indices.
Foundry Test: Randomness Vulnerability Detection
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract RandomnessExploitTest is Test {
BlockhashLottery public lottery;
function setUp() public {
lottery = new BlockhashLottery();
vm.deal(address(lottery), 10 ether);
}
/// @notice Demonstrates that blockhash can be computed by an attacker contract
function test_blockhashExploit() public {
address attacker = makeAddr("attacker");
vm.deal(attacker, 1 ether);
vm.prank(attacker);
lottery.commit{value: 0.1 ether}();
uint256 committedBlock = block.number;
// Roll forward one block so the block hash is available
vm.roll(block.number + 1);
// In same tx, the attacker contract can read blockhash(committedBlock)
// and only call reveal() if the result is favorable
bytes32 knownHash = blockhash(committedBlock);
bool wouldWin = uint256(knownHash) % 2 == 0;
if (wouldWin) {
uint256 balanceBefore = attacker.balance;
vm.prank(attacker);
lottery.reveal();
assertGt(attacker.balance, balanceBefore, "Attacker should win");
} else {
// Don't reveal — forfeit 0.1 ETH to avoid losing
// In practice, only reveal when you know you'll win
}
}
}How Auditors Identify Randomness Bugs
When auditing a contract, search for: any use of block.timestamp, block.prevrandao, blockhash, or block.number in a keccak256, a modulo operation, or a conditional. If the output affects which address wins money, which NFT gets a rare trait, or any other valuable outcome — it's a vulnerability. The fix is always Chainlink VRF for genuine randomness needs.
// Slither command to find timestamp dependence:
slither . --detect timestamp
// Manual grep for suspicious randomness patterns:
grep -rn "block.timestamp %" src/
grep -rn "blockhash" src/
grep -rn "block.prevrandao" src/
grep -rn "keccak256.*block\." src/
// Patterns that are ALWAYS wrong for security-critical randomness:
// uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) ← predictable
// block.timestamp % n ← manipulable by validator
// blockhash(block.number - 1) ← miner withholding attack
// block.prevrandao % n ← validator grinding attackReal-World Exploits
| Protocol | Year | Bug | Impact |
|---|---|---|---|
| Meebits NFT | 2021 | blockhash for trait assignment | Attacker minted rare traits by predicting/controlling blockhash before reveal |
| Fomo3D | 2018 | block.timestamp lottery | Key round outcomes could be influenced by timestamp manipulation |
| SmartBillions | 2017 | blockhash predictability | Contract offered jackpot; attacker predicted blockhash to always win |
| Roast Football | 2018 | block.number randomness | Outcome predictable from block data; lottery repeatedly exploited |
Key Takeaways
- There is no secure on-chain randomness without an external oracle. block.timestamp, blockhash, block.prevrandao — all are manipulable or predictable by validators.
- Chainlink VRF is the correct solution. It generates randomness off-chain with a cryptographic proof that can be verified on-chain. Validators cannot manipulate it because the random number is committed before the transaction.
- Commit-reveal provides partial protection but has griefing vectors. The last revealer can withhold their reveal to change the outcome. Use it only for non-high-stakes randomness where griefing is tolerable.
- keccak256 of block variables is not randomness — it is obscured predictability. The inputs are all known to sophisticated attackers. Hashing known values does not create unknown values.
- When auditing, treat all block-state modulo arithmetic as critical vulnerabilities. Any code pattern of the form
(block.timestamp % n),(blockhash(x) % n), orkeccak256(block.*) % ncontrolling financial outcomes is exploitable.
Commit-Reveal Improvements and Best Practices
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract ImprovedCommitReveal {
struct Commitment {
bytes32 hash;
uint256 blockNumber;
bool revealed;
}
mapping(address => Commitment) public commitments;
uint256 public constant REVEAL_WINDOW = 100; // blocks to reveal
uint256 public constant DEPOSIT = 0.1 ether; // griefing penalty
function commit(bytes32 secretHash) external payable {
require(msg.value == DEPOSIT, "Wrong deposit");
require(commitments[msg.sender].hash == bytes32(0), "Already committed");
commitments[msg.sender] = Commitment({
hash: secretHash,
blockNumber: block.number,
revealed: false
});
}
function reveal(uint256 secret) external {
Commitment storage c = commitments[msg.sender];
require(c.hash != bytes32(0), "No commitment");
require(!c.revealed, "Already revealed");
require(block.number > c.blockNumber, "Too early");
require(block.number <= c.blockNumber + REVEAL_WINDOW, "Window expired");
require(
c.hash == keccak256(abi.encodePacked(secret, msg.sender)),
"Wrong secret"
);
c.revealed = true;
// Return deposit to honest revealer
(bool ok,) = msg.sender.call{value: DEPOSIT}("");
require(ok);
// Use secret as entropy source
_processOutcome(secret);
}
// If participant doesn't reveal in REVEAL_WINDOW blocks:
// Anyone can slash them — they lose their deposit
// This penalizes griefing (selective reveal)
function slashExpired(address participant) external {
Commitment storage c = commitments[participant];
require(c.hash != bytes32(0));
require(!c.revealed);
require(block.number > c.blockNumber + REVEAL_WINDOW, "Window not expired");
delete commitments[participant];
// Slash deposit — goes to contract treasury or slasher
(bool ok,) = msg.sender.call{value: DEPOSIT / 2}("");
require(ok);
}
function _processOutcome(uint256 secret) internal { /* use secret as entropy */ }
}block.prevrandao: Post-Merge Randomness Source
// After Ethereum's Merge (Sept 2022), block.difficulty was renamed block.prevrandao
// It now contains the RANDAO reveal value from the beacon chain
// RANDAO: validators XOR their secrets together to produce randomness
// Is block.prevrandao secure?
// BETTER than block.timestamp: not directly manipulable by timing
// WORSE than Chainlink VRF: validators can still manipulate it
// Validator manipulation of RANDAO:
// In the last slot of each epoch, the proposing validator knows the RANDAO value
// They can choose to reveal or not reveal their contribution
// "Last revealer" has ~1 bit of influence over the RANDAO value
// For a 50/50 lottery: this 1 bit is sufficient to guarantee winning
// Cost: validator loses one epoch's rewards (~2 ETH) to guarantee winning
// If jackpot > 2 ETH: attack is profitable
// Use for: non-critical randomness, tie-breaking, UI shuffling
// Avoid for: lotteries, NFT trait rarity, any high-value random selection
uint256 rand = block.prevrandao; // ← Fine for low stakes, bad for high stakesChainlink VRF Common Implementation Mistakes
| Mistake | What Happens | Correct Approach |
|---|---|---|
| Making game decision in requestRandomWords() | Random words not available yet — callback hasn't fired | Make decisions only in fulfillRandomWords() |
| Not validating requestId in callback | Malicious contract can call fulfillRandomWords() directly | Check msg.sender == VRF coordinator |
| Using one word for multiple independent decisions | Correlated randomness — not truly independent | Derive separate values: keccak(word, index) |
| No fallback if VRF request fails | Game or lottery is permanently stuck | Add timeout + manual resolution mechanism |
| Insufficient LINK balance | VRF request silently fails or reverts | Monitor LINK balance and top up automatically |
Key Takeaways
- Block variables (
block.timestamp,block.prevrandao,blockhash) are not random — miners/validators have varying degrees of influence over them and all values are known or computable before transaction inclusion. - Any on-chain computation of "randomness" is deterministic and can be predicted or manipulated by a contract executing in the same block.
- Chainlink VRF v2+ is the industry standard for on-chain randomness — use it for any application where unpredictability matters for fairness or security.
- Commit-reveal schemes are a viable VRF alternative for applications that can tolerate two-transaction flows and can handle griefing (the revealer refusing to reveal).
- The
blockhashfunction only returns non-zero values for the last 256 blocks — using it for older blocks silently returns zero, which can be predicted and exploited. - Post-Merge
block.prevrandaois generated from the RANDAO mix, which has 1-bit manipulation potential per validator — not suitable for high-value applications.
Entropy Accumulation Anti-Patterns
Some developers attempt to increase apparent randomness by combining multiple block variables or hashing user-provided data together. These approaches still fail because the entire set of inputs is available to a contract in the same block, allowing on-chain exploitation.
// ❌ STILL VULNERABLE: Combining block variables doesn't add entropy
function badRandom() public view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
block.timestamp,
block.prevrandao, // Validator can manipulate (1 bit)
block.coinbase, // Known — the block proposer
block.gaslimit, // Predictable
msg.sender, // Attacker controls this
tx.gasprice // Attacker controls this
)));
}
// An attacker contract can compute this EXACT value before calling
// and simulate different inputs (try different gasprice values) until
// the result gives a winning lottery ticket
// ❌ Also broken: keccak256 of user-supplied "salt"
function userSaltRandom(bytes32 salt) public view returns (uint256) {
// Attacker can brute-force salt values until they get desired output
return uint256(keccak256(abi.encodePacked(block.timestamp, salt)));
}Foundry Test for VRF Dependency Verification
contract VRFSecurityTest is Test {
VRFConsumer consumer;
VRFCoordinatorV2_5Mock coordinator;
function setUp() public {
coordinator = new VRFCoordinatorV2_5Mock(0.1 ether, 1e9);
uint256 subId = coordinator.createSubscription();
coordinator.fundSubscription(subId, 10 ether);
consumer = new VRFConsumer(subId, address(coordinator));
coordinator.addConsumer(subId, address(consumer));
}
// ✅ Verify: result not set until VRF callback fires
function test_ResultPendingUntilFulfillment() public {
consumer.requestRandom();
// Before fulfillment, result should be 0 or "pending"
assertEq(consumer.lastResult(), 0);
// Simulate VRF fulfillment from coordinator
coordinator.fulfillRandomWords(1, address(consumer));
// After fulfillment, result is non-zero
assertGt(consumer.lastResult(), 0);
}
// ✅ Verify: only coordinator can call rawFulfillRandomWords
function test_OnlyCoordinatorCanFulfill() public {
uint256[] memory words = new uint256[](1);
words[0] = 12345;
vm.expectRevert();
// Attacker tries to inject their own "random" value
consumer.rawFulfillRandomWords(1, words);
}
}