
Solidity 編譯器漏洞分析:ABI重編碼的缺陷
TechFlow Selected深潮精選

Solidity 編譯器漏洞分析:ABI重編碼的缺陷
該過程本身並沒有大的邏輯問題,但是當和 Solidity 的 cleanup 機制結合時,由於 Solidity 編譯器代碼本身的疏漏,就導致了漏洞的存在。
總覽
本文從源代碼層面對 Solidity 編譯器( 0.5.8<= version <0.8.16)在 ABIReencoding 過程中,由於對固定長度的 uint 和 bytes 32 類型數組的錯誤處理所導致的漏洞問題進行詳細分析,並提出相關的解決方案及規避措施。
漏洞詳情
ABI 編碼格式是用在用戶或合約對合約進行函數調用,傳遞參數時的標準編碼方式。具體可以參考 Solidity 官方關於ABI 編碼的詳細表述。
在合約開發過程中,會從用戶或其他合約傳來的 calldata 數據中,獲取需要的數據,之後可能會將獲取的數據進行轉發或 emit 等操作。限於 evm 虛擬機的所有 opcode 操作都是基於 memory、stack 和 storage,所以在 Solidity 中,涉及到需要對數據進行 ABI 編碼的操作,都會將 calldata 中的數據根據新的順序按照 ABI 格式進行編碼,並存儲到 memory 中。
該過程本身並沒有大的邏輯問題,但是當和 Solidity 的cleanup 機制結合時,由於 Solidity 編譯器代碼本身的疏漏,就導致了漏洞的存在。
根據 ABI 編碼規則,在去掉函數選擇符之後,ABI 編碼的數據分為 head 和 tail 兩部分。當數據格式為固定長度的 uint 或 bytes 32 數組時,ABI 會將該類型的數據都存儲在 head 部分。而 Solidity 對 memory 中 cleanup 機制的實現是在當前索引的內存被使用後,將下一個索引的內存置空,以防止下一索引的內存使用時被髒數據影響。並且,當 Solidity 對一組參數數據進行 ABI 編碼時,是按照從左到右的順序進行編碼!!
為了便於後面的漏洞原理探索,考慮如下形式的合約代碼:
contract Eocene {
event VerifyABI( bytes[], uint[ 2 ]);
function verifyABI(bytes[] calldata a, uint[ 2 ] calldata b) public {
emit VerifyABI(a, b); //Event 數據會按照 ABI 格式編碼之後存儲到鏈上
}
}
合約 Eocene 中 verifyABI 函數的作用,僅僅是將函數參數中的不定長 bytes[] a 和定長 uint[2 ] b 進行 emit。
這裡需要注意,event 事件也會觸發 ABI 編碼。這裡參數 a, b 會編碼成 ABI 格式後再存儲到鏈上。
我們使用 v 0.8.14 版本的 Solidity 對合約代碼進行編譯,通過 remix 進行部署,並傳入verifyABI(['0x aaaaaa','0x bbbbbb'],[0x 11111, 0x 22222 ])。
首先,我們看一看對verifyABI(['0x aaaaaa','0x bbbbbb'],[0x 11111, 0x 22222 ])的正確編碼格式:
0x 5 2c d 1 a 9 c // bytes 4(sha 3("verify(btyes[], uint[ 2 ])"))
0000000000000000000000000000000000000000000000000000000000000060 // index of a
0000000000000000000000000000000000000000000000000000000000011111 // b[0 ]
0000000000000000000000000000000000000000000000000000000000022222 // b[1 ]
0000000000000000000000000000000000000000000000000000000000000002 // length of a
0000000000000000000000000000000000000000000000000000000000000040 // index of a[0 ]
0000000000000000000000000000000000000000000000000000000000000080 // index of a[1 ]
0000000000000000000000000000000000000000000000000000000000000003 // length of a[0 ]
aaaaaa 0000000000000000000000000000000000000000000000000000000000 // a[0 ]
0000000000000000000000000000000000000000000000000000000000000003 // length of a[1 ]
bbbbbb 0000000000000000000000000000000000000000000000000000000000 // a[1 ]
如果 Solidity 編譯器正常,當參數a, b被 event 事件記錄到鏈上時,數據格式應該和我們發送的一樣。讓我們實際調用合約試試看,並對鏈上的 log 進行查看,如果想自己對比,可以查看該TX。
成功調用後,合約 event 事件記錄如下:
!!震驚,緊跟 b[1 ]的,存儲 a 參數長度的值被錯誤的刪除了!!
0000000000000000000000000000000000000000000000000000000000000060 // index of a
0000000000000000000000000000000000000000000000000000000000011111 // b[0 ]
0000000000000000000000000000000000000000000000000000000000022222 // b[1 ]
0000000000000000000000000000000000000000000000000000000000000000 // length of a?? why become 0??
0000000000000000000000000000000000000000000000000000000000000040 // index of a[0 ]
0000000000000000000000000000000000000000000000000000000000000080 // index of a[1 ]
0000000000000000000000000000000000000000000000000000000000000003 // length of a[0 ]
aaaaaa 0000000000000000000000000000000000000000000000000000000000 // a[0 ]
0000000000000000000000000000000000000000000000000000000000000003 // length of a[1 ]
bbbbbb 0000000000000000000000000000000000000000000000000000000000 // a[1 ]
為什麼會這樣?
正如我們前面所說,在 Solidity 遇到需要進行 ABI 編碼的系列參數時,參數的生成順序是從左至,具體對 a, b 的編碼邏輯如下
-
Solidity 先對 a 進行 ABI 編碼,按照編碼規則,a 的索引放在頭部,a 的元素長度以及元素具體值均存放在尾部。
-
處理 b 數據,因為 b 數據類型為 uint[2 ]格式,所以數據具體值被存放在 head 部分。但是,由於 Solidity 自身的 cleanup 機制,在內存中存放了 b[1 ]之後,將 b[1 ]數據所在的後一個內存地址(被用於存放 a 元素長度的內存地址)的值置 0 。
-
ABI 編碼操作結束,錯誤編碼的數據存儲到了鏈上,SOL-2022-6 漏洞出現。
在源代碼層面,具體的錯誤邏輯也很明顯,當需要從 calldata 獲取定長 bytes 32 或 uint 數組數據到 memory 中時,Solidity 總是會在數據複製完畢後,將後一個內存索引數據置為 0 。又由於 ABI 編碼存在 head 和 tail 兩部分,且編碼順序也是從左至右,就導致了漏洞的存在。
具體漏洞的 Solidity 編譯代碼如下:
當源數據存儲位置為 Calldata,且源數據類型為 ByteArray,String,或者源數組基礎類型為 uint 或 bytes 32 時進入ABIFunctions::abiEncodingFunctionCalldataArrayWithoutCleanup()

進入之後,會首先通過fromArrayType.isDynamicallySized()對源數據是否為定長數組來對源數據進行判斷,只有定長數組才符合漏洞觸發條件。
將isByteArrayOrString()判斷結果傳遞給YulUtilFunctions::copyToMemoryFunction(), 根據判斷結果來確定是否在 calldatacopy 操作完成後,對後一個索引位置進行 cleanup。
上訴幾個約束條件結合,就只有位於 calldata 中的源數據格式為定長的 uint 或 bytes 32 的數組複製到內存時才能觸發漏洞。也即是漏洞觸發的約束條件產生的原因。

由於 ABI 進行參數編碼時,總是從左到右的順序,考慮到漏洞的利用條件,我們必須要明白,必須在定長的 uint 和 bytes 32 數組前,存在動態長度類型的數據被存儲到 ABI 編碼格式的 tail 部分,且定長的 uint 或 bytes 32 數組必須位於待編碼參數的最後一個位置。
原因很明顯,如果定長的數據沒有位於最後一個待編碼參數位置,那麼對後一內存位置的置 0 不會有任何影響,因為下個編碼參數會覆蓋該位置。如果定長數據前面沒有數據需要被存儲到 tail 部分,那麼即便後一內存位置被置 0 也沒有關係,因為該位置並不背 ABI 編碼使用。
另外,需要注意的是,所有的隱式或顯示的 ABI 操作,以及符合格式的所有 Tuple(一組數據),都會受到該漏洞的影響。
具體的涉及到的操作如下:
-
event
-
error
-
abi.encode*
-
returns //the return of function
-
struct //the user defined struct
-
all external call
解決方案
-
當合約代碼中存在上訴受影響的操作時,保證最後一個參數不為定長的 uint 或 bytes 32 數組
-
使用不受漏洞影響的 Solidity 編譯器
-
尋求專業的安全人員的幫助,對合約進行專業的安全審計
歡迎加入深潮 TechFlow 官方社群
Telegram 訂閱群:https://t.me/TechFlowDaily
Twitter 官方帳號:https://x.com/TechFlowPost
Twitter 英文帳號:https://x.com/BlockFlow_News














