В этой статье расскажем про основные уязвимости смарт-контрактов, которые описывает Проект по безопасности децентрализованных приложений. В некоторых примерах будем давать ссылки на англоязычные статьи, где можно посмотреть примеры кода.
Каждый может внести свой вклад в безопасность экосистемы и работать совместно с DASP через GithHub.
Специфика смарт-контрактов
Одним из самых больших преимуществ блокчейна является то, что он может действовать как децентрализованная система, которая не предполагает посредников. Смарт-контракты рекламируются как нечто, что может заменить третью сторону. Идея в использовании контрактов, которые хранятся в цепочке и могут автоматически активироваться, если выполняются определенные условия.
Смарт-контракты могут безопасно храниться на множестве компьютеров, они доступны всем сторонам через децентрализованную систему. Любые попытки изменить контракт одной стороной отклоняются, все заинтересованные стороны будут автоматически проинформированы об этом. Они просты тогда, когда инфраструктура возведена и эффективно функционирует.
Из-за этого смарт-контракты, как правило, быстрее и удобнее, поэтому все чаще люди подключаются к блокчейну для оптимизации своих рабочих процессов. Это экономит деньги, ведь нет необходимости платить гаранту.
Но в текущих реализациях часто есть ошибки, которые могут привести к фатальным последствиям. Отдавая все на автоматизацию, компания должна быть максимально уверена в реализации.
Самыми популярными и значимыми были такие уязвимости:
- в The DAO,
- FirePonzi,
- казино с публичным RNG-сидом,
- когда застряло 1100 ETH из-за нехватки газа,
- игра The King of the Ether,
- 5800 ETH вывели из токенов ERC-20 (атака делалась этичными хакерами),
- Rubixi.
В официальном блоге Эфириума эти ошибки классифицируют таким образом:
- Смешивание имен переменных/функций,
- Публичные данные, которые не должны были быть общедоступными,
- Reentrancy (вызов B, вызывающий A),
- Перевод не выполнен из-за ограничения 2300 газа,
- Лимит газа,
- И другие гораздо более тонкие теоретические уязвимости.
Повторный вход/Reentrancy
Также эта атака известна как:
- Race-To-Empty,
- recursive call vulnerability (рекурсивный вызов),
- call to the unknown (призыв к неизвестному).
Эксплойт наиболее сильно потряс всех, став самым известным, поскольку он стал причиной краха DAO. Эта уязвимость в смарт-контракте часто не замечается рецензентами, которые, как правило, просматривают функции по одной.
Атака Reentrancy впервые осуществилась во время многомиллионной кражи, которая привела к хардфорку Ethereum. Reentrancy возникает, когда внешним вызовам разрешено совершать новые вызовы до завершения первоначального исполнения. Для функции это означает, что состояние контракта может измениться в середине его выполнения в результате вызова ненадежного контракта или использования функции низкого уровня с внешним адресом.
Убыток от этой уязвимости оценивается в 3.5M ETH (~ 50 миллионов долларов на то время).
Пример:
- Умный контракт отслеживает баланс нескольких внешних адресов и позволяет извлекать средства с помощью функции withdraw().
- В мошенническом смарт-контракте используется функция withdraw() для извлечения всего баланса.
- Контракт жертвы выполняет функцию низкого уровня — call.value(amount)() — для отправки ETH во вредоносный контракт до обновления баланса вредоносного контракта.
- В мошенническом контракте есть функция возврата fallback(), которая принимает средства, а затем возвращает их обратно в функцию withdraw().
- Это второе исполнение инициирует перевод средств: помните, что баланс вредоносного контракта по-прежнему не обновлялся с момента первого вывода. В результате вредоносный контракт во второй раз успешно отменяет весь баланс.
Следующая функция содержит функцию msg.sender, уязвимую для атаки повторного вызова. Когда функция низкого уровня call() посылает Эфир на адрес msg.sender, он становится уязвимым. Если адрес является смарт-контрактом, платеж вызовет резервную функцию fallback () с тем, что осталось от транзакционного газа:
function withdraw(uint _amount) { require(balances[msg.sender] >= _amount); msg.sender.call.value(_amount)(); balances[msg.sender] -= _amount; }
Таким образом, пользователь получит в два раза больше своего баланса из контракта.
Как избежать этого:
- transfer () и send () безопасны для повторных атак, поскольку они ограничивают исполнение кода до 2300 газа, в настоящее время достаточно для регистрации и действия.
- Если вам не избежать функции вызова call(), нужно выполнить внутреннюю работу (например, смену баланса) перед использованием внешнего вызова.
- В общем, имейте в виду, что любая функция, выполняющая внешний код, представляет собой угрозу.
Что еще почитать по теме:
- Код DAO,
- Объяснение, как были украдены средства DAO,
- Объяснение проблемы на примере кода и как ее решить.
Контроль доступа
Можно было превратить контракт библиотеки Parity Wallet в обычный кошелек с несколькими подписями и стать его владельцем, вызвав функцию initWallet.
Проблемы контроля доступа распространены во всех программах, а не только с смарт-контрактах. Фактически, это номер 5 в топ-10 OWASP. Обычно вы получаете доступ к функциям контракта через публичные или внешние функции.
Уязвимости могут возникать, когда контракты используют устаревший tx.origin для проверки обращающихся (callers), обработки большой логики авторизации и использовании делегирования в прокси-библиотеках или прокси-контрактах.
Убыток оценивается в 150 000 ETH (~ 30 миллионов долларов в то время)
Проблема всплыла с Parity. Пользователь под ником Devops199 превратил проблемную библиотеку в кошелек, стал его владельцем, выполнил команду «самоуничтожение контракта», и в итоге заблокировал средства множества других кошельков. Кстати, сам Devops199 был новичком, а не хакером. По крайней мере, он так утверждает.
Другой случай состоялся с пирамидой Rubixi. Сборы были украдены, поскольку функция конструктора имела неправильное имя, позволяя любому стать владельцем.
Пример:
- Смарт-контракт обозначает адрес, инициализированный как его владелец. Это дает адресу возможность снять средства с контракта.
- Но функция инициализации может быть вызвана любым. Это позволяет стать владельцем контракта и забрать из него средства.
Не отслеживается тот факт, что владелец контракта уже вызван.
function initContract() public { owner = msg.sender; }
В кошельке Parity эта функция инициализации была отделена от самих кошельков и определена в контракте из библиотеки. К сожалению, функция не проверила, был ли кошелек уже инициализирован. Хуже того, поскольку библиотека была смарт-контрактом, любой мог инициализировать библиотеку и запросить ее уничтожение.
Что исследовать:
Arithmetic Issues
Уязвимость компьютерной арифметики также известна как целочисленное переполнение через нижнюю границу.
Это дает неверные результаты, и, если такая возможность не ожидалась, это угрожает безопасности программы.
Изначально peckshield (компания, которая занимается блокчейн-безопасностью) заметили странное поведение смарт-контракта:
Эта аномалия побудила заглянуть в код. Это происходит от атаки «in-the-wild», которая использует ранее неизвестную уязвимость в контракте. Агентство назвало эту уязвимость batchOverflow. Она представляет собой проблему с переполнением целочисленного типа.
Уязвимая функция находится в batchTransfer:Как указано в строке 257, локальная переменная суммы вычисляется как произведение cnt и _value. Второй параметр, т. е., _value, может быть произвольным 256-битным целым числом, например 0x8000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000 (63 нуля). Имея два _receivers, переданных в batchTransfer (), с этим чрезвычайно большим значением, можно переполнить сумму и сделать ее равной нулю. При обнулении суммы злоумышленник затем:
- проходит проверки на работоспособность в строках 258-259,
- делает вычитание в строке 261 неактуальным,
- в строках 262-265, баланс будет чрезвычайно большим, и для этого не потребуется ни копейки.
При анализе другого кода, более десятка смарт-котрактов ERC20 также уязвимы для batchOverflow.
- Функция смарт-контракта withdraw() позволяет выводить ether, отправленный в контракт, пока баланс остается положительным после проведения операции.
- Атакующий пытается вывести больше, чем его текущий баланс,
- Результат проверки всегда имеет положительное количество, это позволяет атакующему выводить больше, чем есть. А в результате баланс отображается огромным числом.
Что исследовать:
Непроверенные возвращаемые значения для вызовов низкого уровня
Также известна как непроверенная отправка, silent failing sends.
Следует избегать «call» низкого уровня, когда это возможно. Это может привести к неожиданному поведению, если возвращаемые значения не обрабатываются правильно.
Особенность Solidity — функции низкого уровня call(), callcode(), delegatecall() и send(). Их поведение в случае ошибок сильно отличается. Они возвращают значение false, а код продолжает выполнение. Если возвращаемое значение не будет проверено, это может привести к сбоям и другим нежелательным результатам.
Впервые проблема возникла в игре King of Ether. Эта атака произошла из-за неправильного распределения газа.
Итак, «Король Эфирного Трона» (KotET) — это игра, в которой участники соревновались друг с другом за титул «Король эфира». Как это работало:
- Предположим, что текущая цена за трон составляет 10 эфиров.
- Вы хотите быть королем / королевой, поэтому вы отправляете 10 эфиров.
- Контракт отправляет 10 ETH (менее 1% комиссии) предыдущему королю качестве компенсации.
- Контракт делает вас новым Королем / Королевой Эфирного Трона.
- В этом случае новая цена за трон увеличивается на 50%, до 15 ETH.
- Если приходит тот, кто готов заплатить 15 эфиров, он свергает вас, но вы получаете свою выплату в 15 эфиров.
Проще говоря, это была пирамида.
Так что же случилось с KotET?
Контракты KotET работали нормально, за исключением одного сценария. Когда контракт отправил платеж на «контрактную учетную запись», он выделил лишь небольшое количество газа, в 2300 единиц. Этого недостаточно для покрытия расходов.
Когда платеж не обработался, уплаченный эфир был возвращен в контракт KotET. Организация не знала, что платеж не прошел и продолжал обработку.
function withdraw(uint256 _amount) public { require(balances[msg.sender] >= _amount); balances[msg.sender] -= _amount; etherLeft -= _amount; msg.sender.send(_amount); }
Когда программист забывает проверить возвращаемое значение send () EVM (виртуальная машина эфириума) заменит возвращаемое значение на false. Но изменения функции в состоянии контракта не будут отменены, а переменная etherLeft закончит отслеживание некорректного значения.
Что исследовать:
Denial of Service
Отказ в обслуживании также включает в себя достижение предела газа, нарушение контроля доступа, неожиданный крах.
Отказ в обслуживании смертелен в мире Ethereum: в то время как другие типы приложений могут в итоге восстановиться, смарт-контракты могут быть отключены навсегда всего лишь одной из этих атак. Многие способы приводят к отказу в обслуживании, в том числе к вредоносному поведению. Этот класс атаки включает в себя множество различных вариантов.
Убыток оценивается в 514 874 ETH (~ 300 миллионов долларов в то время).
Впервые случилось с GovernMental и описанным выше багом в Parity (когда пользователь «убил» смарт-контракт).
GovernMental был популярной пирамидой на блокчейне. Игра была незамысловата: есть таймер, когда он достигает нуля, весь джекпот получает кто-то один.
Собственно, проблема в том, что участников было очень много. Для покрытия всех расходов нужна сумма газа 5057945, но там было заложено всего 4712388.
И PonziGovernment застрял, 1100 ETH находятся в подвешенном состоянии.
Также возможна намеренная атака. Например:
Пример:
- Аукционный контракт позволяет участвовать в торгах по различным активам.
- Для участия в торгах пользователь вызывает функцию bid (uint object) с нужным количеством эфира. Аукционный контракт будет хранить эфир до тех пор, пока владелец объекта не примет заявку или претендент не отменит ее. То есть, в контракте лежит вся стоимость заявки.
- Аукционный контракт также содержит функцию отзыва, которая позволяет администраторам получать средства из договора. Она общедоступна.
- Злоумышленник вызывает функцию, направляя все средства контракта своим администраторам. Это разрушает возможность хранить эфир и блокирует все ожидающие ставки. То есть, происходит отказ в обслуживании.
- Пока админы могут вернуть депонированные деньги на контракт, злоумышленник может продолжить атаку, снова выводя средства.
Bad Randomness
Также уязвимость известна под названием nothing is secret (ничего в секрете).
Случай впервые обнаружился в лотерее SmartBillions.
Смарт-контракт использует номер блока как источник случайности для игры. Также приватный сид используется в сочетании с номером и хэш-функцией keccak256, чтобы определить, победит ли пользователь. Несмотря на то, что сид является закрытым, он должен быть установлен через транзакцию, поэтому виден в блокчейне.
Вот так делается псевдослучайность:
// Returns a pseudo Random number. function generateRand() private returns (uint) { // Seeds privSeed = (privSeed*3 + 1) / 2; privSeed = privSeed % 10**9; uint number = block.number; // ~ 10**5 ; 60000 uint diff = block.difficulty; // ~ 2 Tera = 2*10**12; 1731430114620 uint time = block.timestamp; // ~ 2 Giga = 2*10**9; 1439147273 uint gas = block.gaslimit; // ~ 3 Mega = 3*10**6 // Rand Number in Percent uint total = privSeed + number + diff + time + gas; uint rand = total % 37; return rand; }
В результате длительных операций (много кода с объяснением), злоумышленник может вручную извлечь приватный сид.
Также есть возможность рандомного подбора и вызова выигрышного контракта. В этой статье описаны уязвимости генераторов псевдослучайных чисел.
Вот еще несколько уязвимостей:
- TOCTOU (была с Банкором и некоторыми токенам ERC-20), когда транзакцию буквально можно обогнать: отправить с большей комиссией, и майнеры охотнее возьмут транзакцию в блок. Подробнее про ситуацию с Bancor (на англ).
- Зависимость от временной метки, когда майнер может манипулировать блоком.
- Короткие адреса: EVM не проверяет правильность введенных аргументов. Из-за короткого адреса в некоторых случаях все данные дальше могут сдвинуться, это меняет также сумму.
Собственно, из-за обилия уязвимостей и неочевидных мест писать качественные смарт-контракты может не каждый. Особенно проблема усугубляется тем, что код часто связан с деньгами и передачей ценности, что привлекает злоумышленников.
Это лишь некоторые уязвимости и возможные атаки: какие-то раскрыты, но не афишированы, какие-то только предстоит найти. Надеемся, что после этой статьи вы будете серьезнее относиться к кодовой базе: будучи и разработчиком, и пользователем.