智能合約安全與審計
重入攻擊
重入攻擊發生在合約尚未更新完自己的帳目、就先去呼叫另一個合約,而那個合約立刻反過來「重入」、利用這個做到一半的狀態行竊。想像一位銀行行員先把現金交到你手上,事後才把你的餘額劃掉:若你能在這個空檔再要一次現金,就等於把錢領走兩次。在 EVM 上,轉出以太幣或發起外部呼叫都會把控制權交給收款方,對方便能在你的帳本還記著欠款時重新進入你的函式。
就機制而言,危險來自把外部呼叫擺在狀態變更之前。一個天真的 withdraw() 會先檢查呼叫者餘額,用低階 call 送出以太幣(這會執行收款方的 receive 或 fallback 函式),最後才把餘額歸零。攻擊者的 fallback 只要再呼叫一次 withdraw(),此時餘額尚未歸零、檢查照樣通過,迴圈便不斷重複,直到合約被掏空或耗盡 gas。2016 年 6 月 The DAO 被搬走約 360 萬枚 ETH,正是如此,並直接導致了以太坊與以太坊經典的分叉。
標準防禦是「檢查—生效—互動」的排序:先驗證一切,再更新狀態(把餘額歸零),最後才發出外部呼叫。重入防護鎖,也就是像 OpenZeppelin 的 nonReentrant 這類互斥修飾子,會加上一道鎖,讓任何巢狀重入直接回滾。也要留意單一函式互斥鎖會漏掉的變體:跨函式重入(重入另一個共用同一份狀態的函式)與唯讀重入(view 函式在呼叫進行中回傳了過時的值,被第三方合約信以為真)。
// VULNERABLE
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: bal}(""); // hands control to attacker
require(ok);
balances[msg.sender] = 0; // too late
}
// SAFE: checks-effects-interactions
function withdraw() external {
uint256 bal = balances[msg.sender];
balances[msg.sender] = 0; // effect first
(bool ok, ) = msg.sender.call{value: bal}(""); // interaction last
require(ok);
}把狀態更新移到外部呼叫之前。
光靠重入防護鎖並非萬靈丹。唯讀重入能完全繞過它:攻擊者在你的外部呼叫進行中重入,去查詢一個仍回報更新前狀態的 view 函式,再把這個錯誤的值餵給第三方協議,而後者根本不知道有一個呼叫正在進行中。
又称
另见