1. Введение
Цифровая подпись используется для обеспечения подлинности и целостности. Как описано в этой статье, «Действительная цифровая подпись, при выполнении всех необходимых условий, дает получателю очень высокую уверенность в том, что сообщение было создано известным отправителем (подлинность) и что сообщение не было изменено при передаче (целостность)».
Цифровые подписи широко используются в смарт-контрактах, например, при минтинге в «белые списки» (allowlist mint) и на NFT-маркетплейсах с книгой ордеров (order-book). Это позволяет сократить транзакционные издержки (подпись вне сети и проверка внутри сети). Однако ошибки разработчиков при использовании цифровых подписей создают риски для NFT-рынка. В этом блоге мы хотим обсудить проблемы, связанные с неправильным использованием цифровых подписей в экосистеме NFT.
2. Применение
Цифровая подпись широко используется для минтинга по «белым спискам» (только пользователи с действительными подписями могут создавать NFT) в NFT-контрактах и на NFT-маркетплейсах для проверки ордеров (только ордера с ожидаемыми подписями могут быть исполнены). Подписание данных происходит вне сети (off-chain) для экономии газа. Ниже мы проиллюстрируем эти два сценария использования.
2.1. Минтинг по белому списку (Allowlist Mint)
«NFT-минтинг» — это процедура создания NFT в блокчейне. Большинство NFT-проектов стремятся продвигать свои продукты; они предпочитают мотивировать пользователей с помощью минтинга по белому списку (также называемого предпродажей и т.д.). Люди, выигравшие место, могут сминтить токены по более низкой цене (или даже бесплатно). Цифровая подпись используется для разграничения пользователей из белого списка и обычных пользователей («публичных минтёров»). Ниже приведен пример реализации минтинга по белому списку.
function mint_approved(
vData memory info,
uint256 number_of_items_requested,
uint16 _batchNumber
) external {
...
require(verify(info), "Unauthorised access secret");
...
}
function verify(vData memory info) public view returns (bool) {
require(info.from != address(0), "INVALID_SIGNER");
bytes memory cat =
abi.encode(
info.from,
info.start,
info.end,
info.eth_price,
info.dust_price,
info.max_mint,
info.mint_free
);
bytes32 hash = keccak256(cat);
require(info.signature.length == 65, "Invalid signature length");
bytes32 sigR;
bytes32 sigS;
uint8 sigV;
bytes memory signature = info.signature; assembly {
sigR := mload(add(signature, 0x20))
sigS := mload(add(signature, 0x40))
sigV := byte(0, mload(add(signature, 0x60)))
} bytes32 data =
keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
);
address recovered = ecrecover(data, sigV, sigR, sigS);
return signer == recovered;
}
Этот фрагмент кода взят из проекта Association NFT (в котором была уязвимость — не копируйте этот код). Функция mint_approved() призвана реализовать минтинг по белому списку: владелец проекта подписывает сообщение о минтинге (переменная info) и отправляет его разрешенному пользователю. Затем пользователь вызывает approved_mint с подписанной переменной. Контракт проверяет, подписано ли сообщение проектом (signer == recovered). Если это так, вызывающему разрешается сминтить NFT (что НЕ безопасно, так как отсутствует проверка того, является ли тот, кто вызывает функцию, тем самым человеком, который находится в белом списке).
2.2. Проверка ордеров
Проверка ордеров — еще одно применение цифровых подписей в экосистеме NFT. NFT-маркетплейсы играют важную роль, предоставляя функционал для торговли. Поскольку каждый NFT-токен уникален, модель автоматических маркет-мейкеров (AMM) сложно применить к NFT-рынкам. Таким образом, большинство маркетплейсов, например OpenSea, LooksRare и X2Y2, используют модель «книги ордеров».
Торговля по книге ордеров проста. Есть мейкер (человек, который хочет продать актив по определенной цене) и тейкер (человек, который хочет купить актив по цене продавца). В этом случае происходит совпадение ордеров. Процесс на NFT-маркетплейсах аналогичен. Единственное отличие — процедура выставления ордера: маркетплейсы используют цифровые подписи для проверки ордеров. На рис. 1 показан пример процесса торговли на одном из маркетплейсов: OpenSea.

В частности, продавец подписывает ордер на продажу и сохраняет его на сервере OpenSea. Покупатель может получить информацию о подписанном ордере и вызвать контракт NFT-рынка, передав подписанный ордер в качестве параметров. Маркет-контракт проверяет ордер, чтобы убедиться, что продавец его подписал (поскольку транзакцию инициирует покупатель), что предотвращает покупку актива без согласия продавца.
3. Инциденты безопасности
Принцип Хортона — это максима для криптографических систем, которую можно выразить как «Аутентифицируйте то, что имеется в виду, а не то, что говорится» или «подразумевайте то, что подписываете, и подписывайте то, что подразумеваете». Он требует полного и точного подписания действия. Если подпись неполная или неточная, последствия будут катастрофическими.
3.1 Association NFT
Вернемся к контракту NBA NFT из раздела 2.1. Функция verify выполняет стандартную проверку подписи, но в ней пропущен КРИТИЧЕСКИ важный компонент. Проверка подписи подтверждает лишь то, что сообщение подписано проектом. Однако нет никакой проверки того, что человек, предоставляющий подпись контракту, является тем же, кто был указан в сообщении. В результате любой может использовать ту же подпись, чтобы пройти проверку и сминтить NFT.
3.2 OpenSea
Другая проблема безопасности связана с OpenSea. В начале 2022 года исследователи обнаружили потенциальную уязвимость контракта маркетплейса OpenSea (версия wyvern 2.2), который реализует основной функционал торговли NFT.
В протоколе Wyvern пользователи создают листинги (предложения о продаже) или офферы (предложения о покупке) вне сети, а их подписи проверяются внутри сети. Офферы Wyvern содержат много параметров, которые объединяются в одну байтовую строку для вычисления дайджеста. Затем контракт проверяет подпись этого дайджеста. Метод агрегации параметров просто объединяет их в байтовую строку следующими методами:
index = ArrayUtils.unsafeWriteAddress(index, order.target);
index = ArrayUtils.unsafeWriteUint8(index, uint8(order.howToCall));
index = ArrayUtils.unsafeWriteBytes(index, order.calldata);
index = ArrayUtils.unsafeWriteBytes(index, order.replacementPattern);
index = ArrayUtils.unsafeWriteAddress(index, order.staticTarget);
index = ArrayUtils.unsafeWriteBytes(index, order.staticExtradata);
index = ArrayUtils.unsafeWriteAddress(index, order.paymentToken);
Например, если параметры состоят из 2 компонентов: (address, bytes), и параметры равны (0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xc098"), агрегированные байты будут выглядеть как 0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fc098, просто аdress + bytes. Кажется простым и понятным, верно?
Теперь рассмотрим более сложный пример, структура параметров — (address, bytes, bytes).
параметр 1:
_(0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xab", "0xcdef")_.параметр 2:
_(0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xabcd", "0xef")_.
Агрегированные байты:
параметр 1:
_0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fabcdef_.параметр 2:
_0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fabcdef_.
Ого! Два разных набора параметров имеют одинаковый результат агрегации, что означает, что их дайджесты ОДИНАКОВЫ, в результате чего одна и та же подпись может подтвердить два разных набора параметров.
Это происходит из-за наличия многих компонентов переменной длины в параметрах. Злоумышленник может обрезать часть переменных и присоединить их к предыдущим или последующим компонентам. К сожалению, контракты Wyvern содержат много параметров переменной длины, как показано ниже:
......
address target;
/* HowToCall. */
AuthenticatedProxy.HowToCall howToCall;
/* Calldata. */
bytes calldata;
/* Calldata replacement pattern, or an empty byte array for no replacement. */
bytes replacementPattern;
/* Static call target, zero-address for no static call. */
address staticTarget;
/* Static call extra data. */
bytes staticExtradata;
......
Влияние этой уязвимости заключается в том, что злоумышленник может (если возможно) контролировать аккаунты жертвы для выполнения вредоносных действий. Подробный анализ уязвимости находится здесь.
Оба упомянутых инцидента безопасности нарушают принцип Хортона. В частности, контракт NBA не включает информацию о минтёре в подписанное сообщение (или не проверяет соответствие этой информации с фактически вызывающим), а контракт Wyvern подписывает неструктурированные параметры, из-за чего значение действия может быть изменено при том, что представление («слова») параметров остается прежним.
4. Рекомендации
Следуйте принципу Хортона: подписывайте то, что подразумеваете, а не то, что утверждаете. Подпись должна содержать исчерпывающую и точную информацию.
- Включайте в подпись всю информацию, которая подлежит проверке. Проверяйте соответствие данных в подписанном сообщении значениям во время выполнения (например, соответствие намеченного пользователя в сообщении и фактического пользователя).
- Сообщение для подписи должно быть детерминировано закодировано, т.е. не должно существовать сообщений с разной структурой, но одинаковым результатом кодирования.



