Pull to refresh

Разбор заданий конкурса на взлом NFT “The Standoff Digital Art”

Reading time6 min
Views3.7K

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!

Tags:
Hubs:
Total votes 7: ↑5 and ↓2+3
Comments1

Articles

Information

Website
www.ptsecurity.com
Registered
Founded
2002
Employees
1,001–5,000 employees
Location
Россия