Solidity編譯器中高危漏洞:誤刪狀態變量賦值
Eocene
2023-05-06 12:49
本文约2022字,阅读全文需要约8分钟
本文從源代碼層面詳解介紹了Solidity( 0.8.13<=solidity<0.8.17)編譯器在編譯過程中,因為Yul 優化機制的缺陷導致的狀態變量賦值操作被錯誤刪除的中/高漏洞原理及相應的預防措施。

本文從源代碼層面詳解介紹了Solidity( 0.8.13<=solidity<0.8.17)編譯器在編譯過程中,因為Yul 優化機制的缺陷導致的狀態變量賦值操作被錯誤刪除的中/高漏洞原理及相應的預防措施。

一級標題

一級標題

官方文檔官方文檔

1.漏洞詳情官方文檔

官方文檔

contract Eocene {

        uint public x;

        function attack() public {

                x = 1;

                x = 2;

        }

}

在編譯過程的UnusedStoreEliminator 優化步驟中,編譯器會將“冗餘”的Storage 寫入操作移除,但由於對“冗餘”的識別缺陷,當某個Yul 函數塊調用特定的用戶定義函數(函數內部存在某個分支不影響調用塊的執行流),且在該Yul 函數塊中被調用函數前後存在對同一狀態變量的寫入操作,會導致在Yul 優化機制將塊中該用戶定義函數被調用前的所有的

Storage 寫入操作從編譯層面被永久刪除

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) }

}

}

考慮如下代碼:

在UnusedStoreEliminator 優化時,x= 1 顯然對於函數attack()的整個執行是冗餘的。自然的,優化後的Yul 代碼會將x= 1;刪除來降低合約的gas 消耗。

接下來考慮在中間插入對自定義函數調用:

顯然,由於y()函數的調用,我們需要判斷y()函數是否會影響函數attack()的執行,如果y()函數可以導致整個函數執行流終止(注意,不是回滾,Yul 代碼中的return()函數可以實現),那麼x= 1 顯然是不能刪除的,所以對於上面的合約來說由於y()函數中存在assembly {return( 0, 0)}可以導致整個消息調用終止,x= 1 自然不能被刪除。

但在Solidity 編譯器中,由於代碼邏輯的問題,使得x= 1 在編譯時被錯誤的刪除,永久改變了代碼邏輯。

  1. 實際編譯測試結果如下:

  2. 震驚!不應該被優化的x= 1 的Yul 代碼丟了!欲知後事如何,請往下看。

    在solidiry 編譯器代碼的UnusedStoreEliminator 中,通過SSA 變量追踪和控制流追踪來判斷一個Storage 寫入操作是否是冗餘的。當進入一個自定義函數中時,UnusedStoreEliminator 如果遇到:

    memory 或storage 寫入操作:將memory 和storage 寫入操作存儲到m_store 變量中,並將該操作的初始狀態設置為Undecided;

    函數調用:獲取函數的memory 或storage 讀寫操作位置,並和m_store 變量中存儲的所有Undecided 狀態下的操作進行對比:

    1.如果是對m_store 中存儲操作的寫入覆蓋,則將m_store 中對應的操作狀態改為Unused

  3. 2.如果是對m_store 中存儲操作的讀取,則將對應m_store 中的對應操作狀態改為Used

3.如果該函數沒有任何可以繼續執行消息調用的分支,將m_store 中所有的內存寫操作改為Unused

1.在上訴條件下,如果函數可以終止執行流,將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

1.獲取y()函數的控制流邏輯,因為y()函數存在可以正常返回的分支,所以canContinue 為True,不進入If 判斷。 x= 1 狀態仍然為Undecided! ! !

3.遇到x= 2 存儲操作:

1. 遍歷m_store 變量,發現處於Undecided 狀態的x= 1 ,x= 2 操作覆蓋x= 1 ,設置x= 1 狀態為Unused。

2. 將x= 2 操作存入m_store,初始狀態為undecided。

2. 將所有m_store 中Unused 狀態的操作刪除

Solidity顯然,在調用函數時,如果被調用函數可以終止消息執行,應該將被調用函數前所有的Undecided 狀態的寫入操作改為Used,而不是依舊保留為Undecided,導致位於被調用函數前的寫入操作被錯誤的刪除。

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) }

    }

}

中,舉例了基本相同的邏輯下,不會受到影響的合約代碼。但,該代碼不受該漏洞的影響並不是因為UnusedStoreEliminator 的處理邏輯存在其他可能,而是在UnusedStoreEliminator 之前的Yul 優化步驟中,存在FullInliner 優化過程會將微小或只有一次調用的被調用函數,嵌入到調用函數中,避免了漏洞觸發條件中的用戶定義函數。

一級標題

編譯結果如下:

一級標題

一級標題

Eocene
作者文库