
Solidity 編譯器中高危漏洞:誤刪狀態變量賦值
TechFlow Selected深潮精選

Solidity 編譯器中高危漏洞:誤刪狀態變量賦值
本文從源代碼層面詳解介紹了 Solidity( 0.8.13<=solidity<0.8.17)編譯器在編譯過程中,因為 Yul 優化機制的缺陷導致的狀態變量賦值操作被錯誤刪除的中/高漏洞原理及相應的預防措施。
本文從源代碼層面詳解介紹了 Solidity( 0.8.13<=solidity<0.8.17)編譯器在編譯過程中,因為 Yul 優化機制的缺陷導致的狀態變量賦值操作被錯誤刪除的中/高漏洞原理及相應的預防措施。
幫助合約開發人員提高合約開發時的安全意識,有效規避或緩解 SOL-2022-7 漏洞對合約代碼安全性的影響。
漏洞詳情
Yul 優化機制是 Solidity 編譯合約代碼的可選項,可以通過優化機制減少合約中某些冗餘的指令,從而降低合約部署和執行過程中的 gas 費用,具體的 Yul 優化機制可以參考官方文檔。
在編譯過程的 UnusedStoreEliminator 優化步驟中,編譯器會將“冗餘”的 Storage 寫入操作移除,但由於對“冗餘”的識別缺陷,當某個 Yul 函數塊調用特定的用戶定義函數(函數內部存在某個分支不影響調用塊的執行流),且在該 Yul 函數塊中被調用函數前後存在對同一狀態變量的寫入操作,會導致在 Yul 優化機制將塊中該用戶定義函數被調用前的所有的 Storage 寫入操作從編譯層面被永久刪除。
考慮如下代碼:
contract Eocene {
uint public x;
function attack() public {
x = 1;
x = 2;
}
}
在 UnusedStoreEliminator 優化時,x= 1 顯然對於函數 attack()的整個執行是冗餘的。自然的,優化後的 Yul 代碼會將 x= 1;刪除來降低合約的 gas 消耗。
接下來考慮在中間插入對自定義函數調用:
contract Eocene {
uint public x;
function attack(uint i) public {
x = 1;
y(i);
x = 2;
}
function y(uint i) internal{
if (i > 0){
return;
}
assembly { return( 0, 0) }
}
}
顯然,由於 y()函數的調用,我們需要判斷 y()函數是否會影響函數 attack()的執行,如果 y()函數可以導致整個函數執行流終止(注意,不是回滾,Yul 代碼中的 return()函數可以實現),那麼 x= 1 顯然是不能刪除的,所以對於上面的合約來說由於 y()函數中存在 assembly {return( 0, 0)}可以導致整個消息調用終止,x= 1 自然不能被刪除。
但在 Solidity 編譯器中,由於代碼邏輯的問題,使得 x= 1 在編譯時被錯誤的刪除,永久改變了代碼邏輯。
實際編譯測試結果如下:
震驚!不應該被優化的 x= 1 的 Yul 代碼丟了!欲知後事如何,請往下看。

在 solidiry 編譯器代碼的 UnusedStoreEliminator 中,通過 SSA 變量追蹤和控制流追蹤來判斷一個 Storage 寫入操作是否是冗餘的。當進入一個自定義函數中時,UnusedStoreEliminator 如果遇到:
-
memory 或 storage 寫入操作:將 memory 和 storage 寫入操作存儲到 m_store 變量中,並將該操作的初始狀態設置為 Undecided;
-
函數調用:獲取函數的 memory 或 storage 讀寫操作位置,並和 m_store 變量中存儲的所有 Undecided 狀態下的操作進行對比:
-
如果是對 m_store 中存儲操作的寫入覆蓋,則將 m_store 中對應的操作狀態改為 Unused
-
如果是對 m_store 中存儲操作的讀取,則將對應 m_store 中的對應操作狀態改為 Used
-
如果該函數沒有任何可以繼續執行消息調用的分支,將 m_store 中所有的內存寫操作改為 Unused
-
在上訴條件下,如果函數可以終止執行流,將 m_store 中,狀態為 Undecided 狀態的 storage 寫操作改為 Used;反之,標識為 Unused
-
函數結束:將所有標記為 Unused 的寫入操作刪除
對 memory 或 storage 寫入操作的初始化代碼如下:
可以看到,將遇到的 memory 和 storage 寫入操作存儲到 m_store 中

遇到函數調用時的處理邏輯代碼如下:
其中,operationFromFunctionCall()和 applyOperation()實現上訴的 2.1 , 2.2 處理邏輯。位於下方的基於函數的 canContinue 和 canTerminate 進行判斷的 If 語句實現 2.3 邏輯。
需要注意,正是下方的 If 判斷的缺陷,導致了漏洞的存在!!!

operationFromFunctionCall()來獲取該函數的所有 memory 或 storage 讀寫操作,這裡需要注意,Yul 中存在很多的內置函數,例如 sstore(), return()。這裡可以看到對於內置函數和用戶定義函數有不同的處理邏輯。

而 applyOperation()函數則是將從 operationFromFuncitonCall()獲取的所有讀寫操作進行對比,來判斷存儲到 m_store 中的是否在該次函數調用中被讀寫,並修改 m_store 中的對應的操作狀態。

考慮上述的 UnusedStoreEliminator 優化邏輯對 Eocene 合約的 attack()函數的處理:
將 x= 1 存儲操作到 m_store 變量中,狀態設置為 Undecided
1.遇到 y()函數調用,獲取 y()函數調用的所有讀寫操作
2.遍歷 m_store 變量,發現 y()調用引起的所有讀寫操作和 x= 1 無關,x= 1 狀態仍然是 Undecided
- 獲取 y()函數的控制流邏輯,因為 y()函數存在可以正常返回的分支,所以 canContinue 為 True,不進入 If 判斷。x= 1 狀態仍然為 Undecided!!!
3.遇到 x= 2 存儲操作:
-
遍歷 m_store 變量,發現處於 Undecided 狀態的 x= 1 ,x= 2 操作覆蓋 x= 1 ,設置 x= 1 狀態為 Unused。
-
將 x= 2 操作存入 m_store,初始狀態為 undecided。
4. 函數結束:
-
將所有 m_store 中 undecided 狀態的操作狀態改為 Used
-
將所有 m_store 中 Unused 狀態的操作刪除
顯然,在調用函數時,如果被調用函數可以終止消息執行,應該將被調用函數前所有的 Undecided 狀態的寫入操作改為 Used,而不是依舊保留為 Undecided,導致位於被調用函數前的寫入操作被錯誤的刪除。
此外,需要注意的是,每個用戶自定義函數控制流標識是會傳遞的,所以在多個函數遞歸調用的場景下,即便最底層函數滿足上訴邏輯,x= 1 也有可能被刪除。
在Solidity中,舉例了基本相同的邏輯下,不會受到影響的合約代碼。但,該代碼不受該漏洞的影響並不是因為 UnusedStoreEliminator 的處理邏輯存在其他可能,而是在 UnusedStoreEliminator 之前的 Yul 優化步驟中,存在 FullInliner 優化過程會將微小或只有一次調用的被調用函數,嵌入到調用函數中,避免了漏洞觸發條件中的用戶定義函數。
contract Normal {
uint public x;
function f(bool a) public {
x = 1;
g(a);
x = 2;
}
function g(bool a) internal {
if (!a)
assembly { return( 0, 0) }
}
}
編譯結果如下:
函數 g(bool a)被嵌入到函數 f()中,避免了用戶定義函數的漏洞條件,避免了漏洞的產生。

解決方案
最根本的解決方案是不使用在受影響範圍的 solidity 編譯器進行編譯,如果需要使用漏洞版本的編譯器,可以考慮在編譯時去除 UnusedStoreEliminator 優化步驟。
如果想要從合約代碼層面進行漏洞緩解,考慮到多個優化步驟的複雜性,以及實際函數調用流的複雜性,請尋找專業的安全人員進行代碼審計來幫助發現合約中的因為該漏洞導致的安全問題。
歡迎加入深潮 TechFlow 官方社群
Telegram 訂閱群:https://t.me/TechFlowDaily
Twitter 官方帳號:https://x.com/TechFlowPost
Twitter 英文帳號:https://x.com/BlockFlow_News














