稽核不是一張通過認證的印章
想像一個團隊週一上線了一個借貸協定。到了週五,已經有 3 億美元湧入。他們委託做了一次稽核,那份 40 頁的 PDF 像一張安全認證一樣掛在官網上。但有個不太舒服的事實:那份報告只是某一個特定 commit 雜湊的快照,由一群沒寫過這份程式碼的人花幾週審過,他們無法測試每一條路徑,而且誠實到會在第一頁就講明這一點。智慧合約稽核能降低風險,卻永遠無法移除風險。
最清楚的例證是 Euler Finance。截至 2023 年 3 月,它的程式碼已經被大約十次不同的稽核審查過,卻仍因一行漏掉的檢查而損失約 1.97 億美元:一個 `donateToReserves` 函式從未重新檢查捐贈者的健康係數,讓攻擊者得以把自己的帳戶推入資不抵債,再自我清算牟利。(罕見的是,攻擊者後來幾乎把資金全數歸還。)被重度稽核,照樣被掏空。請把每一次稽核都當成縱深防禦的一層,而不是保證書。
那麼一次真正的稽核長什麼樣?它是一條流水線,每個階段攔下不同類型的漏洞。前面幾階教過你這些漏洞類型——重入、溢位、失守的存取控制、預言機操縱。本階的壓軸,就是那套有系統地把它們獵捕出來的方法。
- 確定範圍與威脅模型——鎖定 commit,列出角色、資產、信任邊界,以及那些必須永遠成立的不變量。
- 人工審查+靜態分析——逐行親手讀過,並用 Slither 標出已知模式作為後盾。
- 測試與模糊測試——高覆蓋率的單元測試涵蓋你想得到的情況,性質模糊測試攻擊你想不到的情況。
- 形式化驗證——對那少數一旦被打破就會釀成災難的不變量,做數學證明。
- 報告與修補——把問題項連同嚴重程度與概念驗證寫成報告,再重新審查修補後的版本。
- 漏洞獎勵+監控——上線後付錢請外部人士繼續攻擊,並即時監看鏈上動態。
威脅建模:誰會攻擊、賭注是什麼、什麼必須永遠成立
優秀的稽核員不會一上來就讀程式碼。他們先把系統畫出來:角色(使用者、管理員/擁有者、keeper 與清算者,以及攻擊者)、資產(鎖倉量 TVL、治理權、價格饋送本身),以及信任邊界——任何攻擊者可控的資料跨入你的邏輯的地方。最重要的那條邊界正是新手會忽略的:每一次外部呼叫,都把控制權交給了一段你並不擁有的程式碼。
關鍵是:假設攻擊者很有錢。拜閃電貸所賜,任何人都能在一筆交易的時間內借到數千萬美元,所以「他們不可能有那麼多資金」永遠不是有效的防線。把攻擊者建模成能以任意順序呼叫你的函式、用搶先交易把你夾在中間、並從任何外部呼叫重入回來。你的威脅模型,就是那張你承諾「絕不會發生」的壞結果清單。
那張清單會變成你的不變量:在任何可能的呼叫序列之後、由任何人執行、永遠都必須為真的性質。不變量是現代稽核的脊梁——模糊測試工具與形式化工具都是靠「嘗試打破它們」來運作的。在你碰程式碼之前,先把它們寫下來。它們稍後也會成為不變量測試的輸入。
// Invariants for a yield vault — properties that must hold after ANY call. // These are claims; the rest of the audit is an attempt to break them. // 1. Conservation: shares are only created/destroyed by deposit/withdraw // sum(balanceOf[user] for all users) == totalSupply // 2. Solvency: the vault always physically holds what it owes depositors // asset.balanceOf(vault) >= totalDeposited // 3. Access: only the timelocked admin can pause or change parameters // msg.sender == admin for setFee(), pause(), upgrade() // 4. Monotonic share price: pricePerShare never decreases // except inside a documented, bounded loss event // 5. No free money: every token leaving the vault is matched by an // equal-value token entering, within rounding tolerance
人工審查與 Slither 靜態分析
稽核的核心,依然是一個帶著懷疑眼光逐行讀程式碼的人。靜態分析則是後盾。Slither(出自 Trail of Bits)會把你的 Solidity 編譯成中介表示,再跑數十個偵測器,辨認已知危險的型態:外部呼叫之後才寫入狀態(重入的氣味)、用 `tx.origin` 做身分驗證、未初始化的 storage、危險的未檢查低階呼叫,以及任意的 `delegatecall`。底層它仰賴污點分析,追蹤攻擊者可控的資料如何從進入點流向敏感的接收點。
$ slither src/Vault.sol
Reentrancy in Vault.withdraw(uint256) (src/Vault.sol#42-51):
External calls:
- (sent,) = msg.sender.call{value: amount}("") (src/Vault.sol#47)
State variables written after the call:
- balances[msg.sender] = 0 (src/Vault.sol#49) <-- effect AFTER interaction
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#reentrancy
Vault.owner (src/Vault.sol#11) is never initialized — defaults to address(0).
INFO:Slither:src/Vault.sol analyzed (1 contract), 2 results found測試與模糊測試:從單元測試到不變量攻防戰
單元測試證明你想得到的情況;模糊測試攻擊你想不到的。智慧合約模糊測試工具會產生成千上萬個隨機輸入——在有狀態模式下,更會產生帶著隨機參數的隨機呼叫序列——並在每一次之後檢查你的不變量。一旦某個序列打破了不變量,它會把反例縮小成最小的重現步驟,交到你手上。這是稽核員手上槓桿最高的單一技術。
// Foundry invariant test. forge throws random call sequences at the
// vault (via the Handler) and asserts solvency after every single step.
contract VaultInvariants is StdInvariant, Test {
Vault vault;
Handler handler; // wraps deposit/withdraw with bounded inputs
function setUp() public {
vault = new Vault(asset);
handler = new Handler(vault);
targetContract(address(handler)); // fuzz only through the handler
}
// INVARIANT #2: the vault can never owe more than it holds.
function invariant_neverInsolvent() public view {
assertGe(asset.balanceOf(address(vault)), vault.totalDeposited());
}
}// Echidna (Trail of Bits) writes properties IN Solidity. Any function
// prefixed echidna_ must return true no matter what the fuzzer calls.
contract TokenProps is MyToken {
// INVARIANT #1: total supply always equals the sum of all balances.
function echidna_supply_equals_balances() public view returns (bool) {
return totalSupply() == _sumOfTrackedBalances();
}
}
// $ echidna test/TokenProps.sol --contract TokenProps
// echidna_supply_equals_balances: PASSED (50000 calls, 0 failures)Foundry(forge)與 Echidna 這類工具的好壞,完全取決於它們的處理器(handler)與邊界設定。如果你的處理器讓模糊測試工具存入會在抵達真正邏輯之前就溢位的數字、或從不授權代幣,那這場攻防戰只是在探索垃圾,回報一個假的「通過」。寫出好的處理器——真實的角色、合理的輸入範圍、追蹤期望狀態的幽靈變數——才是大部分的功力所在。誠實的稽核員會回報覆蓋率,而不只是亮綠勾。
形式化驗證:用證明取代抽測
模糊測試是對輸入空間抽樣——數百萬個點,但仍只是一個實際上無限的空間中的有限切片。形式化驗證則一次對所有輸入證明某個性質。它透過符號執行把合約與不變量餵給 SMT 求解器:變數不再是具體數字,而是符號,求解器以數學方式搜尋任何能違反規則的賦值。工具有:Certora Prover(搭配它的 CVL 規格語言)、Halmos(符號化的 Foundry 測試),以及 Mythril/hevm。
// Certora CVL-style rule (illustrative): prove the solvency invariant
// holds after EVERY public method, for ALL inputs, with no counterexample.
rule neverInsolvent(method f) {
// assume the invariant holds before the call
require asset.balanceOf(currentContract) >= totalDeposited();
env e; calldataarg args;
f(e, args); // f = any function, args = any inputs
// require it still holds afterward — the prover tries to falsify this
assert asset.balanceOf(currentContract) >= totalDeposited();
}寫出一份問題項:嚴重程度、概念驗證與修補建議
稽核的交付物就是一個個問題項(finding)。每一項都依嚴重程度評級,而嚴重程度結合了衝擊(被利用後有多糟——直接損失資金是 Critical;干擾或卡住的介面是 Low)與可能性(多容易觸發——無需權限且可在單筆交易內完成屬高;需要管理員金鑰屬低)。一份清楚的問題項會載明:標題、嚴重程度與理由、確切位置與 commit、描述、具體的概念驗證,以及修補建議。沒有可重現 PoC 的問題項,只是一種意見。
[H-01] First-depositor inflation attack lets an attacker steal later deposits Severity : High (Impact: High — theft of user funds; Likelihood: Medium) Target : Vault.deposit() / convertToShares() (commit a1b2c3d) Description shares = assets * totalSupply / totalAssets. When the pool is empty (totalSupply == 0) the first depositor mints 1 wei of shares, then sends a large amount of the underlying DIRECTLY to the vault (a donation that mints no shares). This inflates totalAssets, so the next depositor's shares = assets * 1 / totalAssets rounds DOWN to 0 — their assets are captured by the attacker's single share. Proof of concept 1. attacker.deposit(1 wei) -> mints 1 share, totalSupply = 1 2. asset.transfer(vault, 10e18) -> donation, no shares minted 3. victim.deposit(5e18) -> 5e18 * 1 / (10e18 + 1) = 0 shares 4. attacker.redeem(1 share) -> withdraws 15e18 + 1 (victim robbed) Recommendation Use OpenZeppelin ERC4626's decimal-offset / virtual shares, OR seed the pool with a permanent dead-shares deposit at deployment, AND revert on zero-share mints: require(shares > 0, "ZERO_SHARES");
這一類漏洞遍布整個 DeFi 的 ERC-4626 金庫,正因如此,OpenZeppelin 才把虛擬股份的緩解措施內建進它的標準實作。交付之後是修補回合:團隊修掉每一個問題項,稽核員再重新審查那個修補——因為修補本身也會引入漏洞。一個問題項在修補本身用同一個 PoC 驗證過之前,都不算結案。
縱深防禦:稽核只是一片起司
沒有任何單一防護能擋下每一種攻擊,所以專業人士會把它們疊起來——這就是瑞士起司模型:每一層都有破洞,但破洞很少剛好對齊。稽核只是其中一片。圍繞它的還有:安全的撰碼、測試、模糊測試、形式化驗證、不只一家的獨立稽核、公開的漏洞獎勵,以及執行期的防禦。攻擊者現在得在同一個位置、同一時間,把所有這些層一次貫穿。
- 安全撰碼——繼承經稽核的 OpenZeppelin 函式庫、遵循檢查-生效-互動、把攻擊面壓到最小。
- 完整測試+性質模糊測試——高覆蓋率,再對每一個關鍵性質做不變量攻防戰。
- 形式化驗證——證明那少數一旦被違反就意味著資不抵債的不變量。
- 一家或多家獨立稽核——不同的團隊看見不同的漏洞;絕不依賴單一一雙眼睛。
- 公開的漏洞獎勵——付錢請外部人士永遠攻擊下去(Immunefi 曾付給白帽駭客高達 1,000 萬美元,例如 Wormhole 的重大漏洞)。
- 執行期防禦——監控與告警、可暫停的斷路器、治理時間鎖,以及在漸進式上線期間設下 TVL 上限。
讀夠多事後檢討,同一個教訓會一再重演:高手的心態是對抗式的謙卑。你假設自己的程式碼是壞的,把力氣花在試圖證明這一點,並清楚自己有時會失敗。稽核不是你宣告勝利的時刻——它只是一道永遠不會完工的防禦中,一片有紀律、誠實的起司。