在傳統 Web2 世界裡,DoS 通常是指用海量的封包把伺服器頻寬塞爆,讓正常用戶連不上線。但在 Web3 的世界裡,因為有 Gas 機制的存在,駭客很難用「海量請求」來癱瘓以太坊網路(因為成本太高)。因此,智慧合約中的 DoS 攻擊,通常是利用程式碼邏輯的缺陷,讓某個關鍵函數「永遠無法執行成功」,進而導致合約癱瘓或資金被永遠鎖死。
我將示範兩種最經典的智慧合約 DoS 攻擊手法。
手法一:惡意拒收 Ether (Unexpected Revert)
這是在以太坊中最常見的 DoS 漏洞,通常發生在「合約需要主動退款給用戶」的場景中。
靶機情境:拍賣合約 (King of the Hill)
假設我們寫了一個公開競標的合約。規則很簡單:誰出的錢最高,誰就是目前的「國王」。當有人出價更高時,合約會把之前的國王踢下來,並主動把錢退還給前任國王。
'''
contract Auction {
address public currentLeader;
uint public highestBid;
function bid() public payable {
require(msg.value > highestBid, "Bid not high enough");
// 致命弱點:主動退款給前任出價者
if (currentLeader != address(0)) {
// 如果這行轉帳失敗,整個 bid() 交易就會 Revert!
(bool sent, ) = currentLeader.call{value: highestBid}("");
require(sent, "Failed to send Ether");
}
currentLeader = msg.sender;
highestBid = msg.value;
}
}
'''
駭客的攻擊劇本:
駭客寫了一個惡意合約來參加競標,但這個惡意合約刻意不實作 receive() 函數,或者在 receive() 裡面直接寫 revert()。
手法二:Gas 耗盡 (Block Gas Limit Exhaustion)
以太坊為了防止無窮迴圈,設定了「區塊 Gas 上限 (Block Gas Limit)」。如果一筆交易需要消耗的 Gas 超過這個區塊能容納的上限,這筆交易就永遠無法被打包上鏈。
靶機情境:批量派發獎勵
'''contract RewardDistributor {
address[] public users;
function register() public {
users.push(msg.sender);
}
// 管理員呼叫此函數,統一發放獎勵給所有註冊用戶
function distributeRewards() public {
for (uint i = 0; i < users.length; i++) {
// 轉帳邏輯...
}
}
}
'''
駭客的攻擊劇本:
駭客可以寫一個腳本,自動生成幾千幾萬個免洗帳號,瘋狂呼叫 register() 把陣列塞爆。
當管理員想要呼叫 distributeRewards() 時,這個 for 迴圈會變得無比巨大。迴圈跑到一半,消耗的 Gas 就超過了區塊上限,交易被迫中斷。
結果:獎勵永遠發不出去,合約裡的資金形同死錢。
最佳防禦實踐:Pull over Push (拉取取代推送)
要防禦這兩種 DoS 攻擊,我們必須改變設計思維。
在 Web3 中,永遠不要讓合約主動發錢 (Push),而是讓用戶自己來領錢 (Pull)。
對於拍賣合約,我們應該改成這樣:
'''
contract SecureAuction {
address public currentLeader;
uint public highestBid;
// 紀錄每個人可以退多少錢
mapping(address => uint) public pendingReturns;
function bid() public payable {
require(msg.value > highestBid, "Bid not high enough");
if (currentLeader != address(0)) {
// 記帳就好,不要主動發錢!
pendingReturns[currentLeader] += highestBid;
}
currentLeader = msg.sender;
highestBid = msg.value;
}
// 讓被踢下來的用戶自己來把錢「拉」回去
function withdraw() public {
uint amount = pendingReturns[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingReturns[msg.sender] = 0; // 符合 Checks-Effects-Interactions
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
'''
這樣一來,就算駭客拒絕收款,他也只能卡死自己的 withdraw 交易,完全不會影響到其他人呼叫 bid。而針對陣列過大的問題,也應採用分頁處理 (Pagination) 或讓用戶自行領取獎勵來避開龐大的 for 迴圈。
阻斷服務攻擊告訴我們一個深刻的道理:在區塊鏈上,過度熱心(主動幫用戶退款、主動派發獎勵)往往會引火上身。