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!