上一篇的 Level Finance 合約有業務邏輯漏洞,但 Slither 沒有對應的 detector。今天換一個 reentrancy-eth 能直接找到的案例,看看它實際命中了哪些程式結構。
Grim Finance 在 2021 年 12 月遭到攻擊。DeFiHackLabs 將事故分類為 Flashloan & Reentrancy。它保存的 PoC 會在 depositFor 呼叫攻擊者控制的 token 時,從 transferFrom 回呼並再次進入 depositFor。
原本的攻擊還包含 flash loan、LP token 和 share 計算。這裡沒有重現完整事故,而是另外寫一份提款合約,只保留「外部 callback 發生在關鍵 state update 之前」的結構:
contract GrimVaultVulnerable {
mapping(address user => uint256 amount) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount != 0, "empty");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}
}
withdraw 先讀取呼叫者的餘額,再把 Ether 送給 msg.sender,最後才把餘額歸零。如果 msg.sender 是合約,它可以在收到 Ether 時再次呼叫 withdraw。第二次呼叫讀到的餘額還沒有歸零,因此可以再領一次。
測試先由另一位使用者存入 1 ether,再讓攻擊合約存入 1 ether 並提款。vulnerable 版會讓攻擊合約取走 2 ether,合約餘額變成 0。
reentrancy-eth 的輸出對包含 vulnerable / fixed 兩版的 GrimCase.sol 執行 Slither:
slither src/GrimCase.sol --detect reentrancy-eth
reentrancy-eth 只回報 vulnerable 版的 withdraw:
Reentrancy in GrimVaultVulnerable.withdraw() (src/GrimCase.sol#17-24):
External calls:
- (ok,None) = msg.sender.call{value: amount}() (src/GrimCase.sol#21)
State variables written after the call(s):
- balances[msg.sender] = 0 (src/GrimCase.sol#23)
Finding 的 Impact 是 High,Confidence 是 Medium。這兩個分級屬於 reentrancy-eth detector,不是針對這份合約另外計算的風險分數。
輸出同時給了函式和 source location。外部呼叫在第 21 行,呼叫後的 state update 在第 23 行。
從 SlithIR 可以看到 vulnerable 版的執行順序:
# 讀取 balances
REF_1(uint256) -> balances[msg.sender]
amount(uint256) := REF_1(uint256)
# 外部呼叫並送出 Ether
TUPLE_0(bool,bytes) = LOW_LEVEL_CALL,
dest:msg.sender,
function:call,
arguments:['']
value:amount
# 呼叫後才寫入 balances
REF_3(uint256) -> balances[msg.sender]
REF_3(uint256) (->balances) := 0(uint256)
這筆 Finding 需要的結構可以整理成三項:
Slither 的 reentrancy 分析會沿著 CFG 記錄外部呼叫前讀過的 state variable,以及呼叫後寫入的 state variable。reentrancy-eth 再限制結果必須包含 Ether transfer。它的原始碼也註明這是一組 heuristic,仍可能出現誤報和漏報。
fixed 版只調整兩行的順序:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount != 0, "empty");
balances[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
對應的 SlithIR 變成:
REF_5(uint256) -> balances[msg.sender]
amount(uint256) := REF_5(uint256)
REF_6(uint256) -> balances[msg.sender]
REF_6(uint256) (->balances) := 0(uint256)
TUPLE_1(bool,bytes) = LOW_LEVEL_CALL,
dest:msg.sender,
function:call,
arguments:['']
value:amount
餘額先歸零,才執行外部呼叫。攻擊合約再次進入 withdraw 時,會在 amount != 0 這個檢查失敗。測試中攻擊合約只能拿回自己存入的 1 ether,另一位使用者存入的 1 ether 仍留在合約裡。
這種先改 state、再進行外部互動的順序稱為 Checks-Effects-Interactions。Slither 沒有對 fixed 版產生 reentrancy-eth Finding。
完整掃描時,兩版都會收到 low-level-calls 的 Informational Finding,因為兩邊都使用了 call。low-level-calls 只標記低階呼叫的位置,不判斷 state update 的前後順序。
這次的 vulnerable / fixed 對照只有一個關鍵差異:balances[msg.sender] = 0 放在外部呼叫之前或之後。Slither 可以從 CFG、資料讀寫和 call 的資訊辨認這個 pattern,所以 vulnerable 版有 Finding,fixed 版沒有。
這筆 Finding 是需要檢查的線索。它沒有執行攻擊,也沒有證明任意外部呼叫一定能竊取資產。呼叫是否真的能重入、函式是否可達、寫入的 state 是否會造成資產損失,仍要看合約上下文或用測試確認。
明天會把 Level Finance 的批次輸入風險寫成自訂 detector,看看一條規則怎麼命中測試樣本,又會在哪些情況誤報。
reentrancy-eth 原始碼