Автор: Johan & Lisa
Редактор: 77
6 сентября в 13:52:10 UTC в блоке № 4,050,335 Liquid появились две транзакции с одинаковой структурой. Через минуту блок № 4,050,336 подтвердил третью транзакцию: примерно 3 998,5 L-BTC попали в UTXO-набор, при этом за ними не было ни одной peg-in. Спустя еще несколько минут эти несуществующие активы начали перераспределять и тратить, после чего через федеральный канал peg-out они конвертировались в настоящий BTC в основной сети Bitcoin.
На следующий день 3 400 BTC были возвращены в федеральный peg-кошелек. Оставшиеся 598,5 BTC остались у атакующего; на момент публикации они все еще многократно само-переводятся, при этом к каждой транзакции прилагается фрагмент OP_RETURN в открытом виде — с требованием у федеральных властей десятины «награды». Переговоры до сих пор ведутся публично в цепочке блоков.
Мы пошагово сверили блоки до и после 4,050,335, а на стороне Bitcoin с помощью MistTrack проследили поток средств до каждого «прыжка». Эта статья рассказывает о двух вещах: что именно атакующий сделал технически, и об оптимизации производительности, которая была добавлена в кодовую базу ещё в 2016 году — почему спустя десять лет она позволила вывести четыре тысячи биткоинов.
Обзор атаки

Как управляют деньгами в Liquid
У эмиссии L-BTC есть только один «правильный» путь. Передаёте BTC под управление мультисиг-кошелька федерации — тогда на стороне Liquid чеканится эквивалентный объём L-BTC, а в обратном направлении выполняется peg-out, чтобы вернуть BTC. Эту привязку «1 к 1» охраняет механизм конфиденциальных транзакций. Сумма каждого выхода напрямую не записывается в цепочку — она спрятана в обязательстве Педерсена; каждое обязательство должно сопровождаться доказательством rangeproof, подтверждающим, что сумма неотрицательна и не выходит за границы. При проверке транзакции узел суммирует точки обязательств входов и выходов отдельно; если обе стороны сходятся — расчёт считается корректным.
Если хотя бы один выход удастся подменить фиктивным rangeproof, то в обязательстве можно скрыть отрицательное число или астрономически большое значение — математически это приведёт к «созданию денег из ничего». Поэтому rangeproof должен каждый раз быть проверен по-настоящему.
Проблема в том, что это дорого. Один rangeproof обычно занимает более четырёх тысяч байт, а верификация — это реальная эллиптическая кривая, каждый выход нужно проверить полностью. 12 июля 2016 года в репозиторий Elements попала правка под названием Rangeproof caching (66ab07bd1b). Идея была заимствована из кэша подписей Bitcoin: однажды проверенное доказательство больше не проверяется снова — результат «верификация успешна» сохраняется в таблице кэша, а в следующий раз при точно таких же входных данных сразу происходит пропуск.
Такая оптимизация в криптографическом коде встречается очень часто. На этот раз все проблемы тоже оказались именно в ней.
Чего не хватает в ключе кэша
Сначала посмотрим на этот фрагмент кода в релизе 23.3.3.

Ключ кэша — это proof и commitment, «голой» склейкой помещённые в один и тот же поток SHA-256. proof — поле переменной длины: длинная версия занимает более четырёх тысяч байт. В этом ключе нет ни информации об активах, ни о скрипте, ни о каких-либо границах полей.
Правка, поданная 3 августа этого года (c26d719), добавила в конструкцию обязательство активов и скрипт блокировки — и всё это стало четырьмя полями. Направление было правильным: изначально действительно можно было «переиспользовать» одни и те же доказательства, подставляя разные активы и скрипты. Патч всё ещё просто «голыми» склейками помещал эти четыре поля в один и тот же поток. proof и scriptPubKey — поля переменной длины, и границы между ними можно было «переносить» оттуда сюда.
Укрепляющая правка от 8 сентября (94000967f6) перешла на сериализацию хэша с префиксами длины. В тексте сообщения о подаче есть фраза, сказанная очень прямолинейно.
Это сериализует каждое поле с префиксом длины, поэтому различные наборы аргументов, чьи исходные склейки в байтах совпадают, больше не приводят к коллизии в одном и том же ключе кэша. Запись кэша — это положительный результат верификации; коллизия позволила бы атакующему обойти проверку.
В кэше хранится «проверка пройдена». Коллизия ключа означает, что вместо непроверенного ранее объекта подменили сохранённым «оберегом». Также при той же отправке обнаружили, что в кэше surjection proof не привязана метка входного актива — это тоже исправили.
Попадание сразу означает успешную верификацию
Пропуск происходит в начале выполнения CachingRangeProofChecker::VerifyRangeProof.

Сразу возвращается true. Следующие проверки secp256k1_rangeproof_verify и проверка min_value не выполняются ни одной строкой.
Правила записи и очистки кэша различаются — это ключ к таймингу атаки. Когда транзакция попадает в mempool, она проверяется в режиме, допускающем запись: прошедший результат сохраняется в кэш для повторного использования при попадании в блок. А при соединении блоков — наоборот: только запрос, без записи; при нахождении ставится пометка, что результат можно утилизировать. Комментарий в validation.cpp, строка 2340, очень ясно описывает вторую часть правила: Don't cache results if we're actually connecting blocks (still consult the cache, though).
Запись кэша — расходный материал. Всё, что злоумышленнику нужно обеспечить, — это возможность воспользоваться тем окном, пока целевой узел всё ещё хранит запись «уже проверено». Он пересылает транзакцию с полезной нагрузкой коллизии.
Ключ кэша каждого узла также включает случайную соль, сгенерированную при запуске. Соль не спасает положение. Соль добавляется в самое начало всех полей. Две транзакции сами по себе одинаковы, так что с солью или без — они всё равно одинаковы; поэтому коллизия независимо возникает на каждом узле.
В цепочке — восемь минут
Атака началась в 13:52:10, в блоке 4,050,335 две транзакции setup с одинаковой структурой были включены в один и тот же блок. В каждой — выход OP_RETURN, и сам этот выход полностью «чистый». Актив — открытый L-BTC (6f0279e9…), обязательство суммы C0 (09d6c615…) сопровождается честным и корректным rangeproof P0, который узлы проверили по нормальному процессу. Полезная нагрузка спрятана в её скрипте блокировки.
6a43 ‖ C1 ‖ X ‖ 6a
0x43 — это 67; как раз 67 следующих байт засовываются как данные. C1 (086f5d67… ) — это фиктивное обязательство суммы, которое готовится использовать для вывода атаки. X (0a0a488de489… ) — форма «обезличивания», обязательство актива, но по одним лишь байтам невозможно понять, какой именно это актив. Мы в репозитории пересчитали генераторы secp256k1 для открытого L-BTC: он начинается с 0ab963d4 и не совпадает с X по точке. Эти два значения как буквальные данные лежат в скрипте; при верификации они воспринимаются лишь как обычные данные. После проверки в кэше каждого узла появляется ещё одна запись: ключ — это «голая» склейка этих четырёх полей.
Посередине есть ещё время на «primer». Связка блоков расходует записи в кэше, поэтому атакующий многократно рассылает в mempool транзакции с той же полезной нагрузкой, чтобы заново заполнить кэш на каждом узле. Конкретно какие именно транзакции были primer — невозможно восстановить по данным блоков; возможно, они вообще не попадали в блоки. Две транзакции setup также оказались в одном и том же блоке — вероятно, чтобы после того как записи были потрачены, их можно было сразу восполнить.
13:53:10, блок 4,050,336 — появляется транзакция чеканки. У неё есть одно ключевое выходное значение, и четыре поля заполнены так. proof кладётся как P0, затем идёт хвост в 68 байт: содержимое хвоста — C0, A, 6a43; A — обязательство активов самого output setup. commitment — C1. asset_commitment — X. В scriptPubKey остаётся один байт: 6a.

Четыре поля попарно различаются. Мы последовательно «голой» склейкой помещаем их в SHA-256, и две потока идентичны побайтно.
Узлы проверили этот выход, вычислили ключ, попали в кэш и сразу вернули true. secp256k1_rangeproof_verify ни разу не обращалась к этому доказательству. Какой бы ни была величина суммы за C1, затем выход с обязательством отрицательного значения выполняет суммирование в уравнении Педерсена; так в итоге в набор UTXO «провалилась» примерно 3,998.5 L-BTC.
В 13:54:10 и 14:00:10, в блоках 4,050,337 и 4,050,343 две транзакции объединяют и расходуют соответствующие UTXO. Запрос peg-out был по обычной схеме разрешён федерацией; эквивалентные «настоящие» BTC затем были выплачены через Bitcoin mainnet на адрес получателя bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. От первой операции setup до момента начала консолидации средств — ровно восемь минут.
Возврат и переговоры в цепочке
На следующий день 3,400 BTC были переведены обратно в peg-кошелёк федерации bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. Оставшиеся 598.5 BTC остались на месте; с 8 по 10 сентября в промежутке блоков 966,052–966,199 они неоднократно саморасходовались, и каждая операция сопровождалась OP_RETURN с открытым текстом. Одна из строк гласит: "You SHALL pay 10% using your own money as bug bounty", а подпись оставляет Telegram-аккаунт @MRBOND_1. В сообщении автор позиционирует себя как «white hat» и требует, чтобы федерация заплатила официальное вознаграждение в размере 10% за свой счёт. Переговоры до сих пор идут публично в цепочке.
Отслеживание средств MistTrack
После прояснения базового хода событий мы дополнительно проследили потоки средств злоумышленника в Liquid и в Bitcoin mainnet, использовали MistTrack для анализа ключевых адресов со стороны Bitcoin, а затем сопоставили это с данными в блокчейне.
На стороне Liquid злоумышленник использует адрес ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w для сбора и расходования соответствующих активов. Этот адрес задействован в 91 транзакции и имеет 247 выходов; на данный момент все они уже потрачены. Поскольку MistTrack пока не покрывает сеть Liquid, основной верифицируемый поток средств на стороне Liquid — от чеканки L-BTC, расходования UTXO до последующего peg-out — подтверждается в основном по данным блок-эксплорера по каждой операции.

Со стороны Bitcoin mainnet средства peg-out примерно на 3,998.5 BTC в итоге дошли до адреса bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. Риск-скор компании MistTrack для этого адреса — 100/100, рейтинг Severe. Теги включают Malicious Address / Involved Theft Activity, и он находится в непосредственной (в один шаг) связи с сущностями, относящимися к преступным незаконным действиям Theft, с коэффициентом 100%.

С точки зрения источника средств: BTC, полученные по этому адресу, не имеют соответствующего нормального вложенного капитала. Его происхождение можно проследить до ранее описанной чеканки L-BTC и последующего peg-out; фактически основная часть расходов, которые понёс атакующий, — это комиссии за соответствующие транзакции.
Попадание сразу означает успешную верификацию

Оставшиеся примерно 598.5 BTC по-прежнему контролируются злоумышленником. В период с 8 сентября по 10 сентября эта часть средств постоянно выполняла операции самоперевода между блоками Bitcoin 966,052 и 966,199, а также вела публичные переговоры с федерацией через сообщения OP_RETURN. Одна из таких записей содержит фразу «You SHALL pay 10% using your own money as bug bounty» и оставляет Telegram-аккаунт @MRBOND_1. На данный момент эти переговоры всё ещё можно наблюдать в блокчейне.

С учётом текущих данных в цепочке, злоумышленник вернул 3,400 BTC из примерно 3,998.5 BTC средств peg-out; оставшиеся около 598.5 BTC по-прежнему находятся в его контроле, что составляет примерно 15% от суммы peg-out. Далее мы продолжим мониторинг соответствующих адресов и изменений средств.
Заключение
Этот код пролежал в репозитории десять лет и многократно прогонялся на бесчисленных узлах. Один раз его правили 3 августа, но 6 сентября он снова был использован в атаке. Первая версия патча дополнила отсутствующие поля в хэш, но не учла границы полей — спустя пять недель границы были точно использованы. 8 сентября вышла Elements 23.3.4: кроме префиксирования длины ключа, добавили аварийный выключатель -norangeproofcache, чтобы эксплуатация могла отключить эту таблицу кэша целиком — и не пропускать вообще никаких кэшированных результатов.
На этот раз урок можно изложить очень кратко. Кэширование положительного результата на пути криптографической верификации: попал в кэш — и проверка не выполняется, значит ключ обязан рассматриваться по стандарту криптографических примитивов. Каждый прочитанный проверочной функцией поле и каждая граница полей должны однозначно хэшироваться: префикс длины как раз для этого. Оптимизации производительности вроде кэширования, мемоизации и параллелизации — если они затрагивают путь верификации, при аудите к ним следует относиться так же строго, как к самим оптимизируемым криптографическим примитивам.
Команда Slow Mist Security рекомендует проекту провести полный внешний аудит безопасности перед развёртыванием. В контрольном списке аудита на уровне консенсуса целостность ключей верификационного кэша должна быть указана как обязательная проверка.

