На Хабре ещё не было статей про безопасность смарт-контрактов блокчейна Hyperledger Fabric. Так что буду первым. Я занимаюсь исследованием безопасности этого блокчейна год. И сегодня хочу рассказать о довольно серьёзной проблеме: манипуляции временем транзакции. Рассмотрим:
как атакующий может произвести манипуляцию временем транзакции;
к каким финансовым последствиям может привести атака (на примере концепта вымышленного уязвимого смарт-контракта, имитирующего цифровой финансовый актив);
какие способы защиты я предлагаю.
Также, обсудим, почему для корректной защиты от атаки может потребоваться не только изменение смарт-контракта, но и налаживание взаимодействия между командой эксплуатации смарт-контракта и администраторами сети. Статья предполагает хотя бы базовый уровень знакомства читателя с Hyperledger Fabric.
В чём проблема
Существуют функции, которые могут быть использованы в смарт-контракте для получения времени транзакции. При этом, время определяется с клиентской стороны и никак не проверяется на валидность. О чём можно узнать из описания функции GetTxTimestamp() и GetHistoryForKey(). Проблема известна, как минимум, с 2019. Сделано для детерменизма. Детерминизм предполагает, что результаты выполнения смарт-контракта у разных peer-узлов одинаковы. По этой причине, в блокчейне Hyperledger Fabric нельзя вместо времени клиента взять и записать время peer-узла с точностью до секунд - время у узлов может отличаться (особенно, если не обеспечивается точное время на всех узлах). При этом никакого предупреждения о потенциальных последствиях использования вышеуказанных функций не указано. Что довольно странно, учитывая, что для GetHistoryForKey() подробно указана проблема фантомных чтений и способы предотвратить проблему Т.е. можно было ожидать и упоминания об отсутствии проверки времени, получаемой от клиента. Более того, в одном из проектов из официальных примеров Hyperledger (Fabric samples) используют GetTxTimestamp().
Пример атаки
Атаку решил показать на очень упрощённом вымышленном концепте цифрового финансового актива (т.к. Hyperledger Fabric используется в т.ч. для выпуска цифровых финансовых активов).
Представим, что есть смарт-контракт, позволяющий клиенту инвестировать средства под 20% годовых. Рассмотрим уязвимый смарт-контракт time_insecure.go
Через функцию Stake_insecure() записывается время транзакции (т.е. время начала вклада) и размер вклада. Функция CheckDividents_insecure() показывает, сколько накоплено средств с процентами на момент вызова этой функции (при расчёте накопленных средств рассчитывает прошедшее время, как разницу между GetTxTimestamp() при вызове самой CheckDividents_insecure() и GetTxTimestamp() при вызове Stake_insecure() ). Корректность расчёта дивидендов, определяемых в Stake_insecure(), можно проверить через отладочную функцию CalcDividents(): она вернёт расчёт дивидендов, исходя из начальных условий: размера вклада и прошедших дней с момента вклада. Отладочная функция subtractTimestamp() покажет, сколько времени прошло между вызовом Stake_insecure() и текущем временем системы клиента (на основании этого значения рассчитываются дивиденды).
На рисунке 1 видны этапы атаки:
проверяем текущую дату клиента (16.06.2024);
делаем депозит на сумму 10 000 (через вызов Stake_insecure() );
вызывав CheckDividents_insecure() видим, что дивидендов нет (сумма к снятию равна сумме изначального вложения - 10 000);
проверяем разницу времени через вызов отладочной функции subtractTimestamp() (видим, что прошло менее 2-х минут);
меняем системное время на клиенте на год вперёд (теперь 17.06.2025);
убеждаемся, что разница во времени сущетвенно изменилась (возвращает 8779 часов);
вызываем CheckDividents_insecure() и видим сумму к снятию - 12 000
Т.о. атакующий за пару минут действий получил на 20% больше средств, чем должен был.
![Рисунок 1. Подмена времени для проведения финансовой атаки на уязвимый смарт-контракт Рисунок 1. Подмена времени для проведения финансовой атаки на уязвимый смарт-контракт](https://habrastorage.org/getpro/habr/upload_files/489/48f/ced/48948fced29372bda27ed2d1538ba58d.png)
У этого варианта атаки есть особенность: подменяемое время не должно выходить за рамки действия сертификата, иначе будет ошибка. На рисунке 2 видно, что сертификат истекает 03.06.2034. При установки времени в "2035-06-10" возникает ошибка. При установке времени в "2034-06-02 " ошибка исчезает. Возможно, есть метод обхода этого ограничения. Но, это не являлось целью статьи. Вряд ли стоит рассматривать укорачивание времени действия сертификата как вариант защиты.
![Рисунок 2. Ошибка при установке времени позже срока действия сертификата Рисунок 2. Ошибка при установке времени позже срока действия сертификата](https://habrastorage.org/getpro/habr/upload_files/f4d/0df/236/f4d0df2362971dd917e0b950bb511fb7.png)
Встречал решения, когда не сами пользователи вызывали GetTxTimestamp(), а обращение к функции происходило от стороннего сервиса (рисунок 3). Но, это не является решением проблемы. Максимум - снижает уровень опасности. Т.к. нет уверенности, что на сервисе не будет сбито время (случайно, вследствие атаки или севшей батарейки на материнской плате).
![Рисунок 3. Сервис вызывает GetTxTimestamp() Рисунок 3. Сервис вызывает GetTxTimestamp()](https://habrastorage.org/getpro/habr/upload_files/f2f/b0f/cc1/f2fb0fcc16993d59318737a24a29ad18.png)
Манипуляция временем в GetHistoryForKey()
Описание к функции GetHistoryForKey(), на мой взгляд, довольно запутанное: с одной стороны, видим то же упоминание о метке времени, предоставленное клиентом. С другой - упоминается упорядочивание согласно высоты блока и высоты транзакции внутри блока, начиная с версии Fabric v2.0. Сейчас разберёмся что это значит. Я проверил поведение на Hyperledger Fabric v2.5.5. В time_insecure.go функция GetHistoryForKey() используется в getHistory(). И нужна для получения данных о ранее сделанной записи через Stake_insecure(). Через вызов Stake_insecure() я записал 3 разных значения последовательно: 10 000, 20 000, 30 000. При этом даты на клиенте были установлены последовательно перед каждым вызовом Stake_insecure() : 2025-06-16; 2024-06-16; 2026-06-16.
Как видно на рисунке 4, GetHistoryForKey() также подвержена манипуляции временем со стороны клиента. При этом последовательность значений расположена в правильном хронологическом порядке: первой идёт самая свежая запись - т.е. отсортирована согласно высоты блока, как и указано в описании функции (высота транзакций не использовалась, т.к. каждая транзакция оказалась в отдельном блоке).
![Рисунок 4 Манипуляция временем при вызове GetHistoryForKey() Рисунок 4 Манипуляция временем при вызове GetHistoryForKey()](https://habrastorage.org/getpro/habr/upload_files/fcf/7c2/286/fcf7c2286fc4decb9b069558ac653fa2.png)
Никакой бизнес-логики в использование GetHistoryForKey() в time_insecure.go я не закладывал. Она здесь используется лишь как ещё одна функция, подверженная обсуждаемой атаке. Функция отображает историю изменения переменной "amount". При этом самое последнее изменение и есть текущее значение "amount".
Существующие решения
Я смог найти лишь один готовый вариант (3-х летней давности) - TimeFabric (статья, исходники). Согласно описанию, вариант является патчингом исходного кода Hyperledger Fabric. Заявлено, что подходит для версии 1.4 и 2.0. Т.е. перед применением развёрнутый блокчейн нужно пропатчить. И в дальнейшем может появиться необходимость патчинга блокчейна при его обновлении. Судя по всему, проект более не поддерживается. Что вызывает вопрос относительно возможности использования на версиях блокчейна вышедших за последние 3 года.
Предлагаемые мной варианты защиты
Мои варианты решения не требуют патчинга блокчейна т.к. основаны на смарт-контракте. Варианты: сравнение времени с сервером времени и с локальным временем компьютера (там, где смарт-контракт). Оба описываемых варианта работают на версиях 2.5.5 и 3.0.0-beta. Из минусов можно отметить требование у клиента правильно установленного времени (в пределах некоего доверительного интервала). В ином случае транзакция клиента будет отклоняться.
На первый взгляд может показаться, что оба варианта нарушают принцип распределённости (+ появляется единая точка отказа): каждый peer-узел должен выдавать результат независимо от других, а не зависеть от единственного источника времени. Но, это в общем случае не так. Локальное время на разных peer-узлах в общем случае устанавливается независимо и может синхронизироваться с разными серверами времени. Настройка различных независимых источников времени на самих peer-узлах - огранизационный вопрос.
Что касается сервера времени - смарт-контракты могут быть сконфигурированы для использования разных серверов (у них будет разный packageID, но одинаковое определение чейнкода). Именно так я и сделал: на каждый peer-узел установил смарт-контракт, в котором идентично было всё, кроме адресов серверов.
Сравнение времени транзакции с сервером времени (NTP)
Взглянем на код из time_secure_ntp.go. Я использую пакет "github.com/beevik/ntp" для получения точного времени от сервера NTP. Далее, в функциях Stake_secure_ntp() и CheckDividents_secure_ntp() я проверяю, что время от клиента отличается от времени NTP-сервера не более чем на 300 сек (значение выбрано лишь исходя из бизнес-логики: дивиденды начисляются за полные прошедшие 24 часа; возможно, в конкретных реализациях архитектуры блокчейна и его бизнес-логики нужно уделить больше внимания определению возможного отклонения времени). На рисунке 5 видно, что та же последовательность атакующего не привела к успеху: появилась ошибка "Wrong time". В связи с чем атакующий вернул время обратно (после чего ошибка исчезла).
![Рисунок 5. использование запроса к NTP-серверу препятствует манипуляции временем транзакции Рисунок 5. использование запроса к NTP-серверу препятствует манипуляции временем транзакции](https://habrastorage.org/getpro/habr/upload_files/1e0/93d/394/1e093d3943679ae3e18aa30e83606b73.png)
Плюс у решения - не требуется следить за точностью времени на узлах блокчейн-сети. Основная проблема этого подхода в том, что трафик протокола NTP подвержен атаке "человек посередине". Здесь уже всплывают организационные моменты защиты трафика. Например, использование VPN между клиентом и сервером NTP (т.е. нужен свой сервер NTP, общедоступный не подходит). Как вариант решения этой проблемы - использование NTS.
Сравнение времени транзакции с сервером времени (NTS)
time_secure_nts.go является почти копией предыдущего варианта. Изменён протокол взаимодействия на более безопасный NTS. Используется этот пакет. Результат работы функций такой же, как с NTP.
![Рисунок 6. использование запроса к NTS-серверу препятствует манипуляции временем транзакции Рисунок 6. использование запроса к NTS-серверу препятствует манипуляции временем транзакции](https://habrastorage.org/getpro/habr/upload_files/611/7b6/797/6117b6797999f599da43a8c597682232.png)
NTS, по сравнению с NTP, не требует дополнительной защиты трафика для противодействия атаке "человек посередине". Из нюансов: публичных общедоступных NTS-серверов в России найти не удалось (что может быть важно для некоторых организаций в свете геополитической ситуации). Но, хорошей практикой является поднятие собственного локального сервера времени.
Сравнение времени транзакции с системным временем ОС
Если есть уверенность, что на peer-узлах установлено верное время (например, есть специальный программный механизм, контролирующий корректность времени с заданной периодичностью и выключающий узел в случае отклонений, которые невозможно устранить автоматически) - можно сравнивать время транзакции с системным временем peer-узла. Соответствующий код приведён в time_secure_localtime.go. Результаты проверки защиты, в целом, идентичны предыдущему сценарию (см рисунок 7).
![Рисунок 7. использование локального времени узла препятствует манипуляции временем транзакции Рисунок 7. использование локального времени узла препятствует манипуляции временем транзакции](https://habrastorage.org/getpro/habr/upload_files/550/90b/567/55090b567b36268f786ec14244c10819.png)
При данном подходе необходимо помнить не только про вышеуказанный контроль корректности локального времени, но и знать, откуда это время берётся. Если источник времени NTP-сервер - имеем ту же проблему с подменой времени вследствие атаки "человек посередине". Проблема ещё и в том, что разработчики смарт-контрактов далеко не всегда осведомлены об источнике времени (особенно, если смарт-контракт делается на заказ для другой организации).
Выводы
Использование GetTxTimestamp() или GetHistoryForKey() требует дополнительной верификации времени, полученной от клиента. В рассмотренном примере, клиент смог произвести финансовую атаку: получил прибыль явно не за то время, которое ожидали разработчики. Защита от манипуляции временем, в общем виде, нетривиальная задача. При разработке, помимо вышеуказанных изменений в самом смарт-контракте, может потребоваться взаимодействие с эксплуатирующей командой, в целях определения подходящего допустимого отклонения времени (подходящего для конкретной бизнес-логики приложения и его архитектуры) между эталоном и транзакцией пользователя. А также, для определения безопасного источника времени, на который будет полагаться смарт-контракт (есть ли безопасный NTP-сервер, трафик которого не будет подменён? Возможно ли поднять локальный NTS-сервер? Или ориентироваться на время на самих peer-серверах, которые точно безопасно его получают?). По этим же причинам выработка рекомендаций по устранению проблемы (отсутствие проверки времени транзакции) для команды исследователей безопасности исходного кода является нетривиальной задачей.