背景概述
在上篇文章中我們了解了合約中隱藏的惡意代碼,本次我們來了解一個非常常見的攻擊手法—— 搶跑。
前置知識
提到搶跑,大家第一時間想到的一定是田徑比賽,在田徑運動中各個選手的體能素質幾乎相同,起步越早的人得到第一名的概率越大。那麼在以太坊中是如何搶跑的呢?
想了解搶跑攻擊必須先了解以太坊的交易流程,我們通過下面這個發送交易的流程圖來了解以太坊上一筆交易發出後經歷的流程:
可以看到圖中一筆交易從簽名到被打包一共會經歷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’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,雖然這並不會影響什麼,但是在編寫代碼的時候還是建議養成良好的編碼習慣,執行完函數邏輯後將開關設置成正確的狀態。