Как проверить код канистр в Интернете Компьютер, написанный на языке программирования Motoko.

Иоахим Брайтнер

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

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



Ориентация IC | Internet Computer Home
Категория «Документы для разработчиков
содержит всю информацию, которая вам понадобится для начала работы и продвижения в создании приложений…internetcomputer.org»



Межканальные вызовы

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

  • Чтение глобального состояния перед выполнением вызова между накопителями и предположение, что оно все еще сохраняется, когда вызов возвращается.
  • Изменение глобального состояния перед вызовом между накопителями, повторное изменение его в обработчике ответа, но при условии, что ничего не меняет промежуточное состояние (повторный вход).
  • Изменение глобального состояния перед выполнением вызова между блоками и неправильная обработка сбоев, например, когда код, обрабатывающий обратный вызов, откатывается.

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

Эти проблемы относятся ко всем канистрам и не относятся к Motoko.

Откаты

Даже при отсутствии вызовов между контейнерами поведение откатов может удивить. В частности, отклонение (т. е. throw) не отменяет сделанные ранее изменения состояния, в то время как перехват (например, Debug.trap, assert …, вне цикла) делает это.

Поэтому следует проверять все общедоступные точки входа вызова обновления на наличие нежелательных изменений состояния или нежелательных откатов. В частности, ищите методы (точнее, сообщения, т. е. код между точками фиксации), в которых за изменением состояния следует throw.

Эти проблемы относятся ко всем канистрам и не относятся к Motoko, хотя другие CDK могут не превращать исключения в отказы (которые не откатываются).

Разговор с вредоносными канистрами

Разговаривать с ненадежными канистрами может быть рискованно по следующим (вероятно, неполным) причинам:

  • Другой контейнер может воздержаться от ответа. Хотя парадигма двунаправленного обмена сообщениями Интернет-компьютера была разработана, чтобы гарантировать ответ в конечном счете, другая сторона может находиться в цикле занятости до тех пор, пока она готова платить, прежде чем ответить. Хуже того, есть способы заблокировать канистру.
  • Другой контейнер может ответить неверно закодированным Candid. Это приведет к тому, что контейнер, реализованный в Motoko, застрянет в обработчике ответа, и восстановить его будет непросто. Другие CDK могут предоставить вам лучшие способы обработки недействительного Candid, но даже в этом случае вам придется беспокоиться о циклических бомбах Candid, которые вызовут ловушку вашего обработчика ответов.

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



Модернизация канистры: обзор

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

  1. Можно ли вообще апгрейдить канистру?
  2. Сохранит ли обновление контейнера все данные?
  3. Можно ли быстро обновить канистру?
  4. Существует ли план восстановления на случай, если обновление невозможно?

Возможность модернизации канистры

Контейнер, который по какой-либо причине попал в ловушку в своем canister_preupgrade системном методе, больше не подлежит обновлению. Это большой риск. Метод canister_preupgrade контейнера Motoko состоит из написанного разработчиком кода в любом блоке system func preupgrade(), за которым следует сгенерированный системой код, который сериализует содержимое любого stable var в двоичный формат, а затем копирует его в стабильную память.

Поскольку внутренний код сериализации Motoko сначала сериализуется в пустое пространство в основной куче, а затем копируется в стабильную память, канистры с более чем 2 ГБ оперативных данных, скорее всего, не будут обновляться. Но вряд ли это первый предел:

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

Этот риск не может быть устранен полностью, пока используются Motoko и Stable Variables. Его можно уменьшить с помощью соответствующего нагрузочного тестирования:

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

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

Альтернативное смягчение — максимально избегать canister_pre_upgrade. Это означает, что stable var не используется (или ограничивается небольшими данными конфигурации фиксированного размера). Все остальные данные могут быть

  • зеркально отражено от канистры (возможно, вне цепи) и повторно гидратировано вручную после обновления.
  • хранится в стабильной памяти вручную, во время каждого вызова обновления, с использованием ExperimentalStableMemory API. Хотя это соответствует тому, что делают высоконадежные контейнеры Rust (например, Internet Identity), это требует ручного двоичного кодирования данных и помечено как экспериментальное, поэтому я не могу рекомендовать это на данный момент.
  • не помещать в канистру Motoko до тех пор, пока у Motoko не будет масштабируемого решения для стабильных переменных (например, постоянное хранение их в стабильной памяти с интеллектуальным кэшированием в основной памяти и, таким образом, устранение необходимости в коде перед обновлением).

Сохранение данных при обновлениях

Очевидно, что все оперативные данные должны быть сохранены во время обновлений. Motoko автоматически обеспечивает это для stable var данных. Но часто контейнеры хотят работать со своими данными в другом формате (например, в объектах, которые не являются shared и, следовательно, не могут быть помещены в stable vars, например объекты HashMap или Buffer), и поэтому могут следовать следующей идиоме:

stable var fooStable = …;
var foo = fooFromStable(fooStable);
system func preupgrade() { fooStable := fooToStable(foo); })
system func postupgrade() { fooStable := (empty); })

В этом случае важно проверить, что

  • Все нестабильные глобальные var или глобальные let с изменяемыми значениями имеют стабильный компаньон.
  • Присвоения foo и fooStable не забыты.
  • fooToStable и fooFromStable образуют биекции.

Например, HashMaps хранится в виде массивов через Iter.toArray(….entries()) и HashMap.fromIter(….vals()).

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

Быстрые обновления

Кассеты Motoko и Rust не могут быть безопасно обновлены, пока они все еще ожидают ответов на вызовы между канистрами (обратный вызов в конечном итоге достигнет нового экземпляра, и из-за ошибок системного API IC, возможно, вызовет произвольные внутренние функции). Поэтому канистру необходимо остановить перед обновлением и запустить заново. Если вызовы между накопителями занимают много времени, это означает, что обновление может занять много времени, что может быть нежелательно. Опять же, этот риск снижается, если все вызовы выполняются на заслуживающие доверия накопители, и повышается, когда прямо или косвенно вызываются, возможно, ненадежные накопители.

Резервное копирование и восстановление

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

Обратите внимание, что reinstall имеет ту же проблему, что и upgrade, описанную выше в разделе «Быстрые обновления»: его следует сначала остановить, чтобы быть в безопасности.

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

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



Время не является строго монотонным

Временные метки для «текущего времени», которые Интернет-компьютер предоставляет своим накопителям, гарантированно будут монотонными, но не строго монотонными. Он может возвращать одни и те же значения даже в одних и тех же сообщениях, если они обрабатываются в одном блоке. Поэтому его не следует использовать для обнаружения отношений «произошло до».

Вместо использования и сравнения меток времени для проверки того, было ли Y выполнено после того, как X произошло последним, введите явное состояние var y_done : Bool, которое устанавливается на False по X, а затем на True по Y. Когда все станет сложнее, будет легче моделируйте состояние с помощью перечисления с говорящими именами тегов и попутно обновляйте этот «конечный автомат».

Другим решением этой проблемы является введение счетчика var v : Nat, который вы сбрасываете при каждом методе обновления и после каждого await. Теперь v — это счетчик состояния вашего контейнера, и его можно использовать как метку времени разными способами.

Пока мы говорим о времени: системное время (обычно) меняется в await. Так что, если вы сделаете let now = Time.now(), а затем await, значение в now может больше не соответствовать вам.

Обертывание арифметики

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

Циклические атаки с истощением баланса

Из-за модели IC «платит контейнер» все контейнеры подвержены DoS-атакам из-за истощения их баланса цикла, и этот риск необходимо учитывать.

Самая элементарная стратегия смягчения последствий — отслеживать баланс циклов канистр и держать его подальше от (настраиваемого) порога замерзания.

На необработанном уровне IC возможны дальнейшие стратегии смягчения последствий:

  • Если все вызовы обновления аутентифицированы, выполните эту аутентификацию как можно быстрее, возможно, до декодирования аргумента вызывающей стороны. Таким образом, атака с утечкой цикла со стороны злоумышленника, не прошедшего проверку подлинности, менее эффективна (но все же возможна).
  • Кроме того, реализация системного метода canister_inspect_message позволяет выполнять вышеуказанные проверки еще до того, как сообщение будет принято Интернет-компьютером. Но он не защищает от сообщений между контейнерами и поэтому не является полным решением.
  • Если ожидается атака со стороны аутентифицированного пользователя (например, заинтересованного лица), вышеуказанные методы неэффективны, и для эффективной защиты может потребоваться относительно сложная дополнительная программная логика (например, статистика для каждого вызывающего абонента) для обнаружения такой атаки, и реагировать (например, ограничивать скорость).
  • Такие средства защиты бессмысленны, если есть только один метод, к которому они неприменимы (например, метод регистрации пользователя, не прошедшего проверку подлинности). Если приложение по своей природе подвержено атаке таким образом, не стоит беспокоиться о повышении защиты для других методов.
  • Связанный: Обоснование, почему Интернет-идентификация не использует canister_inspect_message)

Контейнер, реализованный Motoko, в настоящее время не может выполнять большинство из этих средств защиты: декодирование аргумента происходит безоговорочно до любого пользовательского кода, который может отклонить сообщение на основе вызывающего, и canister_inspect_message не поддерживается. Кроме того, декодирование Candid не очень защищает от циклов, и следует предположить, что можно создавать сообщения Candid, для декодирования которых требуется много инструкций, даже для «простых» сигнатур типа аргумента.

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

Атаки на большие данные

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

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

Проблема становится намного хуже, если метод имеет тип аргумента, который позволяет использовать космическую бомбу Candid: можно кодировать очень большие векторы со всеми значениями null в Candid, поэтому, если какой-либо метод имеет аргумент типа [Null] или [?t], небольшое сообщение расширится до большого значения в куче Motoko.

Другие типы, на которые следует обратить внимание:

  • Nat и Int: это неограниченное натуральное число, поэтому оно может быть сколь угодно большим. Однако представление Motoko не будет намного больше, чем кодировка Candid (так что это не считается космической бомбой).
  • Тем не менее рекомендуется проверить, является ли число разумным по размеру, прежде чем сохранять его или выполнять await. Например, когда он обозначает индекс в массиве, throw раньше, если он превышает размер массива; если он обозначает сумму токена для перевода, проверьте его на доступный баланс, если он обозначает время, проверьте его на разумные пределы.
  • Principal: Principal фактически является Blob. Спецификация интерфейса говорит, что главные элементы имеют длину не более 29 байт, но Motoko Candid decoder в настоящее время не проверяет это (исправлено в следующей версии Motoko). До тех пор Principal, переданный в качестве аргумента, может быть большим (участник в msg.caller предоставляется системой и, следовательно, безопасен). Если вы не можете дождаться, пока исправление дойдет до вас, вручную проверьте размер основного файла (через Principal.toBlob) перед выполнением await.

Слежка за msg или caller

Не используйте одно и то же имя для «контекста сообщения» включающего актора и методов контейнера: опасно писать shared(msg) actor, потому что теперь msg находится в области действия всех общедоступных методов. Пока они также используют public shared(msg) func … и, таким образом, затеняют внешний msg, это правильно, но если кто-то случайно пропустит или неправильно наберет msg, ошибка компилятора не возникнет, но внезапно msg.caller теперь станет исходным контроллером, что, вероятно, победит важный этап авторизации.

Вместо этого напишите shared(init_msg) actor или shared({caller = controller}) actor, чтобы не использовать msg.

Заключение

Если вы пишете «серьезную» канистру, будь то в Motoko или нет, стоит пройтись по коду и следить за этими шаблонами. Или, что еще лучше, попросите кого-нибудь еще просмотреть ваш код, так как в вашем собственном коде может быть сложно обнаружить проблемы.

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

(Первоначально опубликовано на сайте joachim-breitner.de 9 ноября 2021 г.)

____

Начните строить на smartcontracts.org и присоединяйтесь к нашему сообществу разработчиков на forum.dfinity.org.