WTF-Solidity 合约安全实战:S07 坏随机数(Bad Randomness)漏洞原理、攻击复现与链下随机数防护
WTF-Solidity 合约安全实战:S07 坏随机数(Bad Randomness)漏洞原理、攻击复现与链下随机数防护
导读:本文基于 WTF-Solidity 合约安全系列 S07 讲,系统讲解以太坊智能合约中"坏随机数(Bad Randomness)"漏洞的形成原因、攻击手法与防护方案。你将通过仓库中的 BadRandomness.sol 漏洞合约与配套攻击合约,完整复现"预测随机数、铸造指定稀有 NFT"的实战攻击流程,并掌握使用 Chainlink VRF 等链下随机数方案进行修复的正确姿势,可直接迁移到 NFT 抽奖、盲盒、GameFi 等高频随机数场景中。
一、为什么以太坊上不存在"真随机数"
NFT 随机抽取 tokenId、盲盒抽奖、GameFi 战斗随机判定胜负……大量链上应用都需要随机数。但以太坊(以及绝大多数 EVM 兼容链)有一个天然特性:所有链上数据公开透明(public)且执行确定性(deterministic),同一笔交易在任何节点上重放都会得到完全一致的结果。这决定了它无法像 C、Java 等传统语言那样向开发者提供 random() 之类的系统随机数 API——因为真正的随机数会破坏状态一致性,导致节点之间无法达成共识。
于是,项目方只能退而求其次,使用链上伪随机数:把若干链上全局变量作为"种子"(seed),再交给哈希函数进行混淆,得到"看起来随机"的结果。最常见的组合是:
uint256 randomNumber = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))) % 100;
这条表达式正是本讲漏洞合约的核心逻辑,它的"随机性"完全依赖两个全局变量:
| 种子来源 | 含义 | 是否可预测 / 可操纵 |
|---|---|---|
blockhash(block.number - 1) |
上一个区块的哈希 | 完全公开,且只保留最近 256 个区块的哈希,任何人都能查到 |
block.timestamp |
当前区块的时间戳 | 公开,且可由矿工/验证者在出块时在一定范围内调整 |
哈希函数 keccak256 本身确实具备"灵敏性"和"均一性"——输入微变、输出剧变、结果均匀分布。但它混淆的是公开已知的输入,混淆不等于不可预测:只要种子是公开的,哈希结果就是可以被任何人离线复算的。
二、坏随机数漏洞的本质:可预测性
在 WTF-Solidity 第 39 讲:链上随机数 中,同样给出了一个典型的链上伪随机数生成函数 getRandomOnchain(),把 blockhash(block.number-1)、msg.sender、block.timestamp 一起打包进 keccak256():
function getRandomOnchain() public view returns(uint256){
bytes32 randomBytes = keccak256(abi.encodePacked(blockhash(block.number-1), msg.sender, block.timestamp));
return uint256(randomBytes);
}
其中也明确指出了这种方案的两个致命弱点:
- 可预测:
block.timestamp、msg.sender、blockhash全部公开,使用者可以提前算出生成的随机数,然后挑对自己有利的时机执行合约; - 可操纵:矿工可以操纵
blockhash和block.timestamp,让生成的随机数符合自己的利益。
在以太坊上,交易在被打包进区块之前,发送方可以预判自己交易所在区块的 block.number 与 block.timestamp(即使不精确,也只需"等待/挑选"一个合适的区块),因此基于这类种子生成的随机数,对攻击者而言几乎是"明牌"。所谓坏随机数漏洞,就是攻击者可以事先计算这些伪随机数的结果,从而达成自己想要的任何目的——例如铸造任何他们想要的稀有 NFT,而不是随机抽取。
下图形象地展示了这一漏洞的荒谬之处:用户以为自己在掷骰子,但攻击者(0xAA)能复述出完全相同的"随机数"代码,并给出与系统"随机数"一致的正确答案:
这种漏洞在 NFT 和 GameFi 项目中反复出现,Meebits、Loots、Wolf Game 等知名项目都曾因此被攻击:攻击者可以精准铸造最稀有的 NFT,而非随机抽取。
三、漏洞合约源码剖析:BadRandomness.sol
仓库中的 BadRandomness.sol 实现了一个存在坏随机数漏洞的 NFT 合约。它直接继承了 WTF-Solidity 自带的 ERC721 实现(位于 34_ERC721 目录,实现了 IERC721、IERC721Metadata、IERC165 标准接口),因此天然具备 balanceOf、ownerOf、_mint 等标准能力。
// SPDX-License-Identifier: MIT
// By 0xAA
pragma solidity ^0.8.34;
import "../34_ERC721/ERC721.sol";
contract BadRandomness is ERC721 {
uint256 totalSupply;
// 构造函数,初始化NFT合集的名称、代号
constructor() ERC721("", ""){}
// 铸造函数:当输入的 luckyNumber 等于随机数时才能mint
function luckyMint(uint256 luckyNumber) external {
uint256 randomNumber = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))) % 100; // get bad random number
require(randomNumber == luckyNumber, "Better luck next time!");
_mint(msg.sender, totalSupply); // mint
totalSupply++;
}
}
逐行拆解这个合约的核心逻辑:
- 继承与构造函数:
BadRandomness is ERC721,构造函数调用父合约ERC721("", "")初始化名称与代号(教程示例中留空,可自行替换为实际名称)。 - 状态变量
totalSupply:记录已铸造的 NFT 数量,同时充当下一个tokenId(从 0 递增)。 luckyMint(uint256 luckyNumber)铸造函数:用户调用时输入一个0-99的数字,合约用blockhash(block.number - 1)与block.timestamp计算链上伪随机数,取模% 100得到一个0-99的值:- 若
randomNumber == luckyNumber,则require通过,铸造 NFT 给调用者; - 否则 revert,并回滚交易,报错信息为
"Better luck next time!"。
- 若
从设计上看,这像是一个 1/100 概率的"抽奖铸造":用户需要恰好猜中随机数才能拿到 NFT。但这个随机数根本不需要猜——它就是公开算式的确定输出。漏洞就藏在这行伪随机数生成代码里。
四、攻击合约 Attack.sol:同一区块内"预言"随机数
漏洞的利用方式极其简单:由于 attackMint() 与 luckyMint() 在同一个区块内先后执行,两个函数读取到的 blockhash(block.number - 1) 与 block.timestamp 完全一致,因此它们各自计算出的随机数必然相同。攻击者只需要在调用铸造函数之前,把同一套算式离线复算一遍,把算出的 luckyNumber 作为参数传入,即可 100% 通过校验。
仓库中 BadRandomness.sol 的下半部分就是这个攻击合约:
contract Attack {
function attackMint(BadRandomness nftAddr) external {
// 提前计算随机数
uint256 luckyNumber = uint256(
keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
) % 100;
// 利用 luckyNumber 攻击
nftAddr.luckyMint(luckyNumber);
}
}
攻击流程拆解:
- 复算随机数:
attackMint()内用与受害者合约完全相同的算式(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) % 100)计算出luckyNumber; - 发起攻击交易:将算出的
luckyNumber作为参数调用nftAddr.luckyMint(luckyNumber); - 攻击生效:因为攻击者的调用与合约内部计算处于同一笔交易、同一个区块内,两者读到的
blockhash与block.timestamp相同,随机数必然相等,require必然通过,NFT 成功铸造到msg.sender(即攻击合约)名下。
从源码结构可以推断,Attack 合约没有继承任何接口,仅通过地址参数 BadRandomness nftAddr 与目标合约交互,属于典型的"外部调用型"攻击合约。攻击者可以把 attackMint 包装进自己的自动化脚本,批量对不同区块、不同幸运数字的时机发起攻击,或者直接只在"对已有利"的区块出手。
五、Remix 实战复现:从部署到攻击成功
复现坏随机数攻击需要特别留意环境限制:Remix 自带的 Remix VM 不支持 blockhash 函数(在 Remix VM 中 blockhash() 无法按预期返回真实区块哈希),因此必须将合约部署到以太坊测试链(如 Sepolia 测试网)上进行复现。
完整复现步骤如下:
- 部署
BadRandomness合约:在 Remix 中编译并部署 BadRandomness.sol,部署时构造函数参数留空(ERC721("", "")); - 部署
Attack合约:编译并部署同一文件中的Attack合约; - 发起攻击:将
BadRandomness合约地址作为参数,传入Attack合约的attackMint()函数并调用,完成攻击(此时这一笔交易内已经完成了"算数 + 铸造"的全部动作); - 验证攻击结果:调用
BadRandomness合约的balanceOf,传入Attack合约地址,查看其 NFT 余额。若余额为1(即攻击合约持有一个 tokenId 为 0 的 NFT),说明攻击成功——攻击者无需任何运气,就拿到了"幸运 NFT"。
提示:攻击本质上是"先算后调",因此还可以把
luckyMint的期望值打印出来与balanceOf结果对照,进一步验证随机数可预测性。
六、预防方法:让随机数"不可预测"
坏随机数漏洞的根因是随机数种子来自链上公开数据。修复思路有两个方向:一是把随机数生成移到链下,让攻击者无法在交易发出前获知结果;二是采用不可被单方操纵、可验证的链上随机性协议。
6.1 链下随机数 + 预言机:Chainlink VRF
目前最主流的方案是使用预言机项目提供的链下随机数,例如 Chainlink VRF(可验证随机函数):随机数在链下生成、附带可验证的密码学证明后上传到链上,任何一方都无法在提交前预测结果,也无法事后篡改。WTF-Solidity 第 39 讲 Random.sol 提供了完整的落地示例——一个同时支持"链上伪随机铸造"(不安全)与"VRF 随机铸造"(安全)的 NFT 合约,两者对比可以直观感受安全方案的差异。
使用 Chainlink VRF V2 的标准流程(详见 第 39 讲文档):
- 申请 Subscription 并转入 LINK 代币:在 Chainlink VRF 官网创建订阅(Subscription),测试网 LINK 可通过水龙头领取;
- 合约继承
VRFConsumerBaseV2:在构造函数中初始化VRFCoordinatorV2Interface与Subscription Id,不同链需要填入对应的 Coordinator 地址、Key Hash 等参数; - 调用
requestRandomWords()申请随机数:将keyHash、subId、requestConfirmations、callbackGasLimit、numWords等参数提交给 VRF Coordinator(注意:合约部署后必须先把合约添加到 Subscription 的 Consumers 中,才能成功发起申请); - Chainlink 节点链下生成随机数与数字签名并上链,VRF 合约验证签名有效性;
- 回调
fulfillRandomWords()消费随机数:VRF 合约验证通过后,自动调用用户合约的回调函数,把随机数发送回来,消耗随机数的业务逻辑(如铸造 NFT)必须写在这个回调里。
其中 RandomNumberConsumer 示例合约中的核心参数如下(Sepolia 测试网为例,不同网络需查询官方 supported-networks 文档替换):
| 参数 | 含义 | 示例值(Sepolia) |
|---|---|---|
vrfCoordinator |
VRF Coordinator 合约地址 | 0x8103B0A8A00be2DDC778e6e7eaa21791Cd364625 |
keyHash |
VRF 唯一标识符(gas price 档位) | 0x474e34a077df58807dbe9c96d3c009b23b3c6d0cce433e59bbf5b34f823bc56c |
requestConfirmations |
最小确认块数,数字越大安全性越高 | 3(一般可填 12) |
callbackGasLimit |
回调函数 gas 上限 | 200_000(上限 2,500,000) |
numWords |
一次请求获得的随机数个数 | 3(上限 500) |
需要特别注意的架构差异:申请随机数(requestRandomWords)与接收随机数(fulfillRandomWords)是两笔不同的交易,前者由用户合约发起,后者由 VRF 合约发起,两者可能相隔几分钟。因此在 VRF 回调里消费随机数时,不能直接使用 msg.sender(此时调用者是 VRF 合约而非用户)——Random.sol 的做法是用 requestToSender 映射记录 requestId => 用户地址,回调时再取出来执行 _mint(sender, tokenId)。这是把 VRF 接入业务合约时最容易踩的坑。
6.2 其他思路:可验证的链上随机性
除了预言机之外,行业中也存在以 DAO 模式提供"链上 + 真随机"服务的 RNG 方案(如 RANDAO)。但无论采用哪种方案,核心原则不变:随机数必须对"提交方"不可预测、对"结果"不可篡改。
6.3 预防要点小结
- NFT 与 GameFi 项目方应避免直接使用
blockhash、block.timestamp、msg.sender等链上公开变量拼接哈希生成随机数进行抽奖; - 对随机性要求不高的场景,也要认识到链上伪随机数只能防"普通用户",防不住"同一区块内复算"的攻击者;
- 涉及资产分配(如抽盲盒、抽稀有度)的场景,优先采用 Chainlink VRF 等链下可验证随机数,并把消耗随机数的逻辑放在
fulfillRandomWords()回调中。
七、总结
本讲围绕坏随机数(Bad Randomness)漏洞,完成了从原理到复现再到防御的闭环:
- 漏洞根源:以太坊链上数据公开且确定性,
blockhash()+block.timestamp拼接哈希生成的是可预测的伪随机数; - 攻击本质:攻击者在同一区块内复算同一算式,即可 100% 预测"幸运数字",从 BadRandomness.sol 中的
luckyMint()漏洞可见一斑; - 实战复现:部署漏洞合约与攻击合约,调用
attackMint()即可在测试链上拿到本应 1/100 概率才能获得的 NFT; - 正确防御:使用 Chainlink VRF 等链下预言机随机数,保证随机数不可预测、不可篡改,参考 第 39 讲 Random.sol 的安全实现模式。
对任何涉及随机数分配的合约而言,"随机"二字必须以密码学为背书,而不是以区块元数据为赌注。这也是审计 NFT 与 GameFi 合约时最优先检查的漏洞类型之一。
