Ethereum
March 4, 2023

Уязвимости в смарт-контрактах: Часть 1

Оригинал — https://github.com/kadenzipfel/smart-contract-vulnerabilities

Канал — https://t.me/jetix37eth

  1. Принудительная отправка ETH в контракт
  2. Недостаточное выделение газа
  3. Reentrancy
  4. Переполнение и недополнение целых чисел
  5. Зависимость от временной метки
  6. Авторизация через tx.origin
  7. Плавающая pragma
  8. Видимость функции по умолчанию
  9. Устаревшая версия компилятора
  10. Непроверенное возвращаемое значение вызова
  11. Незащищенный вывод эфира
  12. Незащищенная инструкция Selfdestruct
  13. Видимость переменной состояния по умолчанию
  14. Неинициализированный указатель хранилища
  15. Нарушение assert
  16. Использование устаревших функций
  17. delegateCall ненадежному вызываемому абоненту
  18. Податливость подписи

Принудительная отправка ETH в контракт

Иногда пользователям нежелательно иметь возможность отправлять эфир в смарт-контракт. К сожалению, есть возможность обойти функцию fallback() контракта и принудительно отправить эфир.

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

Первый метод заключается в вызове метода selfdestruct для контракта с адресом уязвимого контракта, установленным в качестве получателя. Это работает, потому что selfdestruct не вызовет функцию fallback().

Другой метод заключается в предварительном вычислении адреса контракта и отправке эфира на этот адрес еще до того, как контракт будет развернут. Как ни удивительно, это возможно.

Недостаточное выделение газа

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

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

Давайте рассмотрим простой контракт ретранслятора в качестве примера. Как показано ниже, контракт ретранслятора позволяет кому-либо совершать и подписывать транзакцию без необходимости ее выполнения. Часто это используется, когда пользователь не может оплатить газ, связанный с транзакцией.

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

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

Reentrancy

Reentrancy (повторный вход) - это атака, которая может произойти, когда ошибка в функции может позволить выполнять взаимодействие с функцией несколько раз, когда в противном случае это должно быть запрещено. Это может быть использовано для вывода средств из смарт-контракта. Фактически, повторный вход был вектором атаки, использованным при взломе DAO.

Внутри функции

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

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

Между функциями

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

В этом примере хакер может воспользоваться этим контрактом, вызвав fallback-функцию transfer() для перевода потраченных средств до того, как баланс будет установлен в 0 в функции withdraw().

Предотвращение reentrancy

При переводе средств в смарт-контракте используйте send или transfer вместо call. Проблема с использованием call заключается в том, что, в отличие от других функций, у нее нет ограничения по расходу газа в 2300. Это означает, что call может использоваться во внешних вызовах функций, которые могут быть использованы для выполнения атак повторного входа.

Другим надежным методом предотвращения является пометка ненадежных функций.

Кроме того, для оптимальной безопасности используйте шаблон checks-effects-interactions. Это простое эмпирическое правило для создания функций смарт-контракта.

Функция должна начинаться с проверок, например, операторов require и assert.

Далее должны быть выполнены последствия контракта, т.е. внесены изменения в состояние.

Наконец, мы можем выполнять взаимодействия с другими смарт-контрактами, например, вызовы внешних функций.

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

Поскольку баланс устанавливается равным 0 перед выполнением каких-либо взаимодействий, если контракт вызывается рекурсивно, после первой транзакции отправлять нечего.

Переполнение и недополнение целых чисел

В Solidity целочисленные типы имеют максимальные значения. Например:

uint8 => 255

uint16 => 65535

uint24 => 16777215

uint256 => (2^256) - 1

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

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

В старых контрактах часто использовалась библиотека SafeMath, чтобы избежать переполнения / недополнения, но в solidity >=v0.8.0 по умолчанию встроена безопасная математическая логика.

Зависимость от временной метки

Временная метка блока, доступ к которому осуществляется через now или block.timestamp может манипулироваться майнером. Есть три соображения, которые вы должны принять во внимание при использовании временной метки для выполнения функции.

Манипулирование временной меткой

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

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

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

Правило 15 секунд

Справочная спецификация Ethereum, Yellow Paper, не устанавливает ограничения на то, сколько блоков может измениться во времени, оно просто должно быть больше, чем временная метка его родительского элемента. При этом популярные реализации протокола отклоняют блоки с временными метками, превышающими 15 секунд, поэтому, пока ваше зависящее от времени событие может безопасно изменяться на 15 секунд, будет безопасным использовать временную метку блока.

Не используйте block.number в качестве временной метки

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

Авторизация через tx.origin

tx.origin — это глобальная переменная в Solidity, которая возвращает адрес, с которого была отправлена транзакция. Важно, чтобы вы никогда не использовали tx.origin для авторизации, поскольку другой контракт может использовать fallback функцию для вызова вашего контракта и получения авторизации, поскольку авторизованный адрес хранится в tx.origin.

Рассмотрим этот пример:

Здесь мы можем видеть, что контракт TxUserWallet проводит функцию transferTo() с помощью tx.origin.

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

Чтобы предотвратить такого рода атаки, используйте msg.sender для авторизации.

Плавающая pragma

Считается наилучшей практикой выбрать одну версию компилятора и придерживаться ее. С плавающей прагмой контракты могут случайно быть развернуты с использованием устаревшей или проблемной версии компилятора, что может привести к ошибкам, поставив под угрозу безопасность вашего смарт-контракта. Для проектов с открытым исходным кодом pragma также сообщает разработчикам, какую версию использовать, если они развернут ваш контракт. Выбранная версия компилятора должна быть тщательно протестирована и рассмотрена на предмет известных ошибок.

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

Видимость функции по умолчанию

Видимость функции может быть задана как public, private, internal или external. Важно учитывать, какая видимость лучше всего подходит для функциональности вашего смарт-контракта.

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

Устаревшая версия компилятора

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

Непроверенное возвращаемое значение вызова

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

В Solidity вы можете использовать вызовы низкого уровня, такие как: address.call(), address.callcode(), address.delegatecall() и address.send(); или вы можете использовать контрактные вызовы, такие как: ExternalContract.doSomething(). Вызовы низкого уровня никогда не будут выдавать исключение, вместо этого они вернут false, если столкнутся с исключением, в то время как вызовы контракта будут выбрасывать исключения автоматически.

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

Незащищенный вывод эфира

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

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

Незащищенная инструкция Selfdestruct

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

Эта атака была использована в атаке на Parity. Анонимный пользователь обнаружил и воспользовался уязвимостью в смарт-контракте library, сделав себя владельцем контракта. Затем злоумышленник приступил к самоуничтожению контракта. Это привело к блокировке средств на 587 уникальных кошельках, на которых в общей сложности было 513 774,16 эфира.

Видимость переменной состояния по умолчанию

Разработчикам свойственно явно объявлять видимость функции, но не так часто объявлять видимость переменной. Переменные состояния могут иметь один из трех идентификаторов видимости: public, internal или private. К счастью, видимость переменных по умолчанию internal, а не public, но даже если вы намереваетесь объявить переменную как internal, важно, чтобы не было неправильных предположений относительно того, кто может получить доступ к переменной.

Неинициализированный указатель хранилища

Данные хранятся в EVM в виде storage, memory или calldata. Важно, чтобы эти параметры были хорошо поняты и правильно инициализированы. Неправильная инициализация указателей на хранилище данных или простое оставление их неинициализированными может привести к уязвимостям контрактов.

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

Нарушение assert

В Solidity 0.4.10 появились следующие функции: assert(), require() и revert(). Мы обсудим функцию assert и то, как ее использовать.

Формально сказано, что функция assert() предназначена для утверждения инвариантов; неофициально сказано, что assert() - это чрезмерно напористый телохранитель, который защищает ваш контракт, но при этом крадет ваш газ. Должным образом функционирующие контракты никогда не должны приводить к сбою утверждения. Если вы достигли неудачного утверждения assert, вы либо неправильно использовали assert(), либо в вашем контракте есть ошибка, которая переводит его в недопустимое состояние.

Если условие, проверенное в assert(), на самом деле не является инвариантом, рекомендуется заменить его инструкцией require().

Использование устаревших функций

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

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

delegateCall ненадежному вызываемому абоненту

delegateCall — это особый вариант вызова с сообщением. Это почти идентично обычному вызову сообщения, за исключением того, что целевой адрес выполняется в контексте контракта вызова, а msg.sender и msg.value остаются неизменными. По сути, delegateCall делегирует другие контракты для изменения хранилища вызывающего контракта.

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

Податливость подписи

Часто люди предполагают, что использование системы криптографических подписей в смарт-контрактах подтверждает уникальность подписей, однако это не так. Подписи в Ethereum могут быть изменены без использования закрытого ключа и оставаться действительными.

Например, криптография с эллиптическим ключом состоит из трех переменных: v, r и s, и если эти значения изменены правильным образом, вы можете получить действительную подпись с неправильным закрытым ключом.

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