JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

讓合約安全的設計模式:檢查—生效—互動、提領式付款與代理

有四個身經百戰的模式,擋在你的合約與攻擊者之間:把狀態變更排在外部呼叫之前、讓使用者主動提領而非由合約推送、把關誰能扳動權限開關,以及把邏輯與儲存分開以便日後修補漏洞。這一課,正是通往安全篇的橋樑。

一台會在交易途中被搶的自動販賣機

想像一台自動販賣機,在「扣款之前」就先把零食掉了出來——而一個機靈的小偷趁機器還在交易途中,把手伸進取物口再抓一包。這不是幻想;2016 年讓 The DAO 被搬走約 360 萬枚 ETH 的,正是這個漏洞。它之所以可能,源自智慧合約的一個深層事實:你的程式一旦發出外部呼叫,方向盤就交到了你無法掌控的程式手上。

在這一階,你已經寫過真正的 Solidity:狀態與可見性、映射與結構、修飾器與事件,還有一個完整的 ERC-20。現在我們要加上一種紀律——它正是「玩具」與「能保管上百萬資金的合約」之間的分水嶺。這四個模式並非學術空談,每一個都是某次著名又昂貴的攻擊所結成的疤。把它們當成習慣,而不是事後補丁。

檢查—生效—互動:擊敗重入的順序

Solidity 裡最重要的單一排序規則,就是 檢查(Checks)→ 生效(Effects)→ 互動(Interactions)。先「檢查」前置條件(輸入、餘額、權限),再施加「效果」——更新合約自身的狀態,最後才進行「互動」——呼叫其他位址。危險就在於把順序弄反:若你在把餘額歸零之前就先匯出款項,收款方就能回頭再呼叫一次、再被付一次。以下就是讓 The DAO 覆滅的脆弱寫法。

// VULNERABLE: interaction happens BEFORE the effect
pragma solidity ^0.8.24;

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

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

    function withdraw() external {
        uint256 amount = balances[msg.sender];        // Check
        require(amount > 0, "nothing to withdraw");
        // INTERACTION first -- hands control to msg.sender's code:
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
        balances[msg.sender] = 0;                     // Effect -- too late!
    }
}
第 14 行的呼叫會觸發收款方的回退函式,它能在第 16 行執行之前重新呼叫 withdraw()——此時 balances[attacker] 仍非零,於是一次又一次地付款出去。

攻擊者部署一個合約,其 `receive()` 函式會再次呼叫 `withdraw()`。由於餘額是在轉帳「之後」才歸零,每次重入看到的都是滿額餘額,於是在一筆交易裡把銀行掏空。修法小到幾乎令人尷尬:把狀態更新移到外部呼叫「之前」,在開門之前就先把門鎖上。

// SAFE: effects committed BEFORE the interaction
function withdraw() external {
    uint256 amount = balances[msg.sender];   // Checks
    require(amount > 0, "nothing to withdraw");
    balances[msg.sender] = 0;                // Effects (state updated FIRST)
    (bool ok, ) = msg.sender.call{value: amount}("");  // Interactions (call LAST)
    require(ok, "transfer failed");
}
現在重入呼叫讀到的是 balances[attacker] == 0,require 直接 revert。同樣的程式碼,只是換了順序——攻擊就此消失。

檢查—生效—互動能應付常見情況,但為了縱深防禦,再加上一道 重入防護鎖:一個遇到任何巢狀呼叫就 revert 的互斥鎖。OpenZeppelin 的防護鎖儲存的是 `1`(未進入)與 `2`(已進入)而非布林值,因為在兩個非零值之間翻轉一個儲存槽,所需的gas遠比從零開始改動來得便宜。

uint256 private _status = 1; // 1 = NOT_ENTERED, 2 = ENTERED

modifier nonReentrant() {
    require(_status == 1, "reentrant call");
    _status = 2;
    _;            // function body runs here
    _status = 1;
}

function withdraw() external nonReentrant {
    /* ... checks-effects-interactions still applies ... */
}
以 Solidity 修飾器實作的重入防護鎖。把它當作檢查—生效—互動這條吊帶之外的另一條皮帶——而非「正確排序」的替代品。

提領優於推送:別讓一個壞收款方癱瘓整個合約

第二個模式關乎「由誰發起轉帳」。天真的「推送」做法,是讓合約在自己的邏輯裡把資金送給使用者。但外部轉帳可能失敗或 revert——如果你的合約進度「取決於」這筆轉帳成功,那麼單一惡意收款方就能把所有人凍結住。這就是阻斷服務漏洞。經典例子:一場拍賣,在出價邏輯裡直接退款給前一位最高出價者。

// VULNERABLE (push): refund runs inside bid()
contract Auction {
    address public highestBidder;
    uint256 public highestBid;

    function bid() external payable {
        require(msg.value > highestBid, "bid too low");
        if (highestBidder != address(0)) {
            // If THIS transfer reverts, no one can ever outbid again.
            payable(highestBidder).transfer(highestBid);
        }
        highestBidder = msg.sender;
        highestBid = msg.value;
    }
}
攻擊者用一個 receive() 永遠 revert 的合約來出價。從此每一次 bid() 都會在退款那一行 revert,拍賣永久卡死,攻擊者穩贏。

解法是 提領式付款(pull payment) 模式:不主動推送退款,而是「記錄」每個位址應得的金額,讓他們在另一筆呼叫裡自行提領。如此一來,某人提領失敗,只影響他自己。注意提領函式仍遵守檢查—生效—互動,並使用低階 `call` 同時檢查其回傳值——絕不要忽略外部呼叫的成功布林值。

// SAFE (pull): bidders withdraw their own refunds
mapping(address => uint256) public pendingReturns;

function bid() external payable {
    require(msg.value > highestBid, "bid too low");
    if (highestBidder != address(0)) {
        pendingReturns[highestBidder] += highestBid;  // record, don't send
    }
    highestBidder = msg.sender;
    highestBid = msg.value;
}

function withdrawRefund() external {
    uint256 amount = pendingReturns[msg.sender];
    require(amount > 0, "nothing to withdraw");
    pendingReturns[msg.sender] = 0;                  // effect before interaction
    (bool ok, ) = payable(msg.sender).call{value: amount}("");
    require(ok, "refund failed");
}
提領式付款也避開了 transfer()/send() 那 2300 gas 補貼的陷阱——當收款方是一個 receive() 需要更多 gas 的智慧合約錢包時,那種寫法會壞掉。

存取控制:誰被允許扳動那根操縱桿

許多災難並非高明的密碼學突破——只是一個任何人都能呼叫的特權函式。存取控制失誤,就是對某個強大動作(鑄造、提領、升級、暫停、自毀)的把關缺失或錯誤。最基本的防禦是一個擁有者檢查,寫成可重用的修飾器,搭配便宜的自訂錯誤。

error NotOwner();

address public owner;

constructor() { owner = msg.sender; }

modifier onlyOwner() {
    if (msg.sender != owner) revert NotOwner();  // msg.sender, NEVER tx.origin
    _;
}

function setFee(uint256 newFee) external onlyOwner { /* ... */ }
使用 msg.sender(直接呼叫者),而非 tx.origin(最初發起的外部帳戶)——見下方警告。

真實系統會超出「單一擁有者」的格局。角色式存取控制把不同權限(MINTER、PAUSER、UPGRADER)授予不同位址,這樣一把被盜的鑄造金鑰,無法同時升級合約。其模式是一個「角色 → 位址 → 布林值」的巢狀映射。

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
mapping(bytes32 => mapping(address => bool)) public hasRole;

modifier onlyRole(bytes32 role) {
    require(hasRole[role][msg.sender], "missing role");
    _;
}

function mint(address to, uint256 amt) external onlyRole(MINTER_ROLE) { /* ... */ }
最小權限:每把金鑰只持有它所需的權力。這正是 OpenZeppelin 的 AccessControl 所制度化的形態,附帶角色管理者與鏈上的授予/撤銷事件。

可升級代理:透過 delegatecall 把邏輯與儲存分開

已部署的位元組碼是不可變的——對信任而言是優點,但當你交付了一個 bug,就成了詛咒。代理升級模式化解這個張力,方法是把合約一分為二:一個持有全部儲存與資金的纖薄 代理(proxy),以及一個可替換、持有邏輯的 實作(implementation)。代理用 delegatecall 把每一筆呼叫轉送給實作——它會在「代理的儲存脈絡中」執行實作的程式碼。

其機制正是 EVM 篇裡 CALL 與 DELEGATECALL 的分別:一般的呼叫在被呼叫者的脈絡中執行,而 `delegatecall` 借用被呼叫者的「程式碼」,卻保留呼叫者的 `storage`、`msg.sender` 與 `msg.value`。要升級,你只需把代理指向一個新的實作位址。以下是一個極簡代理回退函式的核心。

// Proxy: stores state, delegatecalls ALL logic to `implementation`
contract Proxy {
    // EIP-1967 slot = keccak256("eip1967.proxy.implementation") - 1
    bytes32 private constant IMPL_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function _impl() internal view returns (address a) {
        assembly { a := sload(IMPL_SLOT) }
    }

    fallback() external payable {
        address impl = _impl();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch ok
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}
實作位址存放在一個偽隨機的 EIP-1967 儲存槽,刻意如此挑選,讓它永遠不會與實作自己的循序儲存變數(slot 0、1、2……)相撞。

代理的強大與危險不相上下。三條規則讓它不至於反成漏洞:

  1. 儲存佈局只能往後追加。 代理與每一版實作都必須對儲存槽的順序有共識。在 V2 重新排序或插入一個變數,會讓新程式碼在錯誤的儲存位置讀到舊資料——這是無聲卻災難性的資料毀損。只能在尾端追加新變數(或預留儲存間隙 gap)。
  2. 用初始化函式,而非建構式。 建構式是在「實作」部署時於其自身脈絡中執行,因此永遠碰不到代理的儲存。改為對外提供一個 `initialize()` 函式,加上「只能執行一次」的把關——並在部署時以原子方式呼叫它。
  3. 刻意挑選代理樣式。 透明代理把升級邏輯留在代理裡,並區分管理者與使用者的呼叫以避免函式選擇器衝突;UUPS 代理則把 `upgradeTo` 邏輯放進實作(部署較便宜)——但若新版實作忘了把它包含進去,你就永久喪失升級能力。鑽石模式則為超大型合約把邏輯切分到許多切面(facet)。

整合起來:縱深防禦

沒有任何單一模式能讓合約安全;它們是一層層的防線。排序阻擋重入、提領式付款阻擋惡意癱瘓、存取控制阻擋未授權的權力,而代理讓你能修補漏網之魚。它們合起來體現了同一種心態:假定每一次外部互動都心懷敵意,絕不讓合約的安全取決於某個陌生人乖乖守規矩。

  1. 把每一個會改變狀態的函式都按「檢查 → 生效 → 互動」排序;在會發出外部呼叫的函式上加一道重入防護鎖。
  2. 發款時偏好提領而非推送;檢查每一次低階呼叫的回傳值,且絕不對無上限清單迴圈發款。
  3. 用最小權限的角色,透過修飾器為每個特權函式把關;以 msg.sender 授權;把最強大的金鑰放在多重簽章與時間鎖之後。
  4. 若採可升級設計,鎖死儲存佈局、只初始化一次,並把升級金鑰當成傳家之寶般看守。