Всем привет! На связи Сергей Соболев, специалист по безопасности распределенных систем в 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.