July 11, 2022

Атаки на контракт - Reentrancy

Всем привет, с вами @kyoukisu (кёкису). Вкатываюсь в разработку на Solidity и меня заинтересовала тема атак на контракты. Почему? Потому что кроме написания самих контрактов как web3 разработчик будет полезно понимать, как с них могут украсть позаимствовать много денег такие уважаемые люди как tern и user221 и как этого не допустить.

По сему поводу решил начать писать+переводить цикл статей посвященных теме атак на контракты. Первая статья это разбор атаки Reentrancy, дополнительно к ней будет еще одна с более сложным более реальным примером и его полным разбором. Данный цикл будет являться вольным переводом информации с Ethereum Smart Contract Best Practices.

Что такое Reentrancy?

Слово «reentrancy» означает «повторный вход». Всякий раз, когда смарт-контракт делает внешний вызов другому смарт-контракту, может быть осуществлен повторный выход в исходную функцию. Кроме того, когда смарт-контракт делает внешний вызов, выполнение EVM передается от смарт-контракта, выполняющего вызов, к тому, который вызывается.

Single Function Reentrancy

Первая замеченная версия этого эксплойта касалась функций, которые могли вызываться многократно и рекурсивно, до завершения первого вызова функции.

Рассмотрим следующий код:

Функция вывода с уязвимостью reentrancy

На 8 строчке происходит перевод средств на адрес вызывающего функцию, однако если вызывающий - другой смарт-контракт, то msg.sender.call приведет к срабатыванию fallback функции на стороннем контракте.

Fallback выполняется при вызове контракта, если ни одна из других функций не соответствует заданному идентификатору функции (или если данные вообще не были предоставлены). Кроме того, эта функция выполняется всякий раз, когда контракт получает эфир без данных.

receive - пример fallback функции, которая рекурсивно вывела бы все деньги с контракта

Функция receive вызовет повторно withdrawBalance и т.к. 7 строчка еще не успела обнулить баланс пользователя userBalances[msg.sender] = 0, это позволит злоумышленнику получать рекурсивно эфир, пока баланс контракта не истощится.

как происходит атака

В приведенном примере лучший способ предотвратить эту атаку - убедиться, что вы не вызываете внешнюю функцию, пока не выполните всю необходимую внутреннюю работу по изменению состояния контракта, такой шаблон называют Checks-Effects-Interactions:

Исправленная функция вывода

После первого вызова баланс пользователя будет равен 0, поэтому последующие вызовы ничего не снимут.

Cross-Function Reentrancy

Злоумышленник также может быть в состоянии выполнить аналогичную атаку, используя две различные функции, которые используют одно и то же состояние.

Рассмотрим контракт, который хранит в себе 2 баланса для пользователя - в эфире и в каком-то токене.

В этом случае злоумышленник вызывает withdrawBalance, а внешний вызов через fallback на строке 30 вызывает exchangeAndWithdrawBalance. Поскольку баланс токена еще не установлен в 0, можно перевести токены, даже если вывод средств был осуществлен.

Решением проблемы будет тот же способ предотвращения, что и в первом случае с одиночной функцией.

Также обратите внимание, что в данном примере обе функции были частью одного контракта. Однако одна и та же ошибка может возникнуть в нескольких контрактах, если эти контракты имеют общее состояние.

Подводные камни при решении проблемы Reentrancy

Поскольку reentrancy может возникать в нескольких функциях и даже в нескольких контрактах, любое решение, направленное на предотвращение reentrancy с помощью одной функции, будет недостаточным.

Вместо этого рекомендуется сначала завершить всю внутреннюю работу (т.е. изменение состояния), и только потом вызывать внешнюю функцию. Это правило, если следовать ему тщательно, позволит вам избежать уязвимостей, связанных с reentrancy . Однако нужно не только избегать слишком раннего вызова внешних функций, но и избегать вызова функций, которые вызывают внешние, как, например, в коде ниже:

Уязвимость reentrancy в функции getFirstWithdrawalBonus

Даже если getFirstWithdrawalBonus не вызывает напрямую внешний контракт, вызова withdrawReward будет достаточно, чтобы сделать его уязвимым для reentrancy.

Следуя шаблону, что в первую очередь надо менять состояние контракта и только потом вызывать внешние функции - так мы сможем убрать уязвимость из примера выше.

Теперь после первого вызова claimedBonus[recipient] = true, поэтому последующий вызов будет остановлен из-за require.

Другим часто используемым решением является мьютекс.

Мьютекс

Мью́текс (mutex, от mutual exclusion — «взаимное исключение») — примитив синхронизации, обеспечивающий взаимное исключение исполнения критических участков кода.

Это позволяет вам "заблокировать" некоторое состояние так, чтобы его мог изменить только владелец блокировки. Простой пример может выглядеть следующим образом:

На 21 строчке проверяется был ли вызов успешен, если пользователь попытается снова вызвать withdraw до завершения первого вызова, блокировка не даст ему оказать никакого эффекта. Это может быть эффективным шаблоном, но он становится сложным, когда у вас есть несколько контрактов, которые должны сотрудничать.

Пример очень тупой ошибки работы с мьютексом

Логика контракта - пользователь может запросить доступ к средствам контракта, затем осуществить спокойно вывод, а потом снять блокировку.

Пользователь/злоумышленник может вызвать getLock через requestWithdrawal, а затем никогда не вызывать releaseLock через finalizeWithdrawal. Если это сделать, то контракт будет заблокирован навсегда, и ничто уже не поможет. Если вы используете мьютексы для защиты от reentrancy, вам необходимо тщательно следить за тем, чтобы не было возможности осуществить блокировку и не освободить в дальнейшем.

При работе с мьютексами можно себе прострелить ногу (или голову), поэтому стоит ознакомиться с такими понятиями как race condition и deadlocks.

Решением для примера выше будет не оставлять возможности пользователям контракта управлять мьютексом и после завершения работы обязательно освобождать мьютекс.

Заключение

В атаке с reentrancy вредоносный контракт обращается к вызывающему контракту до завершения первого вызова функции. Это может привести к нежелательному взаимодействию различных вызовов функции.

Best practices, чтобы избежать reentrancy:

  • Убедитесь, что все внутренние изменения состояния выполнены до передачи потока управления - шаблон Checks-Effects-Interactions.
  • Используйте блокировку повторного входа, например, ReentrancyGuard от OpenZeppelin.

Если хотите попрактиковаться в поиске уязвимостей reentrancy, можете обратиться к ресурсу SWC Registry. Там есть два примера на reentrancy с решениями. Первый сложный, второй проще. Не заспойлерите себе случайно ответы.

Разбор реального взлома через reentrancy The DAO.

Пишите свои замечания и пожелания в комментариях

Мой канал в телеграме - https://t.me/kyo_dev