智能合約安全審計入門系列之搶跑

作者:小白

背景概述

在上篇文章中我們瞭解了合約中隱藏的惡意代碼,本次我們來瞭解一個非常常見的攻擊手法 —— 搶跑。

前置知識

提到搶跑,大家第一時間想到的一定是田徑比賽,在田徑運動中各個選手的體能素質幾乎相同,起步越早的人得到第一名的概率越大。 那麼在乙太坊中是如何搶跑的呢?

想瞭解搶跑攻擊必須先瞭解乙太坊的交易流程,我們通過下面這個發送交易的流程圖來瞭解乙太坊上一筆交易發出后經歷的流程:

圖片

可以看到圖中一筆交易從簽名到被打包一共會經歷 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: MITpragma 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: MITpragma 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 的位址 + “以太坊” + Bob 的秘密);

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 抄答案嗎?

當然不是!

咋回事呢? 我們看到在 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,雖然這並不會影響什麼,但是在編寫代碼的時候還是建議養成良好的編碼習慣,執行完函數邏輯後將開關設置成正確的狀態。

參考連結:

以身作則的堅固性

https://solidity-by-example.org/hacks/front-running/

什麼是以太坊中的氣體?乙太坊交易費用

https://2miners.com/blog/what-is-gas-in-ethereum-ethereum-transaction-fees/

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。