iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Security

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

Day 9|動手寫一個 Slither Detector

  • 分享至 

  • xImage
  •  

Day 7 看過 Level Finance 的重複領取漏洞。claimMultiple 先逐一檢查 epoch 是否領過,全部檢查完才標記成已領取。傳入 [1, 1] 時,同一個 epoch 的獎勵會計算兩次。當時用的是另外寫的簡化版合約,不是鏈上原合約;Slither 內建的 detector 沒有回報這個問題。

Day 6 提過,Slither 的 detector 只能找已寫成規則的問題。這次自己寫一個 detector 試試看。

觀察函式

Day 4 提過,Slither 會先編譯合約,再分析程式結構,不會執行帶有 [1, 1] 的交易。先看簡化版 claimMultiple 有哪些結構上的特徵:

function claimMultiple(uint256[] calldata epochs) external returns (uint256 payout) {
    for (uint256 i; i < epochs.length; ++i) {
        require(!claimed[msg.sender][epochs[i]], "already claimed");
        payout += rewardFor[epochs[i]];
    }

    for (uint256 i; i < epochs.length; ++i) {
        claimed[msg.sender][epochs[i]] = true;
    }

    paid[msg.sender] += payout;
}

這次檢查的是:

  1. external 或 public 函式接收動態陣列
  2. 第一個迴圈在讀取陣列元素時,也讀取 claimed 這類 state
  3. 第一個迴圈沒有寫入該 state;沿著 CFG 走到後面的迴圈,才看到它被寫入

claimMultiple 的第一圈讀取 claimed[msg.sender][epochs[i]],第二圈才把它設成 true。這比只檢查「有動態陣列、有迴圈」更接近 Day 7 提到的問題。不過這版還沒有證明兩個迴圈使用的是同一個 key,也沒有檢查獎勵是否真的多付。

Detector 程式

先匯入 Slither 的型別、CFG 節點和 detector 基底類別。BatchInput 設定回報的分類;Impact 和 Confidence 是固定值,不是掃描後算出的風險分數。

import sys

from slither import Slither
from slither.core.solidity_types import ArrayType
from slither.core.cfg.node import NodeType
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification


class BatchInput(AbstractDetector):
    ARGUMENT = "batch-input"
    HELP = "State read with caller array in one loop, updated in a later loop"
    IMPACT = DetectorClassification.MEDIUM
    CONFIDENCE = DetectorClassification.LOW

    WIKI = "https://github.com/crytic/slither/wiki/Adding-a-new-detector"
    WIKI_TITLE = "Delayed state update in batch processing"
    WIKI_DESCRIPTION = "A loop reads state and a caller array, then a later loop writes that state using the array."
    WIKI_EXPLOIT_SCENARIO = "claimMultiple([1, 1]) counts epoch 1 twice before marking it claimed."
    WIKI_RECOMMENDATION = "Update per-item state during the same iteration as its check."

_detect 從 external、public 函式找動態陣列參數,再把每個 IFLOOP 的節點範圍收集起來。迴圈裡的節點若同時讀取陣列參數與 state,才往下檢查。

    def _detect(self):
        results = []
        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions_entry_points:
                arrays = [
                    p for p in function.parameters
                    if isinstance(p.type, ArrayType) and p.type.is_dynamic
                ]
                if not arrays:
                    continue
                loops = [
                    (node, _loop_nodes(node))
                    for node in function.nodes
                    if node.type == NodeType.IFLOOP
                ]
                for cond, body in loops:
                    for read in body:
                        if read is cond:
                            continue
                        params = [p for p in arrays if p in read.variables_read]
                        if not params:
                            continue

接著看同一圈有沒有寫入該 state。有就跳過;沒有的話,沿著 CFG 從這圈的出口往後找。後面的迴圈若使用同一個陣列參數寫入該 state,才產生 Finding。以下接在 _detect 裡:

                        for state in read.state_variables_read:
                            # A same-iteration write rules out this delayed-update
                            # pattern. Conditional writes may still need review.
                            if any(state in n.state_variables_written for n in body):
                                continue
                            for later, later_body in loops:
                                if later is cond or not _reachable(cond.son_false, later):
                                    continue
                                write = next(
                                    (n for n in later_body
                                     if state in n.state_variables_written
                                     and any(p in n.variables_read for p in params)),
                                    None,
                                )
                                if write is None:
                                    continue
                                info = [
                                    function, " reads `", state.name,
                                    "` with caller array `", params[0].name,
                                    "` in one loop, then updates it in a later loop\n",
                                    "\t- read: ", read, "\n",
                                    "\t- update: ", write, "\n",
                                ]
                                results.append(self.generate_result(info))
                                break
        return results

用 CFG 找迴圈

第一版用 function.nodes 的列表順序判斷迴圈範圍,結果沒有 Finding。把 claimMultiple 的節點印出來,會發現 ENDLOOP 排在 STARTLOOP 後面,迴圈本體反而排在它們後面:

NodeType.STARTLOOP []
NodeType.ENDLOOP []
NodeType.VARIABLE []
NodeType.IFLOOP ['epochs', 'i']
NodeType.EXPRESSION ['claimed', 'epochs', 'msg.sender', 'i']

改從 IFLOOPson_true 沿 CFG 找迴圈內的節點,遇到 ENDLOOP 就停:

def _loop_nodes(cond) -> set:
    """Return the condition and nodes on its true branch before ENDLOOP."""
    body = {cond}
    stack = [cond.son_true] if cond.son_true else []
    while stack:
        node = stack.pop()
        if node in body or node.type == NodeType.ENDLOOP:
            continue
        body.add(node)
        stack.extend(node.sons)
    return body

另一個判斷是從 IFLOOPson_false 出口往後走,確認第二個迴圈在第一個迴圈之後。function.nodes 的列表順序無法確認這件事:

def _reachable(start, target) -> bool:
    """Check CFG order rather than the order of function.nodes."""
    stack = [start] if start else []
    seen = set()
    while stack:
        node = stack.pop()
        if node is target:
            return True
        if node in seen:
            continue
        seen.add(node)
        stack.extend(node.sons)
    return False

最後從命令列讀入合約,註冊 detector,印出 Finding:

if __name__ == "__main__":
    for target in sys.argv[1:]:
        sl = Slither(target, compile_force_framework="solc")
        sl.register_detector(BatchInput)
        for results in sl.run_detectors():
            for r in results:
                print(r["description"], end="")

執行結果

用 Slither 0.11.6 跑兩版簡化合約,只回報漏洞版。以下是輸出節錄,省略本機檔案路徑與行號:

LevelRewardsVulnerable.claimMultiple(uint256[]) reads `claimed` with caller array `epochs` in one loop, then updates it in a later loop
    - read: require(bool,string)(! claimed[msg.sender][epochs[i]],already claimed)
    - update: claimed[msg.sender][epochs[i_scope_0]] = true
函式 結果 原因
LevelRewardsVulnerable.claimMultiple 命中 第一圈讀 claimed,第二圈才寫
LevelRewardsFixed.claimMultiple 沒命中 同一圈讀取後就寫入 claimed

claimed 的更新順序

比較兩版更新 claimed 的位置:

// 漏洞版:檢查在第一圈,更新在第二圈
for (uint256 i; i < epochs.length; ++i) {
    require(!claimed[msg.sender][epochs[i]], "already claimed");
    payout += rewardFor[epochs[i]];
}
for (uint256 i; i < epochs.length; ++i) {
    claimed[msg.sender][epochs[i]] = true;
}

// 修復版:檢查和更新在同一圈
for (uint256 i; i < epochs.length; ++i) {
    uint256 epoch = epochs[i];
    require(!claimed[msg.sender][epoch], "already claimed");
    claimed[msg.sender][epoch] = true;
    payout += rewardFor[epoch];
}

修復版在同一次迭代裡先檢查、再標記。傳入 [1, 1] 時,處理第二個 1claimed 已是 truerequire 會失敗。detector 看到這一圈已有 state 寫入,就不會回報這筆延後更新的 pattern。

限制

  1. 只比對兩個迴圈讀寫的 state 與陣列參數,沒有證明它們使用同一個陣列元素當 key;仍可能誤報。
  2. 同一圈若有條件式寫入,detector 會因看見寫入而跳過,仍可能漏報。
  3. 參數透過別的變數或 internal 函式傳遞時,這版未追蹤。
  4. 沒有判斷重複輸入是否造成資產損失。

明日預告

整理目前累積的內容。

參考資料


上一篇
Day 8|經典漏洞 Reentrancy 的範例
系列文
從 Detect 到 Proof:智慧合約審計工具實戰9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言