本文探讨了以太坊智能合约中矿工操纵攻击的风险,详细解释了攻击原理和常见手段,并通过一个易受攻击的 lottery 合约实例进行了演示。为了应对这种风险,文章建议采用 Chainlink VRF 等安全随机数生成方案,以及 commit-reveal 等机制来提高合约的安全性,并避免使用矿工可控的变量。
想象一下在以太坊上启动一个去中心化的彩票 dApp,玩家投入 ETH 以获得改变人生的奖金的机会。你的智能合约承诺公平,使用链上数据来选择一个随机的获胜者。但是当抽奖发生时,一个验证者巧妙地调整了一个区块变量,操纵结果使之对自己有利。获胜者不是随机的——而是验证者或他们的同伙。玩家失去信任,你的 dApp 的声誉崩溃,你的项目面临审查。这就是矿工操纵的阴险威胁,区块生产者利用他们对 block.timestamp
和 block.number
等变量的控制来扭曲合约逻辑。
欢迎来到智能合约安全:Solodit 检查清单系列的第六章,我们将讨论 Solodit 检查清单中定义的 SOL-AM-MinerManipulation:矿工控制的变量。矿工操纵利用了以太坊的区块元数据,验证者可以在协议限制范围内调整这些元数据,从而影响时间敏感或依赖随机性的合约。截至 2025 年 7 月,以太坊的权益证明 (PoS) 系统具有约 3000 万 gas 的区块 gas 限制和 12 秒的平均区块时间,这些攻击可能会扰乱彩票、拍卖、质押池或治理系统。
在本开发者友好的指南中,我们将深入探讨矿工操纵的工作原理,分析一个易受攻击的彩票合约,并提供使用 Chainlink VRF、提交-揭示方案和其他强大防御措施的安全实现。我们将包括详细的用户和攻击者工作流程、全面的代码片段、高级安全模式和实用的测试策略,同时将此威胁与之前的漏洞(如阻断 (第五部分)、抢跑 (第四部分) 和状态膨胀 (第二部分))联系起来。无论你是构建彩票、拍卖还是 DeFi 协议,本文都将使你能够智胜操纵性验证者并确保你的 dApp 保持公平、安全和无需信任。
在以太坊的 PoS 系统中,验证者提出并证明区块,取代了工作量证明 (PoW) 时代的矿工。虽然 PoS 减少了与 PoW 相比的个人控制,但验证者仍然会影响:
block.timestamp
):可在 ~15 秒的窗口内调整(±12–15 秒),以与网络共识规则对齐,只要它大于前一个区块的时间戳。block.number
):顺序的,但验证者可以通过战略性地确定优先级来影响时序或交易包含。blockhash
):从区块数据派生而来,在一个区块内可预测,并且可以通过调整其他变量(如 block.timestamp
)来操纵。这些操纵虽然受到限制,但会显着影响依赖这些变量的合约,以用于:
Solodit 检查清单的 SOL-AM-MinerManipulation 强调安全的随机性来源(例如,Chainlink VRF),避免矿工控制的变量,以及实施公平机制,如提交-揭示方案。让我们探讨一下这些攻击是如何展开的,以及如何消除它们。
矿工操纵利用了验证者对区块元数据的有限控制来影响合约结果。在以太坊的 PoS 系统中,验证者:
block.timestamp
,确保它大于前一个区块的时间戳并与网络时间对齐。blockhash
)。keccak256(block.timestamp, block.number)
)以选择有利的结果。block.timestamp
以触发或绕过截止日期(例如,提前结束拍卖或延迟付款)。这些攻击很微妙,因为验证者在协议规则内操作,如果没有链下监控,很难检测到。
在 2016 年,GovernMental 庞氏骗局使用 block.timestamp
来安排付款,使其容易受到矿工操纵。矿工可以调整时间戳以影响付款时间,加剧该骗局的崩溃并锁定 110 万美元的 ETH。此事件突出了依赖矿工控制变量的危险,推动了 Chainlink VRF 等安全随机性解决方案的采用。
下面是一个彩票合约,它使用 block.timestamp
和 block.number
来生成伪随机数,从而选择一个获胜者,使其成为操纵的容易目标。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract VulnerableLottery {
address public winner;
uint256 public entryFee = 0.1 ether;
address[] public players;
bool public drawingActive;
// 进入彩票
function enter() external payable {
require(msg.value == entryFee, "Incorrect entry fee");
require(drawingActive, "Drawing not active");
players.push(msg.sender);
emit PlayerEntered(msg.sender);
}
// 开始抽奖期
function startDrawing() external {
require(!drawingActive, "Drawing already active");
drawingActive = true;
emit DrawingStarted(block.timestamp);
}
// 抽取获胜者
function drawWinner() external {
require(drawingActive, "Drawing not active");
require(players.length > 0, "No players");
uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp, block.number)));
winner = players[random % players.length];
drawingActive = false;
emit WinnerSelected(winner);
}
event PlayerEntered(address indexed player);
event DrawingStarted(uint256 startTime);
event WinnerSelected(address indexed winner);
}
以下是恶意验证者如何利用此合约:
enter()
加入彩票,每人支付 0.1 ETH。players
数组增长(例如,100 名玩家)。startDrawing()
之后调用 drawWinner()
,使用 block.timestamp
和 block.number
生成伪随机索引。block.timestamp
,以生成选择其地址的哈希。const Web3 = require('web3');
const web3 = new Web3('https://mainnet.infura.io/v3/YOUR_PROJECT_ID');
const hash = web3.utils.keccak256(`${timestamp}${blockNumber}`);
const index = BigInt(hash) % BigInt(players.length);
4. 结果:随机值选择验证者的地址作为获胜者,声明奖金池(例如,100 名玩家的 10 ETH)。合法玩家失去机会,没有意识到操纵。
免费加入 Medium 以获取这位作家的更新。
5. 影响:彩票的公平性受到损害,用户失去信任,dApp 面临声誉和潜在的法律风险。
该合约依赖于 block.timestamp
和 block.number
,它们在协议约束范围内可以被操纵。验证者可以离线预测和测试哈希结果,确保他们或同伙获胜。在一个拥有数百万 ETH 的高风险彩票中,这可能导致重大的财务损失和监管审查。对 drawWinner
调用的缺乏限制也允许重复尝试,从而放大了阻断风险(第五部分)。
Solodit 检查清单建议使用安全的随机性来源(如 Chainlink VRF),避免矿工控制的变量,以及实施公平机制(如提交-揭示方案)。以下是一个安全的彩票合约,它结合了这些原则,以及高级功能,如批量加入、时间锁和熔断器。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureLottery is VRFConsumerBaseV2, ReentrancyGuard {
VRFCoordinatorV2Interface COORDINATOR;
address public winner;
address public admin;
bool public paused;
bool public drawingActive;
uint256 public entryFee = 0.1 ether;
uint64 public subscriptionId; // Chainlink VRF 订阅 ID
bytes32 public keyHash; // Chainlink VRF 密钥哈希
uint32 public callbackGasLimit = 100000; // VRF 回调的 Gas 限制
uint16 public requestConfirmations = 3; // 最小确认数
uint32 public numWords = 1; // 随机词数量
address[] public players;
mapping(address => bytes32) public commitments; // 用于提交-揭示
mapping(uint256 => address) public requestIdToSender; // 跟踪 VRF 请求
uint256 public constant MAX_PLAYERS = 1000; // 限制状态增长
uint256 public constant COMMIT_WINDOW = 1 hours; // 提交-揭示窗口
uint256 public constant DRAWING_WINDOW = 7 days; // 抽奖期
uint256 public lotteryStart;
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract paused");
_;
}
modifier withinDrawingWindow() {
require(block.timestamp <= lotteryStart + DRAWING_WINDOW, "Drawing closed");
_;
}
constructor(address vrfCoordinator, bytes32 _keyHash, uint64 _subscriptionId) VRFConsumerBaseV2(vrfCoordinator) {
COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
keyHash = _keyHash;
subscriptionId = _subscriptionId;
admin = msg.sender;
lotteryStart = block.timestamp;
}
// 开始彩票
function startDrawing() external onlyAdmin whenNotPaused {
require(!drawingActive, "Drawing already active");
drawingActive = true;
lotteryStart = block.timestamp;
delete players; // 重置玩家数组
emit DrawingStarted(block.timestamp);
}
// 提交条目以隐藏地址
function commitEntry(bytes32 commitment) external payable whenNotPaused withinDrawingWindow nonReentrant {
require(msg.value == entryFee, "Incorrect entry fee");
require(players.length < MAX_PLAYERS, "Player limit reached");
require(commitments[msg.sender] == bytes32(0), "Already committed");
commitments[msg.sender] = commitment;
emit EntryCommitted(msg.sender, commitment);
}
// 揭示条目并加入彩票
function revealEntry(uint256 nonce) external whenNotPaused withinDrawingWindow nonReentrant {
require(commitments[msg.sender] != bytes32(0), "No commitment");
require(block.timestamp <= lotteryStart + COMMIT_WINDOW, "Commit window expired");
require(keccak256(abi.encodePacked(msg.sender, nonce)) == commitments[msg.sender], "Invalid commitment");
delete commitments[msg.sender];
players.push(msg.sender);
emit PlayerEntered(msg.sender);
}
// 多个玩家批量加入
function batchEnter(address[] calldata entrants, bytes32[] calldata commitments) external payable whenNotPaused withinDrawingWindow nonReentrant {
require(entrants.length == commitments.length, "Mismatched arrays");
require(msg.value == entryFee * entrants.length, "Incorrect entry fee");
require(players.length + entrants.length <= MAX_PLAYERS, "Player limit exceeded");
for (uint256 i = 0; i < entrants.length; i++) {
if (commitments[entrants[i]] == bytes32(0)) {
commitments[entrants[i]] = commitments[i];
emit EntryCommitted(entrants[i], commitments[i]);
}
}
}
// 从 Chainlink VRF 请求随机数
function drawWinner() external onlyAdmin whenNotPaused withinDrawingWindow {
require(drawingActive, "Drawing not active");
require(players.length > 0, "No players");
uint256 requestId = COORDINATOR.requestRandomWords(
keyHash,
subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
requestIdToSender[requestId] = msg.sender;
drawingActive = false;
emit RandomnessRequested(requestId);
}
// Chainlink VRF 回调
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
require(players.length > 0, "No players");
winner = players[randomWords[0] % players.length];
emit WinnerSelected(winner, requestId);
}
// 领取奖品
function claimPrize() external whenNotPaused nonReentrant {
require(msg.sender == winner, "Not winner");
uint256 prize = address(this).balance;
winner = address(0);
(bool success, ) = msg.sender.call{value: prize}("");
require(success, "Prize claim failed");
emit PrizeClaimed(msg.sender, prize);
}
// 在遭受攻击时暂停合约
function pause() external onlyAdmin {
paused = true;
emit Paused();
}
// 取消暂停合约
function unpause() external onlyAdmin {
paused = false;
emit Unpaused();
}
// 拒绝直接 ETH 存款
receive() external payable {
revert("Direct ETH deposits not allowed");
}
event EntryCommitted(address indexed player, bytes32 commitment);
event PlayerEntered(address indexed player);
event DrawingStarted(uint256 startTime);
event RandomnessRequested(uint256 indexed requestChid);
event WinnerSelected(address indexed winner, uint256 requestId);
event PrizeClaimed(address indexed winner, uint256 amount);
event Paused();
event Unpaused();
}
合法用户:
攻击者(验证者):
如果 Chainlink VRF 的成本过高或不可用,请考虑以下替代方案:
用户提交一个随机值的哈希,然后揭示它。合约聚合揭示的值以获得随机数:
mapping(address => bytes32) public randomCommits;
mapping(address => uint256) public randomValues;
function commitRandom(bytes32 commitment) external whenNotPaused {
require(randomCommits[msg.sender] == bytes32(0), "Already committed");
randomCommits[msg.sender] = commitment;
emit RandomCommitted(msg.sender, commitment);
}
function revealRandom(uint256 value, uint256 nonce) external whenNotPaused {
require(randomCommits[msg.sender] != bytes32(0), "No commitment");
require(keccak256(abi.encodePacked(value, nonce)) == randomCommits[msg.sender], "Invalid commitment");
randomValues[msg.sender] = value;
emit RandomRevealed(msg.sender, value);
}
function drawWinnerWithCommits() external onlyAdmin whenNotPaused {
uint256 random = 0;
for (uint256 i = 0; i < players.length; i++) {
random ^= randomValues[players[i]];
}
winner = players[random % players.length];
emit WinnerSelected(winner, 0);
}
event RandomCommitted(address indexed player, bytes32 commitment);
event RandomRevealed(address indexed player, uint256 value);
使用 blockhash(block.number + n) 获得随机数,延迟对可操纵数据的依赖:
function drawWinnerWithFutureBlock(uint256 futureBlock) external onlyAdmin {
require(block.number < futureBlock, "Block already mined");
require(players.length > 0, "No players");
uint256 random = uint256(blockhash(futureBlock));
winner = players[random % players.length];
emit WinnerSelected(winner, 0);
}
使用自定义预言机获得随机数,并在链上验证。
矿工操纵可以放大先前部分的漏洞:
为了与 Solodit 检查清单和行业标准保持一致,请采用以下实践:
COORDINATOR.requestRandomWords(keyHash, subscriptionId, requestConfirmations, callbackGasLimit, numWords);
2. 实施提交-揭示方案:
function commitEntry(bytes32 commitment) external payable {
commitments[msg.sender] = commitment;
}
3. 避免矿工控制的变量:
// 坏
uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp)));
// 好
uint256 random = randomWords[0]; // 来自 Chainlink VRF
4. 使用时间锁:
require(block.timestamp <= lotteryStart + COMMIT_WINDOW, "Commit window expired");
5. 实施熔断器:
function pause() external onlyAdmin {
paused = true;
emit Paused();
}
6. 限制访问:
function drawWinner() external onlyAdmin whenNotPaused {
// ...
}
7. 限制状态增长:
require(players.length < MAX_PLAYERS, "Player limit reached");
8. 使用批量处理:
function batchEnter(address[] calldata entrants, bytes32[] calldata commitments) external payable {
// ...
}
9. 监控随机数和交易:使用 Forta 检测随机数请求或验证者行为中的异常。
10. 全面测试:
await network.provider.send("evm_setNextBlockTimestamp", [newTimestamp]);
截至 2025 年 7 月,以下工具增强了矿工操纵预防:
为了确保你的合约能够抵抗矿工操纵:
it("prevents timestamp manipulation", async () => {
await lottery.commitEntry(commitment, { value: entryFee, from: user });
await lottery.revealEntry(nnonce, { from: user });
await network.provider.send("evm_setNextBlockTimestamp", [newTimestamp]);
await lottery.drawWinner({ from: admin });
// Verify winner is selected via VRF, not timestamp
expect(await lottery.winner()).to.not.be.influencedBy(newTimestamp);
});
it("processes valid entries only", async () => {
await lottery.commitEntry(commitment, { value: entryFee, from: user });
await expect(lottery.revealEntry(invalidNonce, { from: user }))
.to.be.revertedWith("Invalid commitment");
});
it("handles batch entries efficiently", async () => {
const entrants = [user1, user2];
const commitments = [commitment1, commitment2];
await lottery.batchEnter(entrants, commitments, { value: entryFee * 2 });
expect(await lottery.commitments(user1)).to.equal(commitment1);
expect(await lottery.commitments(user2)).to.equal(commitment2);
});
2. 模糊测试:使用 Echidna 模拟随机时间戳、区块编号和无效提交。
3. 内存池模拟:使用 Foundry 测试交易排序和内存池动态。
4. 主网分叉:使用 Hardhat 分叉以太坊主网以测试 VRF 集成和区块变量操纵。
5. 随机数监控:使用 Forta 检测随机数请求或获胜者选择中的异常。
2016 年的 GovernMental 庞氏骗局依赖于 block.timestamp 来确定支付时间,这使得矿工可以操纵时间表并加剧该骗局的崩溃,从而锁定了价值 110 万美元的 ETH。同样,像 PoolTogether(VRF 之前)这样的早期 DeFi 彩票面临着基于时间戳的随机性的风险,促使在以后的版本中采用 Chainlink VRF 和提交-揭示方案。这些事件突出表明需要安全的随机性和强大的设计。
矿工操纵就像魔术师操纵抽奖——验证者可以稍微调整一下赔率,破坏公平性。通过使用 Chainlink VRF 获得安全随机数,实施提交-揭示方案,避免受矿工控制的变量,并采用 gas 高效的设计,正如 Solodit 检查清单 ( SOL-AM-MinerManipulation) 建议的那样,开发人员可以确保公平的结果。 Slither、Foundry、Forta 和 Tenderly 等工具,再加上严格的测试和实时监控,可以使合约对操纵性验证者具有弹性。
本文是智能合约安全:Solodit 检查清单系列的一部分。接下来,我们将讨论预言机操纵,探索攻击者如何利用价格馈送,并实施与 Chainlink 的安全预言机集成。无论你是构建彩票、拍卖平台、质押系统还是 DeFi 协议,掌握矿工操纵防御对于创建公平、安全和无需信任的去中心化应用程序至关重要。
- 原文链接: medium.com/@ankitacode11...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!