iT邦幫忙

0

資安攻防 (二):防禦重入攻擊的最佳實踐

  • 分享至 

  • xImage
  •  

前一篇親手寫了一段駭客合約,利用「重入攻擊」將銀行合約裡的資金瞬間掏空。如果你仔細回顧昨天的攻擊流程,會發現駭客之所以能成功,完全是因為銀行合約在「扣除餘額」之前,就先把「錢發送出去」了。

在傳統 Web2 的系統中,先發錢再扣餘額(或者包在同一個 Database Transaction 裡)可能不會引發太大的問題;但在 Web3 的世界裡,外部呼叫(External Call)會將控制權短暫地交給接收方。今天,我們就要來學習如何透過一個極度簡單、卻能拯救無數資金的設計模式,來徹底封死這個漏洞。

業界鐵律:Checks-Effects-Interactions (CEI) 模式
在編寫 Solidity 智慧合約時,只要你的函數會與外部合約互動或轉移資金,你就必須死守 Checks-Effects-Interactions (檢查-生效-互動) 這個黃金法則。它的核心精神是嚴格規定函數內部程式碼的執行順序:

  1. Checks (檢查):第一步,永遠先檢查所有的條件。例如:呼叫者是誰?餘額夠不夠?輸入參數是否合法?(通常使用 require)。
  2. Effects (生效):第二步,在確認條件都滿足後,更新合約內部所有的狀態變數。例如:扣除該用戶的餘額、修改布林值狀態。
  3. Interactions (互動):第三步,也是最後一步,才執行與外部合約的互動或轉移以太幣。

實戰修復:重寫銀行合約
讓我們把昨天那個漏洞百出的 withdraw 函數,依照 CEI 模式重新改寫:
'''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SecureBank {
mapping(address => uint256) public balances;

function deposit() public payable {
    balances[msg.sender] += msg.value;
}

// 修復後的提款函數
function withdraw() public {
    uint256 bal = balances[msg.sender];
    
    // 1. Checks (檢查)
    require(bal > 0, "Insufficient balance");

    // 2. Effects (生效 - 狀態更新)
    // 在把錢轉出去之前,我們先把使用者的餘額歸零!
    balances[msg.sender] = 0;

    // 3. Interactions (互動 - 外部呼叫)
    (bool sent, ) = msg.sender.call{value: bal}("");
    require(sent, "Failed to send Ether");
}

}
'''
駭客視角再推演:
如果駭客現在試圖對 SecureBank 發起攻擊,當執行到步驟 3 把錢轉給駭客時,駭客的 receive() 函數依然會被觸發並再次呼叫 withdraw()。
但是!當第二次進入 withdraw() 時,因為我們在步驟 2 已經先執行了 balances[msg.sender] = 0,所以程式碼會在步驟 1 的 require(bal > 0) 直接報錯並退回 (Revert)。重入攻擊宣告失敗!

縱深防禦:引入 ReentrancyGuard (互斥鎖)
雖然 CEI 模式足以防禦絕大多數的重入攻擊,但在複雜的 DeFi 協議中,有時跨函數或跨合約的狀態變數太多,開發者難免會有疏漏。因此,資安界推崇「縱深防禦 (Defense in Depth)」,也就是加上第二道保險:互斥鎖 (Mutex)。

我們可以再次請出好朋友 OpenZeppelin,使用他們寫好的 ReentrancyGuard 模組:
'''
import "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";

// 繼承 ReentrancyGuard
contract UltraSecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;

// 加上 nonReentrant 修飾子
function withdraw() public nonReentrant {
    uint256 bal = balances[msg.sender];
    require(bal > 0, "Insufficient balance");

    balances[msg.sender] = 0;

    (bool sent, ) = msg.sender.call{value: bal}("");
    require(sent, "Failed to send Ether");
}

}
'''
nonReentrant 修飾子的底層邏輯非常簡單:它會在函數執行前把一個狀態變數鎖上(設為 true),執行結束後才解開(設為 false)。如果函數在還沒結束前又被呼叫了一次,就會因為鎖還沒解開而被直接拒絕。雙管齊下,駭客將毫無可乘之機。

重入攻擊雖然可怕,但防禦它的成本其實極低,只需要養成良好的程式碼編排習慣。記住:永遠先扣錢,再發錢!


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言