iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Security

從 Detect 到 Proof:智慧合約審計工具實戰系列 第 8

Day 8|經典漏洞 Reentrancy 的範例

  • 分享至 

  • xImage
  •  

上一篇的 Level Finance 合約有業務邏輯漏洞,但 Slither 沒有對應的 detector。今天換一個 reentrancy-eth 能直接找到的案例,看看它實際命中了哪些程式結構。

Grim Finance 與簡化版合約

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 行。

Detector 命中的結構

從 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 需要的結構可以整理成三項:

  1. 外部呼叫前讀取 state variable。
  2. 外部呼叫可以產生 callback,而且會送出 Ether。
  3. 同一個 state variable 在外部呼叫之後才被寫入。

Slither 的 reentrancy 分析會沿著 CFG 記錄外部呼叫前讀過的 state variable,以及呼叫後寫入的 state variable。reentrancy-eth 再限制結果必須包含 Ether transfer。它的原始碼也註明這是一組 heuristic,仍可能出現誤報和漏報。

Fixed 版

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,因為兩邊都使用了 calllow-level-calls 只標記低階呼叫的位置,不判斷 state update 的前後順序。

這說明了什麼

這次的 vulnerable / fixed 對照只有一個關鍵差異:balances[msg.sender] = 0 放在外部呼叫之前或之後。Slither 可以從 CFG、資料讀寫和 call 的資訊辨認這個 pattern,所以 vulnerable 版有 Finding,fixed 版沒有。

這筆 Finding 是需要檢查的線索。它沒有執行攻擊,也沒有證明任意外部呼叫一定能竊取資產。呼叫是否真的能重入、函式是否可達、寫入的 state 是否會造成資產損失,仍要看合約上下文或用測試確認。

明日預告

明天會把 Level Finance 的批次輸入風險寫成自訂 detector,看看一條規則怎麼命中測試樣本,又會在哪些情況誤報。

參考資料


上一篇
Day 7|Level Finance 的重複領取漏洞
系列文
從 Detect 到 Proof:智慧合約審計工具實戰8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言