Всем привет! На связи Сергей Соболев, специалист по безопасности распределенных систем в Positive Technologies, наша команда занимается аудитом смарт-контрактов. Сегодня я расскажу вам о результатах исследований и выводах нашей команды насчет аудита безопасности смарт-контрактов на языках FunC и Tact платформы TON.
С чего начать
Ни для кого не секрет, что блокчейн TON имеет большие отличия от привычных платформ индустрии. Первое, о чем хочется упомянуть, — способ обработки транзакций в TON. Все действия в блокчейне сопровождаются сообщениями, то есть по факту получается так, что каждая функция вашего смарт-контракта при выполнении будет обрабатывать какое-то сообщение и отправлять ответ или продолжать цепочку сообщений.
При этом сообщения выполняются асинхронно и независимо друг от друга, из-за чего транзакции, состоящие из сообщений между контрактами, могут обрабатывать несколько блоков, что приводит к задержкам. А самое неприятное — частичное исполнение транзакции, когда ваши токены списались, но до получателя не дошли, потому что что-то случилось, пока они шли, а во всем виноват программист, не уcледивший за всеми возможными вариантами и не добавивший обработчиков ошибок в контракт.
Так вот, первое, что нужно сделать, когда вы задумываетесь о безопасности смарт-контракта в TON, — отрисовать все цепочки сообщений и предположить, что каждое сообщение может завершиться неудачей. Тогда какими будут последствия? И из-за чего вообще они могут завершиться неудачей? Что будет, если не хватит газа на обработку всей цепочки? На все эти вопросы необходимо ответить.
К примеру, можно взять схему цепочки сообщений для перевода Jetton, стандартного контракта токена (TEP 74, контракт FunC). На схеме синие кружки — контракты, белые прямоугольники — сообщения. Красным прямоугольником выделено отскочившее сообщение, зеленым — необязательное сообщение, возможное, только если forward_ton_amount
не равен нулю, желтым — тело сообщения с избытком, которое отправляется только в том случае, если после оплаты остались монеты TON.
Если что-то произойдет при выполнении сообщения internal_transfer
, из баланса отправителя будет вычтена сумма перевода, но не будет зачислена получателю, это можно назвать частичным исполнением транзакции. С помощью обработчика отскочивших сообщений on_bounce
в контракте кошелька Jetton B можно вернуть списанные средства обратно.
Теперь, когда у нас есть общая картина того, как контракты обрабатывают сообщения, куда именно сообщения направляются и какие точки входа для хакера есть в контракте, можно углубляться в код.
Внимательно и пристально нужно проверять все входные параметры, все данные невозможно проверить, но можно отловить ошибки на стадии исследования входящего сообщения и данных. Правильно ли настроена авторизация входящих сообщений в контракте? Иногда программисты просто забывают ее добавить. Чаще всего используют обычный require
, где в условии проверяется адрес, он, в свою очередь, может быть вычислен из кода и данных, которые используются при развертывании контракта.
Например, в Jetton:
cell calculate_jetton_wallet_state_init( slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline { return begin_cell() .store_uint(0, 2) .store_dict(jetton_wallet_code) .store_dict(pack_jetton_wallet_data(0, owner_address, jetton_master_address, jetton_wallet_code)) .store_uint(0, 1) .end_cell(); } slice calculate_jetton_wallet_address(cell state_init) inline { return begin_cell().store_uint(4, 3) .store_int(workchain(), 8) .store_uint(cell_hash(state_init), 256) .end_cell() .begin_parse(); }
Функция calculate_jetton_wallet_state_init
формирует начальное состояние контракта, а функция calculate_jetton_wallet_address
вычисляет адрес контракта через хеширование начального состояния, которое получается из кода в jetton_wallet_code
и переменных, упакованных в ячейку через pack_jetton_wallet_data
.
В Tact все намного проще. На примере, который я взял отсюда, видно, что вычисление адреса происходит в одну строчку:
receive(msg: HiFromChild) { let expectedAddress: Address = contractAddress(initOf TodoChild(myAddress(), msg.fromSeqno)); require(sender() == expectedAddress, "Access denied"); // only the real children can get here }
Кроме этого, нельзя допустить случая, когда требования авторизации могут негативно сказаться на исполнении смарт-контрактов из-за излишней централизации, все это должно быть изначально заложено в дизайн бизнес-модели. Какие меры эта модель использует, чтобы предотвратить замораживание или удаление контрактов?
Нужно обращать внимание на обработку внешних сообщений (пришедших из интернета) функцией recv_external
(в смарт-контрактах на языке FunC): применяется ли функция accept_message()
только после всех надлежащих проверок. Это нужно, чтобы предотвратить атаки с высасыванием газа, так как после вызова accept_message()
за все дальнейшие операции платит контракт. Внешние сообщения не имеют контекста (например, sender
, value
), на обработку дается 10 000 единиц газа в кредит, что достаточно, чтобы проверить подпись и принять сообщение. Конечно, все зависит от дизайна контракта, но, если это возможно, имеет смысл писать контракт без возможности принимать внешние сообщения. Функция recv_external
является одной из входных точек, которую нужно проверить несколько раз.
Асинхронная природа блокчейна TON
После исследования всего кода можно вернуться к диаграммам и пройтись по ним еще раз, вспомнив несколько постулатов TON:
-
Сообщения гарантированно доставляются, но не в предсказуемые сроки.
-
Предсказать порядок сообщений можно только в том случае, если сообщения отправляются от одного контракта к другому контракту, в этом случае используется логическое время.
-
Если несколько сообщений отправляются разным контрактам, порядок их получения не гарантируется.
На рисунках ниже очень ясно поясняется работа с сообщениями.
Каждому сообщению присваивается свое логическое время, сообщение с меньшим логическим временем будет обработано раньше, поэтому можно опираться на последовательность обработки, однако если контрактов несколько, то неизвестно, какое сообщение будет получено раньше.
Предположим, у нас есть три контракта — A
, B
и C
. В транзакции контракт A
отправляет два внутренних сообщения — msg1
и msg2
, одно — контракту B
, другое — C
. Даже если они были созданы в точном порядке (msg1
, затем msg2
), мы не можем быть уверены, что msg1
будет обработано раньше msg2
. Для наглядности в документации сделали предположение, что контракты отправляют обратно сообщения msg1'
и msg2'
, после того как msg1
и msg2
были выполнены контрактами B
и C
. В результате к контракту A
будут идти две транзакции — tx2'
и tx1'
, поэтому получится два возможных варианта:
-
tx1'_lt < tx2'_lt
-
tx2'_lt < tx1'_lt
В обратную сторону работает точно так же, когда два контракта B
и C
отправляют сообщения контракту A
. Даже если сообщение от B
было отправлено раньше, чем от С
, нельзя знать, какое из них будет доставлено первым. В любом сценарии с более чем двумя контрактами порядок доставки сообщений может быть произвольным.
Так вот, исследуя диаграммы потоков сообщений, нужно ответить на следующие вопросы:
-
Что произойдет, если параллельно будет выполняться другой процесс?
-
Как это может повлиять на контракт и как это можно смягчить?
-
Могут ли какие-либо требуемые значения изменяться, пока исполняется цепочка сообщений?
-
От каких параметров или состояний других контрактов зависит этот контракт?
-
Насколько операции зависят от последовательности поступления сообщений?
Всегда нужно ожидать появления посредников во время обработки сообщений. То есть, если в начале проверялось какое-то свойство контракта, не стоит полагать, что на третьем этапе он будет по-прежнему проходить проверку по этому свойству. По большей части от всего этого защищает паттерн carry-value. Надлежащим ли образом он используется для управления состоянием между сообщениями?
Общие ошибки в TON
Пожалуй, можно начать с самого очевидного: не отправляйте приватные данные в блокчейн (пароли, ключи и так далее). Блокчейн публичный, и все данные можно будет получить.
Нельзя забывать про обработку отскочивших сообщений, если такой обработки не было бы в Jetton, то можно было бы отправлять токены в пустоту. Так, в проекте TON Stablecoin есть обработка отскочившего сообщения op::internal_transfer
, которое отправляется на Jetton-кошелек при чеканке токенов. Если обработки не будет, то при чеканке total_supply
увеличится и будет неактуален, так как токены на кошелек не поступили и они не могут быть в обращении.
Часто встречаются ошибки в формулах, алгоритмах и структурах данных. Например, если выполнить сначала деление, а потом умножение, можно потерять точность вычислений и получить ошибку округления:
let x: Int = 40; let y: Int = 20; let z: Int = 100; // 40 / 100 * 20 = 0 let result: Int = x / z * y; // 40 * 20 / 100 = 8 let result: Int = a * c / b;
Кроме того, в коде могут быть самые стандартные ошибки, среди которых:
-
Дублирование кода.
-
Недостижимый код.
-
Неэффективные алгоритмы.
-
Неудачный порядок выражений в условных операторах.
-
Логические ошибки.
-
Ошибки в парсинге данных.
В TON возможна атака повторного воспроизведения. Она возникает из-за того, что в TON отсутствует понятие одноразовых номеров у адреса (как nonce
в Ethereum), которые позволяют делать уникальные подписи. Эта концепция добавлена в стандартные кошельки, которыми мы пользуемся для хранения и перевода TON. То есть в контракт кошелька приходит внешнее сообщение с подписью, которая проверяется, также проверяется присланный seqno
(аналог nonce
в Ethereum), сохраненный в хранилище контракта. Ниже представлен листинг проверок, после которых стандартный кошелек принимает сообщение:
throw_unless(33, msg_seqno == stored_seqno); throw_unless(34, subwallet_id == stored_subwallet); throw_unless(35, check_signature(slice_hash(in_msg), signature, public_key)); accept_message();
Хорошей практикой является следование паттерну carry-value, который подразумевает, что передается значение, а не сообщение.
Например, в Jetton:
-
Отправитель вычитает
amount
из своего баланса и отправляет его сop::internal_transfer
. -
Получатель принимает
amount
через сообщение и добавляет его к собственному балансу (или отклоняет).
То есть в TON невозможно получить актуальные данные через запрос, так как к тому моменту, когда ответ дойдет до запрашивающего, данные могут быть уже неактуальными. Поэтому в Jetton нельзя получить баланс ончейн, так как, пока идет ответ, остаток баланса уже может быть кем-то потрачен.
Альтернативный вариант:
-
Отправитель запросит через мастер-контракт баланс Jetton-кошелька.
-
Кошелек обнулит баланс и отправит его в мастер-контракт.
-
Мастер-контракт, получив средства, решает, достаточно ли их, и либо использует их (отправляет куда-нибудь), либо возвращает кошельку отправителя.
Примерно так можно получить баланс. Похожую схему можно применить и для всех остальных данных.
Следите за тем, как происходит чтение и запись в ячейках, потому что из-за невнимательности можно получить ошибку. Проблема переполнения возникает, когда пользователь пытается сохранить в ячейке больше данных, чем она поддерживает. Текущее ограничение составляет 1023 бита и 4 ссылки на другие ячейки. При превышении этих ограничений контракт возвращает ошибку с кодом выхода 8 во время фазы вычисления.
Проблема недополнения возникает, когда пользователь пытается получить из структуры больше данных, чем она поддерживает. Когда это происходит, контракт возвращает ошибку с кодом выхода 9 во время фазы вычисления.
Прочитать про коды выхода можно тут.
// storeRef используется больше, чем 4 раза beginCell() .storeRef(...) .storeAddress(myAddress()) .storeRef(...) .storeRef(...) .storeRef(...) .storeRef(...) .endCell()
Генерация случайных чисел в TON
Как и в EVM-подобных блокчейнах, валидаторы могут влиять на случайность, а хакеры могут вычислить формулу формирования случайности. Поэтому нужно подходить с умом к коду, которому она требуется.
В FunC есть функция random()
, которую без дополнительных функций использовать нельзя. Чтобы добавить непредсказуемости при генерации числа, можно использовать функцию randomize_lt()
, которая добавит к начальному значению текущее логическое время, что приведет к тому, что разные транзакции будут иметь разные результаты. Кроме того, можно использовать randomize(x)
, где x
— 256-битное целое число, проще говоря, хеш каких-либо данных.
Использование в Tact nativeRandom
и nativeRandomInterval
не самая лучшая идея, так как они не инициализируют генератор случайных чисел с помощью nativePrepareRandom
заранее. В Tact используется randomInt
или random
соответственно.
Проблемы с отправкой сообщений
Каждый контракт принимает сообщения и либо продолжает цепочку отправки, либо отвечает на них. Нужно быть уверенным, что сообщение сформировано правильно, то есть все ключи и магические числа (флаги, режимы работы и остальные параметры) при формировании сообщения соответствуют логике контракта и не приводят к чрезмерному истощению его баланса. Особенно это важно в отношении платежей за хранение: в TON нужно платить газ за каждую секунду, пока смарт-контракт хранится в блокчейне. Однако не нужно копить на адресе контракта весь непотраченный газ, лучше правильно продумать логику возврата излишков отправителю, когда это необходимо. Важно помнить, что исчерпание газа приводит к частичному выполнению транзакций, из-за чего могут возникнуть критически опасные проблемы.
Например, если убрать обработчик отскочивших сообщений в контракте Jetton-кошелька, то при ошибке во время перевода токенов (произошло исключение) этот и последующие шаги не будут выполнены и списанные токены не получится восстановить, они просто сгорят. Транзакция выполнится частично. Так можно сказать и про мастер-контракт в TON Stablecoin. Во время чеканки токенов total_supply
увеличивается, однако если сообщение отскочет, а обработчика не будет, то total_supply
будет неправильным, так как будут учтены лишние токены, которых нет в обращении:
if (msg_flags & 1) { in_msg_body~skip_bounced_prefix(); ;; обрабатывается только отскоки сообщения mint ifnot (in_msg_body~load_op() == op::internal_transfer) { return (); } in_msg_body~skip_query_id(); int jetton_amount = in_msg_body~load_coins(); (int total_supply, slice admin_address, slice next_admin_address, cell jetton_wallet_code, cell metadata_uri) = load_data(); ;; тут вычитается сумма перевода из общего предложения save_data(total_supply - jetton_amount, admin_address, next_admin_address, jetton_wallet_code, metadata_uri); return (); }
Лучше всего понять способы формирования сообщений вам поможет документация. Возможны случаи неправильного формирования режимов сообщений. Ну и на всякий случай нужно проверить, точно ли программист указал побитовый оператор ИЛИ
.
Например, в Tact:
// Флаг дублируется send(SendParameters{ to: recipient, value: amount, mode: SendRemainingBalance | SendRemainingBalance });
Опасной практикой может быть отправка сообщений из цикла:
-
Цикл может оказаться бесконечным или иметь слишком много итераций, что может привести к непредсказуемому поведению контракта.
-
Непрерывное зацикливание без завершения может привести к атаке типа out-of-gas.
-
Злоумышленники могут использовать неограниченные циклы для создания атак типа «отказ в обслуживании».
Особое внимание следует уделить функции отправки сообщений. В стандартной библиотеке Tact есть функция nativeSendMessage
, которая является низкоуровневым аналогом функции send
. Используя nativeSendMessage
при формировании сообщения можно будет ошибиться, поэтому лучше ее применять только в том случае, если в контракте есть сложная логика, которую нельзя выразить иным способом.
Управление хранилищем данных
Блокчейн TON не приветствует бесконечные структуры данных. В Solidity есть стандарт ERC-20, это один контракт с отображением адрес → баланс. В TON аналогом ERC-20 является Jetton, и по итогу это целая система контрактов. Стандарт Jetton состоит из двух контрактов — jetton-minter.fc и jetton-wallet.fc. Для каждого адреса создается свой контракт wallet с возможностью отправлять и принимать токены, а контракт minter представляет собой главный контракт с метаданными и дает возможность чеканить токены или сжигать их.
Такая схема обусловлена устройством хранилища в блокчейне TON, которое представляет собой деревья ячеек. Дерево ячеек не позволяет создавать отображения со сложностью O(1)
, и получается, что размер отображения влияет на количество потраченного газа: чем оно больше, тем больше газа нужно на поиск. Поэтому следует оценивать все отображения и проверять, есть ли возможность удаления данных из него (например, через .del
). Отсутствие способа очистки или удаления записей может привести к неконтролируемому росту хранилища.
Итак, не раздуваем хранилище контракта и все? Нет, сейчас информация больше для FunC, чем Tact, так как в FunC разработчик вручную управляет хранилищем. Обязательно нужно проверять правильность порядка расположения переменных при упаковке в хранилище. Может случиться так, что какая-то переменная окажется на месте другой, тогда нарушится логика контракта. Возможен и другой сценарий: переменные состояния могут быть перезаписаны из-за коллизий имен переменных или загрязнения пространства имен.
Почти все сообщения обрабатываются по такому шаблону:
() function(...) impure { (int var1, var2, <a lot of vars>) = load_data(); ... ;; логика обработчика save_data(var1, var2, <a lot of vars>); }
К сожалению, существует тенденция, когда <a lot of vars>
является простым перебором всех полей данных контракта, а их может быть очень много. Поэтому при добавлении нового поля в хранилище потребуется обновить все вызовы load_data()
и save_data()
, что может оказаться трудоемкой задачей. И по итогу может получиться так:
save_data(var2, var1, <a lot of vars>);
Кроме того, не стоит пренебрегать таким свойством FunC, как затенение переменных. В общем смысле это возможно, когда переменная во внутренней области видимости объявляется с таким же именем, с каким уже существует переменная во внешней области видимости. Соответственно, существует вероятность того, что локальная переменная попадет в хранилище, это возможно из-за повторного объявления переменных в FunC:
int x = 2; int y = x + 1; int x = 3; ;; эквивалентно присваиванию x = 3
Если задуматься об эффективности, то хорошей практикой будет вложенное хранилище, но в работе с ним тоже есть высокая вероятность ошибки. Вложенное хранилище представляет собой переменные внутри переменных, которые распаковываются только в нужный момент, а не каждый раз при обработке сообщений:
()function(...) impure { (slice mint, cell burn, cell swap) = load_data(); (int total_supply, int amount) = mint.parse_mint_data(); … mint = pack_mint_data(total_supply + value, amount); save_data(mint, burn, swap); }
Используйте end_parse()
при парсинге хранилища или полезной нагрузки сообщения везде, где это возможно, чтобы обеспечить правильность обработки данных, таким образом можно убедиться, что не осталось непрочитанных данных. В Tact функция endParse
возвращает исключение с кодом 9 (cell underflow), в отличие от empty
, которая возвращает true
или false
.
В Tact есть возможность добавления в хранилище контракта опционального значения переменной, то есть используется специальное значение null
. Если разработчик создает опциональную переменную или поле, он должен использовать ее функциональность, обращаясь к значению null
где-то в коде. В противном случае опциональный тип следует удалить, чтобы упростить и оптимизировать код:
contract Simple { a: Int?; get fun getA(): Int { return self.a!!; } }
Проблемы с обновлением кода
Это одна из самых удобных фишек платформы, однако в исходном коде должна быть добавлена возможность обновления, но она может негативно сказаться на децентрализации протокола. Представьте, что создатели протокола однажды подменят код-контракт или кто-то взломает их мультиподпись и изменит протокол. Важно, что обновление кода не влияет на текущую транзакцию, изменения вступают в силу только после успешного завершения выполнения.
Функции set_code
и set_data
используются для обновления регистров с3
и с4
соответственно, set_data
полностью перезаписывает регистр. Перед обновлением в FunC нужно проверить, не нарушает ли код существующую логику хранения данных — следите за коллизиями хранилища и упаковкой переменных.
В Tact на момент написания статьи нет возможности обновлять контракты, однако для этого можно использовать trait Upgradable
из форка библиотеки Ton-Dynasty (аналог библиотеки OpenZeppelin для Solidity). Там используются функции из FunC, однако обязательно нужно позаботиться о миграции хранилища, если добавляется новая переменная.
Общие тезисы по безопасной разработке и аудиту
-
Для того чтобы не путаться с флагами и режимами сообщений, добавляйте константы — обертки для численных литералов. Так код станет яснее и читабельнее.
-
Проверьте, что все отскочившие сообщения обрабатываются.
-
Тщательно рассчитывайте расходы на газ и проверяйте, достаточно ли газа для работы и хранения контракта в блокчейне.
-
Будьте осторожны со структурами данных, которые могут расти бесконечно, так как со временем они увеличивают расходы на газ.
-
Проверьте, не объявляются или не инициализируются ли переменные дважды.
-
Сохраняйте логику контракта автономной и избегайте включения недоверенного внешнего кода (Пример из документации):
а) Выполнение стороннего кода небезопасно, так как исключения out of gas не могут быть пойманы с помощью
CATCH
.б) Злоумышленник может использовать
COMMIT
для изменения состояния контракта перед тем, как поднять исключение out of gas. -
Для экономии газа обращайте внимание на порядок логических выражений в
if
илиrequire
, размещение константных или более дешевых условий первыми может предотвратить ненужное выполнение дорогостоящих операций. -
Очень много ошибок допускается при обработке данных, то есть в формулах и алгоритмах, поэтому нужно проверять все.
-
Ищите логические лазейки, которые могут быть использованы.
-
Оцените возможность атак повторного воспроизведения.
-
Используйте уникальные префиксы или модули для предотвращения коллизий имен переменных.
-
Оформляйте четкую и подробную документацию по функциональности и проектным решениям контракта.
-
Поручите проверку кода контракта независимым аудиторам, чтобы выявить потенциальные проблемы.
-
Убедитесь, что контракт соответствует стандартам и лучшим практикам TON.
Следуя этому контрольному списку, вы сможете систематически оценивать безопасность и надежность смарт-контрактов TON, выявляя потенциальные уязвимости и обеспечивая надежную работу в экосистеме TON.
Наша команда проводит аудиты смарт-контрактов разных блокчейн-платформ, эл. почта для связи: audit@positive.com.
ссылка на оригинал статьи https://habr.com/ru/articles/873654/
Добавить комментарий