你有沒有遇到過手機突然卡死,點什么都沒反應的情況?在區塊鏈世界里,智能合約也可能遭遇類似的 "罷工"—— 這就是 "拒絕服務攻擊"(Denial of Service,簡稱 DoS)。今天用大白話講講合約里的 DoS 攻擊是怎么回事,以及如何防范。
先理解:什么是拒絕服務攻擊?
簡單說,拒絕服務攻擊就是通過各種手段,讓合約的核心功能徹底失效,誰都用不了。
就像:
- 你經營一家自助餐廳,有人故意把所有座位占滿卻不消費,真正的顧客進不來
- 你開了個快遞柜,有人把所有格子都塞一些廢紙條,導致正常包裹放不下
在合約里,DoS 攻擊會讓轉賬、提款、投票等關鍵功能徹底卡住,嚴重的甚至會讓合約里的資產永遠取不出來。
合約里最常見的 3 種 DoS 攻擊手法
1. 利用 "必須成功的批量操作"—— 最容易踩的坑
很多合約會設計批量操作功能(比如批量發工資、批量退款),如果代碼里用了for
循環一次性處理所有用戶,就可能被攻擊。
漏洞合約示例(批量退款):
contract BatchRefund {address[] public refundees; // 退款名單mapping(address => uint) public amounts; // 每個人該退的錢// 管理員添加退款名單function addRefundee(address who, uint amount) public {refundees.push(who);amounts[who] = amount;}// 批量退款(有漏洞!)function refundAll() public {// 循環給每個人退款for (uint i = 0; i < refundees.length; i++) {address payable who = payable(refundees[i]);// 只要有一次轉賬失敗,整個函數就會卡住(bool success, ) = who.call{value: amounts[who]}("");require(success, "給某個人退款失敗了");}}
}
攻擊方式:
黑客只需要用一個 "有問題的地址"(比如一個沒有receive
函數的合約地址)加入退款名單。當refundAll()
執行到這個地址時,轉賬會失敗,require
會觸發revert
,整個批量退款就會卡住。
結果就是:所有人都拿不到退款,功能徹底報廢。
2. 用 "gas 炸彈" 耗盡資源 —— 讓交易永遠失敗
以太坊的每筆交易都有 gas 限制(相當于 "燃料上限"),如果合約里有需要大量計算的功能,黑客可以故意觸發這些功能,讓 gas 消耗超過上限,導致交易失敗。
漏洞合約示例(投票系統):
contract BadVoting {mapping(address => uint) public votes; // 每個人的票數address[] public voters; // 投票人名單// 投票function vote() public {votes[msg.sender]++;voters.push(msg.sender); // 每次投票都記錄地址}// 計算總票數(有漏洞!)function countTotalVotes() public view returns (uint) {uint total = 0;// 遍歷所有投票人計算總數for (uint i = 0; i < voters.length; i++) {total += votes[voters[i]];}return total;}
}
攻擊方式:
黑客可以用大量不同的地址反復調用vote()
,讓voters
數組變得非常長(比如 10 萬個地址)。當有人想調用countTotalVotes()
時,遍歷 10 萬個地址需要的 gas 會遠遠超過區塊上限,導致這個函數永遠無法執行。
結果就是:投票系統的計票功能徹底癱瘓。
3. 控制關鍵權限 —— 讓合約變成 "死賬戶"
如果合約的核心功能(比如提款、升級)依賴某個單一地址(比如管理員),而這個地址因為私鑰丟失、被黑等原因失控,就會導致合約 "永久停機"。這也算一種特殊的 DoS。
漏洞場景:
contract AdminControl {address public admin; // 管理員地址mapping(address => uint) public balances;constructor() {admin = msg.sender; // 部署者成為管理員}// 提款必須由管理員觸發function withdraw(address to, uint amount) public {require(msg.sender == admin, "不是管理員");(bool success, ) = payable(to).call{value: amount}("");require(success);}
}
攻擊 / 風險方式:
如果管理員的私鑰丟了,或者管理員地址是個合約且該合約功能已失效,那么withdraw()
函數就永遠沒人能調用,合約里的資產就成了 "死錢"。
這種情況在實際中很常見,每年都有大量加密資產因為 "管理員私鑰丟失" 而永久凍結。
如何防范拒絕服務攻擊?
針對不同的 DoS 攻擊,有不同的防御方案,但核心原則是:避免單點依賴,控制操作復雜度。
1. 解決批量操作問題:化整為零
把一次性的批量操作,拆分成多次小批量操作,即使某一次失敗,也不影響整體。
修復批量退款合約:
contract SafeBatchRefund {address[] public refundees;mapping(address => uint) public amounts;uint public nextIndex; // 記錄下次開始退款的位置function refundBatch(uint batchSize) public {uint end = nextIndex + batchSize;// 防止數組越界if (end > refundees.length) end = refundees.length;// 本次只退batchSize個人for (uint i = nextIndex; i < end; i++) {address payable who = payable(refundees[i]);(bool success, ) = who.call{value: amounts[who]}("");if (success) {nextIndex++; // 只有成功了才更新索引} else {// 失敗了就跳過,下次再試break;}}}
}
這樣即使某個人退款失敗,也能繼續給其他人退款,不會全軍覆沒。
2. 解決 gas 炸彈:限制計算復雜度
- 避免在合約里寫需要遍歷超長數組的函數
- 提前計算并限制單次操作的復雜度
- 重要功能盡量設計成 "常量級" 或 "線性級" 復雜度
修復投票系統:
contract GoodVoting {mapping(address => uint) public votes;uint public totalVotes; // 直接記錄總票數,不用每次計算function vote() public {votes[msg.sender]++;totalVotes++; // 投票時直接更新總數}// 直接返回已記錄的總數,無需遍歷function countTotalVotes() public view returns (uint) {return totalVotes;}
}
3. 解決權限問題:去中心化治理
- 避免單一管理員,改用多簽錢包(需要多個管理員同意才能操作)
- 關鍵功能設計成 "時間鎖"(操作需要等待一段時間才能執行,給社區反應時間)
- 重要合約可以引入 DAO 治理,讓社區共同決定關鍵操作
示例(多簽簡化版):
contract MultiSig {address[] public admins; // 多個管理員uint public required; // 需要多少個管理員同意constructor(address[] memory _admins, uint _required) {admins = _admins;required = _required;}// 提款需要足夠多管理員同意function withdraw(address to, uint amount) public {// 檢查是否有足夠多管理員授權(實際實現更復雜)require(isApprovedByEnoughAdmins(), "同意人數不足");// ... 執行提款 ...}
}
總結:拒絕服務攻擊的本質是 "卡住關鍵流程"
DoS 攻擊不像重入攻擊那樣直接偷錢,但它能讓你的合約徹底失效,造成的損失可能更大(比如無法提款的資金池)。
防范的核心思路是:
- 別把所有雞蛋放在一個籃子里(避免單點依賴)
- 復雜操作要拆分(避免一次性處理太多任務)
- 給系統留 "后路"(失敗了能重試,權限丟了有備選)
寫合約時多問自己:"如果這個功能卡住了,有沒有備用方案?" 能幫你避開大多數 DoS 陷阱。