上一篇用 Slither 掃了 FlippazOne,找到沒有加 onlyOwner 的 ownerWithdrawAllTo。這篇來看 Slither 在報出這個結果之前,做了哪些事。
上一篇沒有仔細介紹 FlippazOne,這邊先補上。它是一個只拍賣一個 NFT 的合約:
startAuction 開始拍賣,拍賣時間 24 小時。bid() 出價,起標價 0.9 ETH,每次加價至少 0.25 ETH,出價的 ETH 會存在合約裡。buyNow() 直接買下。最高出價低於 14.75 ETH 時直購價是 15 ETH,超過的話是最高出價的兩倍。endAuction,NFT 會鑄造給最高出價者。bidderWithdraw 拿回出價,或是由 refundBids 一次退給所有人。ownerWithdraw 這幾個函式把得標金額領走。FlippazOne 自己寫的函式裡,只有 startAuction 和四個修改設定的函式有加 onlyOwner,第 6 步的提款函式都沒有,所以任何人都能把錢領走。
Slither 大概會經過這幾個步驟:
solc 編譯合約,拿到 AST (Abstract Syntax Tree)。這些中間產物都可以用 Slither 內建的 printer 印出來看,下面用 ownerWithdrawAllTo 一個一個看。
AST 是編譯器解析原始碼後產生的樹狀結構,函式、變數、運算式都是樹上的節點。Slither 不會自己解析 Solidity,它用的是 solc 輸出的 AST,所以合約要先能用對應版本的編譯器編譯,Slither 才跑得起來。上一篇要先用 solc-select 切到 0.8.15 就是這個原因。
CFG 會把一個函式拆成好幾個節點,箭頭代表執行的順序。cfg printer 會把每個函式的 CFG 輸出成 .dot 檔:
slither FlippazOne.sol --print cfg
再用 Graphviz 轉成圖片:
dot -Tpng "FlippazOne.sol-FlippazOne-ownerWithdrawAllTo(address).dot" -o ownerWithdrawAllTo.png

ownerWithdrawAllTo 的 CFG 很短,宣告 success 之後就直接轉帳,最後 require(success)。
拿有加 onlyOwner 的 startAuction 來比較:

startAuction 進入點之後的第一個節點是 onlyOwner(),它的 SlithIR 是 MODIFIER_CALL, Ownable.onlyOwner()(),modifier 在 CFG 裡會單獨成一個節點。ownerWithdrawAllTo 沒有這個節點,轉帳之前也沒有檢查是誰在呼叫。
CFG 每個節點裡的 IRs 就是 SlithIR,也可以用 slithir printer 單獨印出來:
slither FlippazOne.sol --print slithir
ownerWithdrawAllTo 轉帳的那一行會變成:
TMP_547 = CONVERT this to address
TMP_548(uint256) = SOLIDITY_CALL balance(address)(TMP_547)
TUPLE_8(bool,bytes) = LOW_LEVEL_CALL, dest:toAddress, function:call, arguments:[''] value:TMP_548
success(bool)= UNPACK TUPLE_8 index: 0
toAddress.call{value: address(this).balance}("") 被拆成四行:把 this 轉成地址、查餘額、低階呼叫,最後從回傳值取出 success。中間的值存在 TMP_547、TMP_548 這些暫存變數裡。
偵測器看的是這種拆開後的指令,不用處理 Solidity 的各種寫法。像 arbitrary-send-eth 會去找 LOW_LEVEL_CALL 這類會送出 ETH 的指令,再看 dest 和 value 從哪裡來。這裡的 dest 是 toAddress,是函式的參數。
SSA 規定每個變數只能被賦值一次,同一個變數每被寫一次就會多一個新版本。用 slithir-ssa printer 可以看到:
slither FlippazOne.sol --print slithir-ssa
TUPLE_8(bool,bytes) = LOW_LEVEL_CALL, dest:toAddress_1, function:call, arguments:[''] value:TMP_548
success_1(bool)= UNPACK TUPLE_8 index: 0
toAddress 變成 toAddress_1,success 變成 success_1。這個函式很短,每個變數都只有一個版本。如果函式比較長,一個變數被改了幾次,就會有 _1、_2、_3 幾個版本。
每個版本只會有一個來源,所以 Slither 可以往回查一個值是從哪裡來的。toAddress_1 是函式參數,參數是呼叫者傳進來的,所以轉帳的目的地是呼叫者決定的。arbitrary-send-eth 就是用這種資料流分析來判斷目的地。
所以 arbitrary-send-eth 會報 ownerWithdrawAllTo,是因為三個條件都成立:
LOW_LEVEL_CALL。dest 來自函式參數,呼叫者可以控制。onlyOwner 的 modifier,條件式和呼叫參數裡也沒有用到 msg.sender,所以被判定為沒有保護。第 3 點是 Slither 原始碼裡 is_protected 的判斷方式,它看的是 modifier 的名字。如果 modifier 叫 onlyOwner 但裡面寫錯,函式還是會被當成有保護。
將整理 Slither 的優缺點,並介紹評估這類工具常用的指標。