一台會在交易途中被搶的自動販賣機
想像一台自動販賣機,在「扣款之前」就先把零食掉了出來——而一個機靈的小偷趁機器還在交易途中,把手伸進取物口再抓一包。這不是幻想;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!
}
}攻擊者部署一個合約,其 `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");
}檢查—生效—互動能應付常見情況,但為了縱深防禦,再加上一道 重入防護鎖:一個遇到任何巢狀呼叫就 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 ... */
}提領優於推送:別讓一個壞收款方癱瘓整個合約
第二個模式關乎「由誰發起轉帳」。天真的「推送」做法,是讓合約在自己的邏輯裡把資金送給使用者。但外部轉帳可能失敗或 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;
}
}解法是 提領式付款(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");
}存取控制:誰被允許扳動那根操縱桿
許多災難並非高明的密碼學突破——只是一個任何人都能呼叫的特權函式。存取控制失誤,就是對某個強大動作(鑄造、提領、升級、暫停、自毀)的把關缺失或錯誤。最基本的防禦是一個擁有者檢查,寫成可重用的修飾器,搭配便宜的自訂錯誤。
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 { /* ... */ }真實系統會超出「單一擁有者」的格局。角色式存取控制把不同權限(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) { /* ... */ }可升級代理:透過 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()) }
}
}
}代理的強大與危險不相上下。三條規則讓它不至於反成漏洞:
- 儲存佈局只能往後追加。 代理與每一版實作都必須對儲存槽的順序有共識。在 V2 重新排序或插入一個變數,會讓新程式碼在錯誤的儲存位置讀到舊資料——這是無聲卻災難性的資料毀損。只能在尾端追加新變數(或預留儲存間隙 gap)。
- 用初始化函式,而非建構式。 建構式是在「實作」部署時於其自身脈絡中執行,因此永遠碰不到代理的儲存。改為對外提供一個 `initialize()` 函式,加上「只能執行一次」的把關——並在部署時以原子方式呼叫它。
- 刻意挑選代理樣式。 透明代理把升級邏輯留在代理裡,並區分管理者與使用者的呼叫以避免函式選擇器衝突;UUPS 代理則把 `upgradeTo` 邏輯放進實作(部署較便宜)——但若新版實作忘了把它包含進去,你就永久喪失升級能力。鑽石模式則為超大型合約把邏輯切分到許多切面(facet)。
整合起來:縱深防禦
沒有任何單一模式能讓合約安全;它們是一層層的防線。排序阻擋重入、提領式付款阻擋惡意癱瘓、存取控制阻擋未授權的權力,而代理讓你能修補漏網之魚。它們合起來體現了同一種心態:假定每一次外部互動都心懷敵意,絕不讓合約的安全取決於某個陌生人乖乖守規矩。