На Хабре ещё не было статей про безопасность смарт-контрактов блокчейна Hyperledger Fabric. Так что буду первым. Я занимаюсь исследованием безопасности этого блокчейна год. И сегодня хочу рассказать о довольно серьёзной проблеме: манипуляции временем транзакции. По классификации, уязвимость попадает в OWASP Smart Contract Top 10: SC03:2023 Timestamp Dependence.
UPD 27.08.2024 уязвимости присвоен идентификатор CVE-2024-45244. Рассмотрим:
как атакующий может произвести манипуляцию временем транзакции;
к каким финансовым последствиям может привести атака (на примере концепта вымышленного уязвимого смарт-контракта, имитирующего цифровой финансовый актив);
какие способы защиты я предлагаю.
Также, обсудим, почему для корректной защиты от атаки может потребоваться не только изменение смарт-контракта, но и налаживание взаимодействия между командой эксплуатации смарт-контракта и администраторами сети. Статья предполагает хотя бы базовый уровень знакомства читателя с 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% больше средств, чем должен был.
У этого варианта атаки есть особенность: подменяемое время не должно выходить за рамки действия сертификата, иначе будет ошибка. На рисунке 2 видно, что сертификат истекает 03.06.2034. При установки времени в "2035-06-10" возникает ошибка. При установке времени в "2034-06-02 " ошибка исчезает. Возможно, есть метод обхода этого ограничения. Но, это не являлось целью статьи. Вряд ли стоит рассматривать укорачивание времени действия сертификата как вариант защиты.
Встречал решения, когда не сами пользователи вызывали GetTxTimestamp(), а обращение к функции происходило от стороннего сервиса (рисунок 3). Но, это не является решением проблемы. Максимум - снижает уровень опасности. Т.к. нет уверенности, что на сервисе не будет сбито время (случайно, вследствие атаки или севшей батарейки на материнской плате).
Манипуляция временем в 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() также подвержена манипуляции временем со стороны клиента. При этом последовательность значений расположена в правильном хронологическом порядке: первой идёт самая свежая запись - т.е. отсортирована согласно высоты блока, как и указано в описании функции (высота транзакций не использовалась, т.к. каждая транзакция оказалась в отдельном блоке).
Никакой бизнес-логики в использование 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". В связи с чем атакующий вернул время обратно (после чего ошибка исчезла).
Плюс у решения - не требуется следить за точностью времени на узлах блокчейн-сети. Основная проблема этого подхода в том, что трафик протокола NTP подвержен атаке "человек посередине". Здесь уже всплывают организационные моменты защиты трафика. Например, использование VPN между клиентом и сервером NTP (т.е. нужен свой сервер NTP, общедоступный не подходит). Как вариант решения этой проблемы - использование NTS.
Сравнение времени транзакции с сервером времени (NTS)
time_secure_nts.go является почти копией предыдущего варианта. Изменён протокол взаимодействия на более безопасный NTS. Используется этот пакет. Результат работы функций такой же, как с NTP.
NTS, по сравнению с NTP, не требует дополнительной защиты трафика для противодействия атаке "человек посередине". Из нюансов: публичных общедоступных NTS-серверов в России найти не удалось (что может быть важно для некоторых организаций в свете геополитической ситуации). Но, хорошей практикой является поднятие собственного локального сервера времени.
Сравнение времени транзакции с системным временем ОС
Если есть уверенность, что на peer-узлах установлено верное время (например, есть специальный программный механизм, контролирующий корректность времени с заданной периодичностью и выключающий узел в случае отклонений, которые невозможно устранить автоматически) - можно сравнивать время транзакции с системным временем peer-узла. Соответствующий код приведён в time_secure_localtime.go. Результаты проверки защиты, в целом, идентичны предыдущему сценарию (см рисунок 7).
При данном подходе необходимо помнить не только про вышеуказанный контроль корректности локального времени, но и знать, откуда это время берётся. Если источник времени NTP-сервер - имеем ту же проблему с подменой времени вследствие атаки "человек посередине". Проблема ещё и в том, что разработчики смарт-контрактов далеко не всегда осведомлены об источнике времени (особенно, если смарт-контракт делается на заказ для другой организации).
Выводы
Использование GetTxTimestamp() или GetHistoryForKey() требует дополнительной верификации времени, полученной от клиента. В рассмотренном примере, клиент смог произвести финансовую атаку: получил прибыль явно не за то время, которое ожидали разработчики. Защита от манипуляции временем, в общем виде, нетривиальная задача. При разработке, помимо вышеуказанных изменений в самом смарт-контракте, может потребоваться взаимодействие с эксплуатирующей командой, в целях определения подходящего допустимого отклонения времени (подходящего для конкретной бизнес-логики приложения и его архитектуры) между эталоном и транзакцией пользователя. А также, для определения безопасного источника времени, на который будет полагаться смарт-контракт (есть ли безопасный NTP-сервер, трафик которого не будет подменён? Возможно ли поднять локальный NTS-сервер? Или ориентироваться на время на самих peer-серверах, которые точно безопасно его получают?). По этим же причинам выработка рекомендаций по устранению проблемы (отсутствие проверки времени транзакции) для команды исследователей безопасности исходного кода является нетривиальной задачей.
UPD: в качестве более удобного решения проблемы для разработчиков и AppSec команд - разработал смарт-контракт, который передаёт точное время от NTP/NTS серверов другим смарт-контрактам. Это избавляет разработчиков смарт-контрактов от необходимости самостоятельно реализовывать логику взаимодействия с серверами точного времени.
UPD 27.08.2024 уязвимости присвоен идентификатор CVE-2024-45244. Пришлось самостоятельно обращаться в MITRE (воспользовался этой статьёй) т.к. разработчик не признал это уязвимостью. Хоть и выпустил фикс. Исправление сейчас недоступно для стабильных версий (находится в ветке main).
UPD 13.09.2024 разработчик пытался оспорить, что это уязвимость - не вышло.