WTF-Solidity 合约安全实战:S07 坏随机数(Bad Randomness)漏洞原理、攻击复现与链下随机数防护

原创2026-09-15 10:35:191,402 阅读
文章标签:示例工程区块链教程

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);
}

其中也明确指出了这种方案的两个致命弱点:

  1. 可预测:block.timestamp、msg.sender、blockhash 全部公开,使用者可以提前算出生成的随机数,然后挑对自己有利的时机执行合约;
  2. 可操纵:矿工可以操纵 blockhash 和 block.timestamp,让生成的随机数符合自己的利益。

在以太坊上,交易在被打包进区块之前,发送方可以预判自己交易所在区块的 block.number 与 block.timestamp(即使不精确,也只需"等待/挑选"一个合适的区块),因此基于这类种子生成的随机数,对攻击者而言几乎是"明牌"。所谓坏随机数漏洞,就是攻击者可以事先计算这些伪随机数的结果,从而达成自己想要的任何目的——例如铸造任何他们想要的稀有 NFT,而不是随机抽取。

下图形象地展示了这一漏洞的荒谬之处:用户以为自己在掷骰子,但攻击者(0xAA)能复述出完全相同的"随机数"代码,并给出与系统"随机数"一致的正确答案:

Bad Randomness 示意图:攻击者复算伪随机数并"猜中"幸运数字

这种漏洞在 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);
    }
}

攻击流程拆解:

  1. 复算随机数:attackMint() 内用与受害者合约完全相同的算式(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) % 100)计算出 luckyNumber;
  2. 发起攻击交易:将算出的 luckyNumber 作为参数调用 nftAddr.luckyMint(luckyNumber);
  3. 攻击生效:因为攻击者的调用与合约内部计算处于同一笔交易、同一个区块内,两者读到的 blockhash 与 block.timestamp 相同,随机数必然相等,require 必然通过,NFT 成功铸造到 msg.sender(即攻击合约)名下。

从源码结构可以推断,Attack 合约没有继承任何接口,仅通过地址参数 BadRandomness nftAddr 与目标合约交互,属于典型的"外部调用型"攻击合约。攻击者可以把 attackMint 包装进自己的自动化脚本,批量对不同区块、不同幸运数字的时机发起攻击,或者直接只在"对已有利"的区块出手。

五、Remix 实战复现:从部署到攻击成功

复现坏随机数攻击需要特别留意环境限制:Remix 自带的 Remix VM 不支持 blockhash 函数(在 Remix VM 中 blockhash() 无法按预期返回真实区块哈希),因此必须将合约部署到以太坊测试链(如 Sepolia 测试网)上进行复现。

完整复现步骤如下:

  1. 部署 BadRandomness 合约:在 Remix 中编译并部署 BadRandomness.sol,部署时构造函数参数留空(ERC721("", ""));
  2. 部署 Attack 合约:编译并部署同一文件中的 Attack 合约;
  3. 发起攻击:将 BadRandomness 合约地址作为参数,传入 Attack 合约的 attackMint() 函数并调用,完成攻击(此时这一笔交易内已经完成了"算数 + 铸造"的全部动作);
  4. 验证攻击结果:调用 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 讲文档):

  1. 申请 Subscription 并转入 LINK 代币:在 Chainlink VRF 官网创建订阅(Subscription),测试网 LINK 可通过水龙头领取;
  2. 合约继承 VRFConsumerBaseV2:在构造函数中初始化 VRFCoordinatorV2Interface 与 Subscription Id,不同链需要填入对应的 Coordinator 地址、Key Hash 等参数;
  3. 调用 requestRandomWords() 申请随机数:将 keyHash、subId、requestConfirmations、callbackGasLimit、numWords 等参数提交给 VRF Coordinator(注意:合约部署后必须先把合约添加到 Subscription 的 Consumers 中,才能成功发起申请);
  4. Chainlink 节点链下生成随机数与数字签名并上链,VRF 合约验证签名有效性;
  5. 回调 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 合约时最优先检查的漏洞类型之一。

登录后查看全文
WTF-Solidity