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;
}
這次檢查的是:
claimed 這類 stateclaimMultiple 的第一圈讀取 claimed[msg.sender][epochs[i]],第二圈才把它設成 true。這比只檢查「有動態陣列、有迴圈」更接近 Day 7 提到的問題。不過這版還沒有證明兩個迴圈使用的是同一個 key,也沒有檢查獎勵是否真的多付。
先匯入 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
第一版用 function.nodes 的列表順序判斷迴圈範圍,結果沒有 Finding。把 claimMultiple 的節點印出來,會發現 ENDLOOP 排在 STARTLOOP 後面,迴圈本體反而排在它們後面:
NodeType.STARTLOOP []
NodeType.ENDLOOP []
NodeType.VARIABLE []
NodeType.IFLOOP ['epochs', 'i']
NodeType.EXPRESSION ['claimed', 'epochs', 'msg.sender', 'i']
改從 IFLOOP 的 son_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
另一個判斷是從 IFLOOP 的 son_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] 時,處理第二個 1 時 claimed 已是 true,require 會失敗。detector 看到這一圈已有 state 寫入,就不會回報這筆延後更新的 pattern。
整理目前累積的內容。