15-16 ноября в Москве проводилась ежегодная кибербитва The Standoff, которая собрала лучшие команды защитников и атакующих. В рамках глобальной конференции по информационной безопасности проводился конкурс на взлом NFT под названием The Standoff Digital Art. Мы пригласили известных цифровых художников для использования их NFT-работ в качестве целей для взлома. Для конкурса мы подготовили смарт-контракт стандарта ERC1155 для нашей коллекции. Владельцем каждой из NFT в коллекции (всего их было 6) был специально подготовленный уязвимый смарт-контракт. При успешной эксплуатации каждого из смарт-контрактов атакующий получал во владение NFT (в тестовой сети). Также за каждый успешный взлом полагался денежный приз. Итак, какие же были уязвимости?


Инкубатор

???? Автор: Артем Ткач

В смарт-контракте мы видим три внешние функции: mint(), allowMinting() и addToWailist(). Цель - заставить смарт-контракт сделать передачу NFT через функцию mint(), однако в конструкторе переменная canMint объявляется как false. Чтобы разблокировать функцию mint(), присутствует функция allowMinting(), однако она доступна только владельцу смарт-контракта. Что же делать? Если внимательно изучить третью функцию addToWailist(), то мы увидим, что в ней объявляется неинициализированный динамический список адресов. В языке Solidity, если при инициализации не присвоить сложным типам данных типа array, mapping или struct какое-либо значение, то при использовании ключевого слова “storage” переменная просто перезапишет первый слот стораджа смарт-контракта. Правда, разработчики Solidity не оставили такую “возможность” языка без внимания и еще 4 года назад исправили компилятор таким образом, чтобы при подобных случаях возвращалась ошибка. Однако компилятор не всегда видит перезапись стораджа. Подробнее об этом можно узнать здесь:

Таким образом, если вызвать addToWaitlist(), то перезапишется первый элемент стораджа, в котором хранится значение переменной canMint. После чего, вызвав функцию mint(), атакующий получает NFT.

Mine

???? Автор: Meta Rite

В этом задании смарт-контракт StandoffNFT_2 наследует контракт Ownable, что является распространенным шаблоном. Можно заметить, что в конструкторе основного смарт-контракта переменной owner присваивается адрес отправителя. У функции withdraw(), которая передает владение NFT, имеется модификатор onlyOwner, разрешающий вызов только владельцу смарт-контракта. Сам код onlyOwner тоже стандартный:

modifier onlyOwner() {
	require(owner == msg.sender);
	_;
}

Однако чему будет равен owner при вызове onlyOwner()? Правильно, он будет равен 0, так как присваивание произошло в контракте StandoffNFT_2, а не в Ownable. При наследовании значение owner в Ownable останется нетронутым. Иначе говоря, нам ничего не мешает позвать setOwner() и затем успешно выполнить withdraw().

Matter

???? Автор: Desinfo

В исходном коде смарт-контракта мы видим функцию unlock(), которая передает владение NFT при выполнении условия:

require(
	bytes32(
  	0x8d8056f94c32675006872f854a6757279eb9a1070660e871535fc7231dc18b30) ==
  	keccak256(preimage), "invalid preimage"
);

Также замечаем комментарий “we have very secure metadata”, что недвусмысленно дает понять, где искать preimage. Обратившись к смарт-контракту коллекции, получаем адрес, где хранятся метаданные:

constructor() ERC1155("https://standoff-nft.vercel.app/api/{}.json") {

Адрес, который передается в функцию ERC1155, является token URI, т.е. это тот адрес, куда NFT маркетплейсы вроде OpenSea, будут ходить за метаданными каждого токена коллекции. Кажется, что дело в шляпе, нужно лишь подставить вместо {} идентификатор токена. Однако при обращении к /api/3.json, получаем 404 ошибку. В чем же дело?

Ответ может дать документация стандарта ERC1155:

The string format of the substituted hexadecimal ID MUST be lowercase alphanumeric: [0-9a-f] with no 0x prefix.

The string format of the substituted hexadecimal ID MUST be leading zero padded to 64 hex characters length if necessary.

Иными словами, TOKEN_ID необходимо перевести в шестнадцатеричную форму и привести к длине из 64 символов с нулями. Т.е. вместо /api/3.json мы должны запрашивать /api/0000000000000000000000000000000000000000000000000000000000000002.json:

Отправив значение preimage в функцию unlock(), получаем NFT.

Raven

???? Автор: volv_victory

В исходном коде смарт-контракта видим два маппинга blacklisted и whitelisted с адресами коллекций. Также имеется функция addCollections, которая принимает на вход упомянутые маппинги, а также подпись, по которой проверяется, что маппинги подписал владелец смарт-контракта. Глядя на историю транзакций на EtherScan, обнаруживаем вызов addCollections с корректной подписью.

В _blacklisted адрес коллекции The Standoff Digital Art. Это означает, что мы не можем вызвать функцию transfer(), которая отправляет NFT, так как она имеет следующее условие:

require(whitelisted[_collection], "collection is not allowed");

Но это не проблема, если внимательно  изучить, как проверяется подпись в addCollections():

bytes32 hash = keccak256(abi.encodePacked(_whitelisted, _blacklisted));
address signer = hash.toEthSignedMessageHash().recover(_signature);
require(signer == owner, "only owner can add NFT collections");

Два маппинга “склеиваются“ с помощью функции abi.encodePacked(), от полученного маппинга считается keccak-хэш и от этого хэша считается подпись. Здесь ошибка заключается в том, что используется abi.encodePacked() вместо abi.encode(). Между ними есть существенная разница: abi.encodePacked() не сохраняет информацию о количестве элементов при сериализации. Это означает, что выражения abi.encodePacked([1,2,3], [4]) и abi.encodePacked([1,2], [3,4]) будут возвращать один и тот же результат и, соответственно, один и тот же keccak-хэш. Более подробно о подобных хэш-коллизиях из-за abi.encodePacked() можно ознакомиться в этой статье.

Таким образом, атакующий может переиспользовать подпись владельца смарт-контракта, чтобы изменить расположение адресов коллекций в переменных blacklisted и whitelisted. Иначе говоря, вместо вызова:

addCollections(
	[0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
	0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
	0x1A92f7381B9F03921564a437210bB9396471050C],
	[0x1EBDe1D447752Ef17625c13940bf0218220bED3b], // адрес standoff в blacklisted
	signature
)

мы делаем следующий вызов:

addCollections(
	[0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
	0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
	0x1A92f7381B9F03921564a437210bB9396471050C,
	0x1EBDe1D447752Ef17625c13940bf0218220bED3b],
	[], // blacklisted теперь пустой
	signature
)

Подпись будет одинаковой и теперь мы сможем успешно выполнить transfer()!

Transformation

???? Автор: Anomalit Kate

В смарт-контракте имеется лишь единственная внешняя функция transfer(), которая переводит NFT отправителю транзакции, однако вызвать ее может только владелец смарт-контракта, который устанавливается в конструкторе. Game over? Как бы ни так, обращаем внимание на старую версию компилятора Solidity - 0.4.25. В этой версии все еще была возможность допустить ошибку при объявлении конструктора, а именно сделать конструктор обычной функцией.

✅ правильный синтаксис: constructor(IERC1155 _collection) {}

???? неправильный синтаксис: function constructor(IERC1155 _collection) {}

Все, что оставалось сделать самому быстрому и внимательному участнику, это отправить транзакцию с вызовом функции constructor() с адресом коллекции, а затем сделать transfer().

Recharge

???? Автор: Loit

Пройдя по адресу NFT, мы не увидим исходного кода смарт-контракта, но в истории транзакций есть любопытное сообщение:

По указанному адресу расположено задание. Оно каким-то образом должно позволить завладеть адресом, которому принадлежит NFT. Читая исходный код, видим функцию deploy(), которая на вход принимает параметр “salt” длиной 4 байта. В ней вызывается одноименная функция из модуля Create2 от OpenZeppelin:

address addr = Create2.deploy(0, salt, getInitCode());

Данный вызов публикует в сеть новый смарт-контракт с помощью опкода CREATE2, который не так давно появился в EVM (Ethereum Virtual Machine) в рамках хард-форка Constantinople. Ранее существовал лишь опкод CREATE; разница между ними состоит в том, что адрес нового смарт-контракта, созданного с помощью обычного CREATE, зависит от nonce - это число, которое увеличивается при каждом новом вызове CREATE, а адреса смарт-контрактов, созданных с помощью CREATE2, зависят от контролируемого пользователем значения salt, что делает адреса новых смарт-контрактов заранее известными.

Смарт-контракт, который можно таким образом задеплоить, называется NFTOwner. Он имеет конструктор, в котором владельцем становится tx.origin, т.е. изначальный отправитель транзакции, и функцию transfer(), передающая NFT. Все это дает понять, что нам нужно угадать salt и разместить контракт именно по тому адресу, который владеет NFT. Задача несложная, так как перебрать нужно всего 4 байта. В результате брутфорса получаем значение “aZy5”. Вызвав функцию deploy() с этим значением забираем NFT.

Итоги

Целых 5 NFT удалось взломать Алексею Быхуну @caffeinumв первые часы после начала конкурса. Последний NFT достался Алексею Егорову, который решил задание на перебор соли. Победители получат денежные призы от организаторов The Standoff, поздравляем!

Until next time!

Комментарии (1)


  1. caffeinum
    24.11.2021 05:48
    +2

    Крутой конкурс, спасибо!

    В некоторых задачках вышло слишком просто – из-за того что мало вариантов, что я могу сделать с контрактом, список публичных методов уже был огромной подсказкой. Я бы добавил туда кучу грязи, чтоб пришлось читать код и думать, где же дыра)

    Спасибо про задание с брутфорсом, открыл для себя новый мир, но так в итоге и не справился – делал брутфорс через обращение к контракту, потому что локально запустить и правильно захэшировать не получалось. А через контракт получалось долго, перебрал только 5% от всех солей.

    Больше всего понравилась задачка с коллизией хэша, прямо почуствовал себя хакером