時(shí)間:2023-05-12|瀏覽:229
背景概述
在上篇文章中我們了解了合約中隱藏的惡意代碼,本次我們來了解一個(gè)非常常見的攻擊手法 —— 搶跑。
前置知識(shí)
提到搶跑,大家第一時(shí)間想到的一定是田徑比賽,在田徑運(yùn)動(dòng)中各個(gè)選手的體能素質(zhì)幾乎相同,起步越早的人得到第一名的概率越大。那么在以太坊中是如何搶跑的呢?
想了解搶跑攻擊必須先了解以太坊的交易流程,我們通過下面這個(gè)發(fā)送交易的流程圖來了解以太坊上一筆交易發(fā)出后經(jīng)歷的流程:
可以看到圖中一筆交易從簽名到被打包一共會(huì)經(jīng)歷 7 個(gè)階段:
1. 使用私鑰對(duì)交易內(nèi)容簽名;
2. 選擇 Gas Price;
3. 發(fā)送簽名后的交易;
4. 交易在各個(gè)節(jié)點(diǎn)之間廣播;
5. 交易進(jìn)入交易池;
6. 礦工取出 Gas Price 高的交易;
7. 礦工打包交易并出塊。
交易送出之后會(huì)被丟進(jìn)交易池里,等待被礦工打包。礦工從交易池中取出交易進(jìn)行打包與出塊。根據(jù) Eherscan 的數(shù)據(jù),目前區(qū)塊的 Gas 限制在 3000 萬左右這是一個(gè)動(dòng)態(tài)調(diào)整的值。若以一筆基礎(chǔ)交易 21,000 Gas 來計(jì)算,則目前一個(gè)以太坊區(qū)塊可以容納約 1428 筆交易。因此當(dāng)交易池里的交易量大時(shí),會(huì)有許多交易沒辦法即時(shí)被打包而滯留在池子中等待。這里就衍生出了一個(gè)問題,交易池中有那么多筆交易,礦工先打包誰的交易呢?
礦工節(jié)點(diǎn)可以自行設(shè)置參數(shù),不過大多數(shù)礦工都是按照手續(xù)費(fèi)的多少排序。手續(xù)費(fèi)高的會(huì)被優(yōu)先打包出塊,手續(xù)費(fèi)低的則需要等前面手續(xù)費(fèi)高的交易全部被打包完才能被打包。當(dāng)然進(jìn)入交易池中的交易是源源不斷的,不管交易進(jìn)入交易池時(shí)間的先后,手續(xù)費(fèi)高的永遠(yuǎn)會(huì)被優(yōu)先打包,手續(xù)費(fèi)過低的可能永遠(yuǎn)都不會(huì)被打包。
那么手續(xù)費(fèi)是怎么來的呢?
我們先看以太坊手續(xù)費(fèi)計(jì)算公式:
Tx Fee(手續(xù)費(fèi))= Gas Used(燃料用量)* Gas Price(單位燃料價(jià)格)
其中 Gas Used 是由系統(tǒng)計(jì)算得出的,Gas Price 是可以自定義的,所以最終手續(xù)費(fèi)的多少取決于 Gas Price 設(shè)置的多少。
舉個(gè)例子:
例如 Gas Price 設(shè)置為 10 GWEI,Gas Used 為 21,000(WEI 是以太坊上最小的單位 1 WEI = 10^-18 個(gè) Ether,GWEI 則是 1G 的 WEI,1 GWEI = 10^-9 個(gè) Ether)。因此,根據(jù)手續(xù)費(fèi)計(jì)算公式可以算出手續(xù)費(fèi)為:
10 GWEI(單位燃料價(jià)格)* 21,000(燃料用量)= 0.00021 Ether(手續(xù)費(fèi))
在合約中我們常見到 Call 函數(shù)會(huì)設(shè)置 Gas Limit,下面我們來看看它是什么東西:
Gas Limit 可以從字面意思理解,就是 Gas 限制的意思,設(shè)置它是為了表示你愿意花多少數(shù)量的 Gas 在這筆交易上。當(dāng)交易涉及復(fù)雜的合約交互時(shí),不太確定實(shí)際的 Gas Used,可以設(shè)置 Gas Limit,被打包時(shí)只會(huì)收取實(shí)際 Gas Used 作為手續(xù)費(fèi),多給的 Gas 會(huì)退返回來,當(dāng)然如果實(shí)際操作中 Gas Used > Gas Limit 就會(huì)發(fā)生 Out of gas,造成交易回滾。
當(dāng)然,在實(shí)際交易中選擇一個(gè)合適的 Gas Price 也是有講究的,我們可以在 ETH GAS STATION 上看到實(shí)時(shí)的 Gas Price 對(duì)應(yīng)的打包速度:
由上圖可見,當(dāng)前最快的打包速度對(duì)應(yīng)的 Gas Price 為 2,我們只需要在發(fā)送交易時(shí)將 Gas Price 設(shè)置為 >= 2 的值就可以被盡快打包。
好了,到這里相信大家已經(jīng)可以大致猜出搶跑的攻擊方式了,就是在發(fā)送交易時(shí)將 Gas Price 調(diào)高從而被礦工優(yōu)先打包。下面我們還是通過一個(gè)合約代碼來帶大家了解搶跑是如何完成攻擊的。
合約示例
// 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 合約的部署者給出了一個(gè)哈希值,任何人都可以通過 solve() 提交答案,只要 solution 的哈希值與部署者的哈希值相同就可以得到 10 個(gè)以太的獎(jiǎng)勵(lì)。我們這里排除部署者自己拿取獎(jiǎng)勵(lì)的可能。
我們還是請出老朋友 Eve(攻擊者) 看看他是如何使用搶跑攻擊拿走本該屬于 Bob(受害者)的獎(jiǎng)勵(lì)的:
1. Alice(合約部署者)使用 10 Ether 部署 FindThisHash 合約;
2. Bob 找到哈希值為目標(biāo)哈希值的正確字符串;
3. Bob 調(diào)用 solve("Ethereum") 并將 Gas 價(jià)格設(shè)置為 15 Gwei;
4. Eve 正在監(jiān)控交易池,等待有人提交正確的答案;
5. Eve 看到 Bob 發(fā)送的交易,設(shè)置比 Bob 更高的 Gas Price(100 Gwei),調(diào)用 solve("Ethereum");
6. Eve 的交易先于 Bob 的交易被礦工打包;
7. Eve 贏得了 10 個(gè)以太幣的獎(jiǎng)勵(lì)。
這里 Eve 的一系列操作就是標(biāo)準(zhǔn)的搶跑攻擊,我們這里就可以給以太坊中的搶跑下一個(gè)定義:搶跑就是通過設(shè)置更高的 Gas Price 來影響交易被打包的順序,從而完成攻擊。
那么這類攻擊該如何避免呢?
修復(fù)建議
在編寫合約時(shí)可以使用 Commit-Reveal 方案:
https://medium.com/swlh/exploring-commit-reveal-schemes-on-ethereum-c4ff5a777db8
Solidity by Example 中提供了下面這段修復(fù)代碼,我們來看看它是否可以完美地防御搶跑攻擊。
// 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.");
}
}
}
首先可以看到修復(fù)代碼中使用了結(jié)構(gòu)體 Commit 記錄玩家提交的信息,其中:
commit.solutionHash = _solutionHash = keccak256(玩家地址 + 答案 + 密碼)【記錄玩家提交的答案哈?!?/p>
commit.commitTime = block.timestamp 【記錄提交時(shí)間】
commit.revealed = false 【記錄狀態(tài)】
下面我們看這個(gè)合約是如何運(yùn)作的:
1. Alice 使用十個(gè)以太部署 SecuredFindThisHash 合約;
2. Bob 找到哈希值為目標(biāo)哈希值的正確字符串;
3. Bob 計(jì)算 solutionHash = keccak256 (Bob’s Address + “Ethereum” + Bob’s secret);
4. Bob 調(diào)用 commitSolution(_solutionHash),提交剛剛算出的 solutionHash;
5. Bob 在下個(gè)區(qū)塊調(diào)用 revealSolution("Ethereum",Bob"s secret) 函數(shù),傳入答案和自己設(shè)置的密碼,領(lǐng)取獎(jiǎng)勵(lì)。
這里我們看下這個(gè)合約是如何避免搶跑的,首先在第四步的時(shí)候,Bob 提交的是(Bob’s Address + “Ethereum” + Bob’s secret)這三個(gè)值的哈希,所以沒有人知道 Bob 提交的內(nèi)容到底是什么。這一步還記錄了提交的區(qū)塊時(shí)間并且在第五步的 revealSolution() 中就先檢查了區(qū)塊時(shí)間,這是為了防止在同一個(gè)區(qū)塊開獎(jiǎng)被搶跑,因?yàn)檎{(diào)用 revealSolution() 時(shí)需要傳入明文答案。最后使用 Bob 輸入的答案和密碼驗(yàn)證與之前提交的 solutionHash 哈希是否匹配,這一步是為了防止有人不走 commitSolution() 直接去調(diào)用 revealSolution()。驗(yàn)證成功后,檢查答案是否正確,最后發(fā)放獎(jiǎng)勵(lì)。
所以這個(gè)合約真的完美地防止了 Eve 抄答案嗎?
Of course not!
咋回事呢?我們看到在 revealSolution() 中僅限制了 commit.commitTime < block.timestamp ,所以假設(shè) Bob 在第一個(gè)區(qū)塊提交了答案,在第二個(gè)區(qū)塊立馬調(diào)用 revealSolution("Ethereum",Bob"s secret) 并設(shè)置 Gas Price = 15 Gwei Eve ,通過監(jiān)控交易池拿到答案,拿到答案后他立即設(shè)置 Gas Price = 100 Gwei ,在第二個(gè)區(qū)塊中調(diào)用 commitSolution() ,提交答案并構(gòu)造多筆高 Gas Price 的交易,將第二個(gè)區(qū)塊填滿,從而將 Bob 提交的交易擠到第三個(gè)區(qū)塊中。在第三個(gè)區(qū)塊中以 100 Gwei 的 Gas Price 調(diào)用 revealSolution("Ethereum",Eve"s secret) ,得到獎(jiǎng)勵(lì)。
那么問題來了,如何才能有效地防止此類攻擊呢?
很簡單,只需要設(shè)置 uint256 revealSpan 值并在 commitSolution() 中檢查 require(commit.commitTime + revealSpan >= block.timestamp, "Cannot commit in this block");,這樣就可以防止 Eve 抄答案的情況。但是在開獎(jiǎng)的時(shí)候,還是無法防止提交過答案的人搶先領(lǐng)獎(jiǎng)。
另外還有一點(diǎn),本著代碼嚴(yán)謹(jǐn)性,修復(fù)代碼中的 revealSolution() 函數(shù)執(zhí)行完后并沒有將 commit.revealed 設(shè)為 True,雖然這并不會(huì)影響什么,但是在編寫代碼的時(shí)候還是建議養(yǎng)成良好的編碼習(xí)慣,執(zhí)行完函數(shù)邏輯后將開關(guān)設(shè)置成正確的狀態(tài)。
熱點(diǎn):智能合約