智能合约安全审计入门系列之抢跑
作者:小白
背景概述
在上篇文章中我们了解了合约中隐藏的恶意代码,本次我们来了解一个非常常见的攻击手法 —— 抢跑。
前置知识
提到抢跑,大家第一时间想到的一定是田径比赛,在田径运动中各个选手的体能素质几乎相同,起步越早的人得到第一名的概率越大。那么在以太坊中是如何抢跑的呢?
想了解抢跑攻击必须先了解以太坊的交易流程,我们通过下面这个发送交易的流程图来了解以太坊上一笔交易发出后经历的流程:
可以看到图中一笔交易从签名到被打包一共会经历 7 个阶段:
1. 使用私钥对交易内容签名;
2. 选择 Gas Price;
3. 发送签名后的交易;
4. 交易在各个节点之间广播;
5. 交易进入交易池;
6. 矿工取出 Gas Price 高的交易;
7. 矿工打包交易并出块。
交易送出之后会被丢进交易池里,等待被矿工打包。矿工从交易池中取出交易进行打包与出块。根据 Eherscan 的数据,目前区块的 Gas 限制在 3000 万左右这是一个动态调整的值。若以一笔基础交易 21,000 Gas 来计算,则目前一个以太坊区块可以容纳约 1428 笔交易。因此当交易池里的交易量大时,会有许多交易没办法即时被打包而滞留在池子中等待。这里就衍生出了一个问题,交易池中有那么多笔交易,矿工先打包谁的交易呢?
矿工节点可以自行设置参数,不过大多数矿工都是按照手续费的多少排序。手续费高的会被优先打包出块,手续费低的则需要等前面手续费高的交易全部被打包完才能被打包。当然进入交易池中的交易是源源不断的,不管交易进入交易池时间的先后,手续费高的永远会被优先打包,手续费过低的可能永远都不会被打包。
那么手续费是怎么来的呢?
我们先看以太坊手续费计算公式:
Tx Fee(手续费)= Gas Used(燃料用量)* Gas Price(单位燃料价格)
其中 Gas Used 是由系统计算得出的,Gas Price 是可以自定义的,所以最终手续费的多少取决于 Gas Price 设置的多少。
举个例子:
例如 Gas Price 设置为 10 GWEI,Gas Used 为 21,000(WEI 是以太坊上最小的单位 1 WEI = 10^-18 个 Ether,GWEI 则是 1G 的 WEI,1 GWEI = 10^-9 个 Ether)。因此,根据手续费计算公式可以算出手续费为:
10 GWEI(单位燃料价格)* 21,000(燃料用量)= 0.00021 Ether(手续费)
在合约中我们常见到 Call 函数会设置 Gas Limit,下面我们来看看它是什么东西:
Gas Limit 可以从字面意思理解,就是 Gas 限制的意思,设置它是为了表示你愿意花多少数量的 Gas 在这笔交易上。当交易涉及复杂的合约交互时,不太确定实际的 Gas Used,可以设置 Gas Limit,被打包时只会收取实际 Gas Used 作为手续费,多给的 Gas 会退返回来,当然如果实际操作中 Gas Used > Gas Limit 就会发生 Out of gas,造成交易回滚。
当然,在实际交易中选择一个合适的 Gas Price 也是有讲究的,我们可以在 ETH GAS STATION 上看到实时的 Gas Price 对应的打包速度:
由上图可见,当前最快的打包速度对应的 Gas Price 为 2,我们只需要在发送交易时将 Gas Price 设置为 >= 2 的值就可以被尽快打包。
好了,到这里相信大家已经可以大致猜出抢跑的攻击方式了,就是在发送交易时将 Gas Price 调高从而被矿工优先打包。下面我们还是通过一个合约代码来带大家了解抢跑是如何完成攻击的。
合约示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract FindThisHash {
bytes32 public constant hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;
constructor() payable {}
function solve(string memory solution) public {
require(hash == keccak256(abi.encodePacked(solution)), "Incorrect answer");
(bool sent, ) = msg.sender.call{value: 10 ether}("");
require(sent, "Failed to send Ether");
}
}
攻击分析
通过合约代码可以看到 FindThisHash 合约的部署者给出了一个哈希值,任何人都可以通过 solve() 提交答案,只要 solution 的哈希值与部署者的哈希值相同就可以得到 10 个以太的奖励。我们这里排除部署者自己拿取奖励的可能。
我们还是请出老朋友 Eve(攻击者)看看他是如何使用抢跑攻击拿走本该属于 Bob(受害者)的奖励的:
1. Alice(合约部署者)使用 10 Ether 部署 FindThisHash 合约;
2. Bob 找到哈希值为目标哈希值的正确字符串;
3. Bob 调用 solve(“Ethereum”) 并将 Gas 价格设置为 15 Gwei;
4. Eve 正在监控交易池,等待有人提交正确的答案;
5. Eve 看到 Bob 发送的交易,设置比 Bob 更高的 Gas Price(100 Gwei),调用 solve(“Ethereum”);
6. Eve 的交易先于 Bob 的交易被矿工打包;
7. Eve 赢得了 10 个以太币的奖励。
这里 Eve 的一系列操作就是标准的抢跑攻击,我们这里就可以给以太坊中的抢跑下一个定义:抢跑就是通过设置更高的 Gas Price 来影响交易被打包的顺序,从而完成攻击。
那么这类攻击该如何避免呢?
修复建议
在编写合约时可以使用 Commit-Reveal 方案:
https://medium.com/swlh/exploring-commit-reveal-schemes-on-ethereum-c4ff5a777db8
Solidity by Example 中提供了下面这段修复代码,我们来看看它是否可以完美地防御抢跑攻击。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/Strings.sol";
contract SecuredFindThisHash {
// Struct is used to store the commit details
struct Commit {
bytes32 solutionHash;
uint commitTime;
bool revealed;
}
// The hash that is needed to be solved
bytes32 public hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;
// Address of the winner
address public winner;
// Price to be rewarded
uint public reward;
// Status of game
bool public ended;
// Mapping to store the commit details with address
mapping(address => Commit) commits;
// Modifier to check if the game is active
modifier gameActive() {
require(!ended, "Already ended");
_;
}
constructor() payable {
reward = msg.value;
}
/*
Commit function to store the hash calculated using keccak256(address in lowercase + solution + secret).
Users can only commit once and if the game is active.
*/
function commitSolution(bytes32 _solutionHash) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime == 0, "Already committed");
commit.solutionHash = _solutionHash;
commit.commitTime = block.timestamp;
commit.revealed = false;
}
/*
Function to get the commit details. It returns a tuple of (solutionHash, commitTime, revealStatus);
Users can get solution only if the game is active and they have committed a solutionHash
*/
function getMySolution() public view gameActive returns (bytes32, uint, bool) {
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
return (commit.solutionHash, commit.commitTime, commit.revealed);
}
/*
Function to reveal the commit and get the reward.
Users can get reveal solution only if the game is active and they have committed a solutionHash before this block and not revealed yet.
It generates an keccak256(msg.sender + solution + secret) and checks it with the previously commited hash.
Front runners will not be able to pass this check since the msg.sender is different.
Then the actual solution is checked using keccak256(solution), if the solution matches, the winner is declared,
the game is ended and the reward amount is sent to the winner.
*/
function revealSolution(
string memory _solution,
string memory _secret
) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
require(commit.commitTime < block.timestamp, "Cannot reveal in the same block");
require(!commit.revealed, "Already commited and revealed");
bytes32 solutionHash = keccak256(
abi.encodePacked(Strings.toHexString(msg.sender), _solution, _secret)
);
require(solutionHash == commit.solutionHash, "Hash doesn't match");
require(keccak256(abi.encodePacked(_solution)) == hash, "Incorrect answer");
winner = msg.sender;
ended = true;
(bool sent, ) = payable(msg.sender).call{value: reward}("");
if (!sent) {
winner = address(0);
ended = false;
revert("Failed to send ether.");
}
}
}
首先可以看到修复代码中使用了结构体 Commit 记录玩家提交的信息,其中:
commit.solutionHash = _solutionHash = keccak256(玩家地址 + 答案 + 密码)【记录玩家提交的答案哈希】
commit.commitTime = block.timestamp【记录提交时间】
commit.revealed = false【记录状态】
下面我们看这个合约是如何运作的:
1. Alice 使用十个以太部署 SecuredFindThisHash 合约;
2. Bob 找到哈希值为目标哈希值的正确字符串;
3. Bob 计算 solutionHash = keccak256 (Bob’s Address + “Ethereum” + Bob’s secret);
4. Bob 调用 commitSolution(_solutionHash),提交刚刚算出的 solutionHash;
5. Bob 在下个区块调用 revealSolution(“Ethereum”,Bob’s secret) 函数,传入答案和自己设置的密码,领取奖励。
这里我们看下这个合约是如何避免抢跑的,首先在第四步的时候,Bob 提交的是(Bob’s Address + “Ethereum” + Bob’s secret)这三个值的哈希,所以没有人知道 Bob 提交的内容到底是什么。这一步还记录了提交的区块时间并且在第五步的 revealSolution() 中就先检查了区块时间,这是为了防止在同一个区块开奖被抢跑,因为调用 revealSolution() 时需要传入明文答案。最后使用 Bob 输入的答案和密码验证与之前提交的 solutionHash 哈希是否匹配,这一步是为了防止有人不走 commitSolution() 直接去调用 revealSolution()。验证成功后,检查答案是否正确,最后发放奖励。
所以这个合约真的完美地防止了 Eve 抄答案吗?
Of course not!
咋回事呢?我们看到在 revealSolution() 中仅限制了 commit.commitTime < block.timestamp ,所以假设 Bob 在第一个区块提交了答案,在第二个区块立马调用 revealSolution(“Ethereum”,Bob’s secret) 并设置 Gas Price = 15 Gwei Eve ,通过监控交易池拿到答案,拿到答案后他立即设置 Gas Price = 100 Gwei ,在第二个区块中调用 commitSolution() ,提交答案并构造多笔高 Gas Price 的交易,将第二个区块填满,从而将 Bob 提交的交易挤到第三个区块中。在第三个区块中以 100 Gwei 的 Gas Price 调用 revealSolution(“Ethereum”,Eve’s secret) ,得到奖励。
那么问题来了,如何才能有效地防止此类攻击呢?
很简单,只需要设置 uint256 revealSpan 值并在 commitSolution() 中检查 require(commit.commitTime + revealSpan >= block.timestamp, “Cannot commit in this block”);,这样就可以防止 Eve 抄答案的情况。但是在开奖的时候,还是无法防止提交过答案的人抢先领奖。
另外还有一点,本着代码严谨性,修复代码中的 revealSolution() 函数执行完后并没有将 commit.revealed 设为 True,虽然这并不会影响什么,但是在编写代码的时候还是建议养成良好的编码习惯,执行完函数逻辑后将开关设置成正确的状态。
参考链接:
Solidity by Example
https://solidity-by-example.org/hacks/front-running/
What is Gas in Ethereum? Ethereum Transaction Fees
https://2miners.com/blog/what-is-gas-in-ethereum-ethereum-transaction-fees/