Аппаратный кошелёк продают за одно обещание: ключ рождается внутри, в кремнии, и наружу не выходит никогда. У Coldcard это обещание подпирали Bitcoin-only прошивка, air-gap-режим с подписью через microSD и репутация устройства для тех, кто не доверяет ни бирже, ни Ledger. Не массовый девайс – премиальный выбор параноика, который читал исходники.
30 июля 2026 года кто-то за 41 минуту вычистил 1 196 адресов и забрал 1 082,65 BTC. К первому августа насчитали три волны: 1 367,05 BTC с 4 585 адресов, около 88,6 миллиона долларов. Ни одного вскрытого устройства, ни одной подобранной PIN-последовательности, ни одного паяльника. Атакующий вообще не подходил к железу – он посчитал чужие ключи у себя дома и подписал абсолютно валидные транзакции.
Потому что генератор случайных чисел в Coldcard пять лет не был генератором случайных чисел. Аппаратный TRNG на STM32 стоял на плате. Secure element стоял рядом. А сид собирался из программной заглушки, которую MicroPython держит для дешёвых плат без TRNG вообще. Между кремнием и ключом стоял препроцессор – и он выбрал заглушку.
Всё сломала разница между #ifndef и #if.
Разберём цепочку по звеньям, она короткая и оттого особенно неприятная.
Звено первое. Продакшен-конфигурация платы осознанно выключает реализацию RNG из MicroPython:
// We have our own version of this code.
#define MICROPY_HW_ENABLE_RNG (0)
Лежит это в stm32/COLDCARD/mpconfigboard.h:76-77, stm32/COLDCARD_MK4/mpconfigboard.h:77-78 и stm32/COLDCARD_Q1/mpconfigboard.h:79-80. Обычная скомпилированная конфигурация борда, не переменная окружения и не экзотический билд-оверрайд. И решение по-своему здравое: у Coldcard есть своя обёртка над аппаратным RNG, её random_buffer() читает периферию STM32 и падает по таймауту или на повторной выборке. В Python она торчит как ckcc.rng_bytes. То есть правильный путь всё это время существовал и работал.
Звено второе. Библиотека libngu, через которую идёт вся криптография, выбирает источник энтропии так:
extern uint32_t rng_get(void);
#define CHIP_TRNG_32() rng_get()
#ifndef MICROPY_HW_ENABLE_RNG
#error "get a HW TRNG plz"
#endif
Вот он, тот самый предохранитель. Кто-то не поленился и поставил #error с человеческой формулировкой ровно на случай сборки без аппаратного TRNG. Только #ifndef проверяет факт определения макроса, а не его значение. Макрос определён. Значение – ноль. Страж пропускает.
Дальше промах добивается второй раз: борд-локальная реализация экспортирует random32() и random_buffer(), а глобального rng_get() не даёт. Ссылка libngu резолвится в единственный доступный rng_get() – тот, что в MicroPython.
Звено третье. MicroPython читает тот же самый макрос правильно, по значению:
#if MICROPY_HW_ENABLE_RNG
// STM32 hardware RNG
#else
// Yasmarang fallback
#endif
Ноль – значит компилируется программный Yasmarang. Сборка проходит без единого предупреждения, прошивка подписывается, устройства уезжают к покупателям, и ngu.random начинает выдавать красивый детерминированный шум.
Два проекта читают один макрос двумя разными директивами. Один спрашивает «а он вообще есть?», другой – «а он включён?». Ответы расходятся, и в эту щель за пять лет утекло 1 367 биткоинов.
Заглушку MicroPython инициализирует один раз, при первом вызове rng_get(), вот этим:
pad = UID_low32 ^ SysTick->VAL;
n = RTC->TR;
d = RTC->SSR;
dat = 0;
После инициализации новая энтропия не собирается вообще. Каждый следующий выход – детерминированный переход состояния. Посмотрим на входы глазами того, кто собирается их перебирать.
UID – заводской идентификатор чипа, 96 бит, фиксированный на весь срок жизни MCU, читается из памяти и частично попадает в USB-серийник устройства. Это метка производителя, а не секрет. В инициализации участвует только его младшее 32-битное слово, и разные устройства это слово вполне могут делить.
SysTick – периодический счётчик, перезаряжаемый раз в миллисекунду: 80 000 возможных значений на Mk2/Mk3 (примерно 2^16,29) и 120 000 на Mk4/Q/Mk5 (2^16,87). Это потолок перебора, а не гарантированная энтропия: знание момента первого вызова RNG режет его в разы.
RTC->TR и RTC->SSR – время суток и субсекундная позиция. Они коррелируют друг с другом и с SysTick, так что считать их независимыми случайными величинами – значит завышать стойкость на ровном месте. Хуже того: на старте Mk2/Mk3 выбранный RTC-осциллятор выключен, что прямо намекает на нулевые или статические значения при обычном холодном старте. На текущих устройствах источник RTC сконфигурирован, но помечен как неиспользуемый, а инициализация RTC в MicroPython отключена.
И отдельная деталь, которую стоит подержать в голове: UID_low32 XOR SysTick даёт не 2^(32 + биты SysTick) вариантов, а максимум 2^32. Оба значения схлопываются в одно 32-битное слово pad. XOR не складывает энтропию, он её смешивает в фиксированной ширине.
Дальше libngu делает то, что выглядит как усиление, а является декорацией. Она держит второй генератор Yasmarang, инициализированный публичными константами прямо из исходника:
static uint32_t yasmarang_pad = 0x0a8ce26f;
static uint32_t yasmarang_n = 69;
static uint32_t yasmarang_d = 233;
static uint8_t yasmarang_dat = 0;
И каждое выходное слово собирает так:
chip = rng_get(); // фолбэк MicroPython
chip ^= my_yasmarang(); // второй Yasmarang libngu
Два воспроизводимых потока, сложенных по модулю два, дают воспроизводимый поток. Константы лежат в открытом репозитории. Никакой энтропии эта строка не добавляет – только уверенности читающему код.
Есть там и health-check: он отбраковывает соседние одинаковые выходы rng_get(). Детерминированный PRNG соседние значения повторяет крайне редко, поэтому проверку проходит штатно, всегда, с первого раза. Это идеальный образец предохранителя, который срабатывает только на отказ, который и так виден.
Регрессия имеет точный адрес и дату. До неё, в прошивке v3.2.2, генерация сида выглядела так:
seed = bytearray(32)
rng_bytes(seed)
Этот путь вёл в ckcc.rng_bytes – в ту самую борд-локальную обёртку над аппаратным RNG STM32. Всё было правильно.
Коммит b18723dd от 1 марта 2021 года переписал генерацию на libngu:
seed = random.bytes(32)
shared/random.py мапит этот вызов на ngu.random.bytes. Семнадцатого марта 2021 года это уехало в релизную прошивку v4.0.0. Уязвимый страж в libngu, к слову, существовал ещё с 28 января – то есть мина была заложена заранее, а коммит просто подвёл к ней провод.
Пять лет и четыре месяца. За это время устройство успело сменить два поколения, обрасти secure element, air-gap-процедурами, Edge-веткой прошивки и репутацией эталона самостоятельного хранения.
В Mk4 появился ресед от secure element – коммит 01cb43f7 от 11 марта 2022 года, в продакшене с v5.0.0 от 14 марта. Выглядит он солидно ровно до того момента, пока не досчитаешь байты:
a = callgate.read_rng(1) # 32 байта из SE1
b = callgate.read_rng(2) # 8 байт из SE2
n = ngu.hash.sha256d(a + b)
n, = ustruct.unpack('I', n[0:4])
ngu.random.reseed(n)
Сорок байт от защищённых элементов заходят в sha256d. Из дайджеста берутся первые четыре байта. Остальные двадцать восемь выбрасываются. И принимающая сторона:
STATIC mp_obj_t random_reseed(mp_obj_t arg)
{
yasmarang_pad = mp_obj_get_int_truncated(arg);
return mp_const_none;
}
Одно присваивание в одно слово состояния. Эта функция не принимает полный дайджест, не инициализирует криптографический DRBG, не реседит фолбэк MicroPython, не трогает остальные слова состояния Yasmarang, не даёт ни периодического реседа, ни prediction resistance.
Итог арифметический и безжалостный: при фиксированном состоянии фолбэка и известной истории вызовов существует не более 2^32 различимых выходных потоков, в среднем 2^31 попыток перебора. Внутренняя эволюция состояния размажет эти 32 бита по последующему выходу, но создать биты из ничего не сможет.
Отдельным штрихом – качество входа. SE1 действительно возвращает 32 аутентифицированных байта с непредсказуемостью защищённого элемента. А SE2 читает восемь аутентифицированных байт со страницы ROM-опций, а не выполняет живую команду генерации. То есть даже до усечения дайджеста пятая часть входа была не случайностью, а константой.
Это принципиально хуже, чем отсутствие фикса. Отсутствующий фикс виден в аудите. Написанный фикс закрывает вопрос в чек-листе, попадает в чейнджлог, успокаивает ревьюера – и оставляет 2^32.
Финальная генерация кошелька выглядит так:
seed = ngu.random.bytes(32)
assert len(set(seed)) > 4
return ngu.hash.sha256d(seed)
Ассерт ловит только совсем уж клиническую поломку вроде потока одинаковых байт. Yasmarang проходит его не напрягаясь.
А sha256d делает выход статистически равномерным – и не делает больше ничего. Это детерминированная функция: сколько различных входов, столько и различных выходов, ни одним больше.
≤ 2^32 кандидатных выходов RNG
↓ SHA256d
≤ 2^32 кандидатных сидов кошелька
Контрольная сумма BIP39 энтропии тоже не добавляет – она проверяет целостность, а не происхождение. И именно здесь ломается интуиция: сид из такого кошелька пройдёт любой статистический тест на случайность, потому что после двойного SHA-256 он и есть равномерный шум. Просто этот шум выбирается из множества, которое помещается в память ноутбука.
Мы уже разбирали симметричный случай с другой стороны баррикад: криптор рандомизировал PE-метаданные генератором случайных слов, и постоянная форма этой случайности сама стала сигнатурой. Тот же закон работает и здесь. Случайность определяется размером множества, из которого делается выбор, а не тем, как выглядит результат.
Теперь соберём поисковое пространство в одну картину. Отсчёт от нормы: Mk1 и все Mk2/Mk3 по v3.2.2 включительно, которые ходили в аппаратный RNG напрямую, дают порядка 2^256 – ровно то, за что платили.
Mk2/Mk3 на ветке v4.0.0–v4.1.9. Криптографической энтропии в ngu.random не добавляется вообще, реседа нет. При известных UID, состоянии таймеров и истории вызовов результат детерминирован: 2^0. При известном UID и неизвестном SysTick – около 2^16,29, восемьдесят тысяч вариантов. Если считать эффективное значение UID_low32 XOR SysTick полностью неизвестным – потолок 2^32. Широкая верхняя граница с перебором всех состояний SysTick и RTC – меньше 2^40,7.
Mk4/Q/Mk5. При успешном реседе – не более 2^32, в среднем 2^31. Заведомо щедрый потолок, если считать все поля таймеров независимыми (120 000 значений SysTick × 86 400 значений времени × 256 субсекундных), даёт около 2^41,27 состояний фолбэка, а вместе с реседом – сырые 2^73,27.
И вот на этой цифре Block ставит прямую оговорку, которую надо процитировать целиком, потому что дальше начнётся самое интересное: это не 73-битная криптостойкость. Поля таймеров коррелированы, реальные диапазоны у них уже, значения потенциально наблюдаемы или реконструируемы. Более того, атакующий может профилировать распределения SysTick, RTC и истории вызовов на собственном устройстве – железо и прошивка общие, замеры переносятся.
Пока новость расходилась по лентам, в ней устоялись две цифры: «около 40 бит на Mk3» и «около 72 бит вместо ожидаемых 128». Обе звучат солидно, обе выглядят как оценка стойкости, и обе искажают отчёт.
Числа 40 бит в исследовании Block нет вообще. Ближайшее к нему – 2^40,7, и это широкая верхняя граница для Mk2/Mk3, полученная при допущениях, максимально благоприятных для защищающегося: перебираем все мыслимые состояния таймеров и RTC. Нижняя граница в том же разделе того же отчёта – 2^0. Полный детерминизм. Разница между потолком и полом здесь не в разы, а в сорок порядков двоичной шкалы.
С 72 битами история та же по форме. Это формулировка Coinkite из адвайзори, и в системе координат Block ей соответствует сырой потолок 2^73,3 – тот самый, который сам Block сопроводил отказом считать его криптостойкостью. Собственная же оценка исследователей для этих устройств – не более 2^32.
То есть обе публичные цифры – это потолки, поданные как оценки. Механика подмены прозрачна: в отчёте оценка идёт диапазоном, у диапазона два конца, и в пересказ уезжает тот, который приятнее выглядит в заголовке. Читателю в результате сообщают, что его кошелёк защищён сорока битами, тогда как исследование говорит: при известной истории вызовов там ноль.
Разбирать это стоит не ради занудства. Между 2^32 и 2^40,7 лежит примерно семисоткратная разница в объёме работы, а 2^0 – это вообще не перебор, а адресное восстановление. Сколько всё это стоит в часах и железе, Block сознательно не пишет: сквозного бенчмарка брутфорса в отчёте нет и авторы прямо об этом предупреждают, потому что практическая цена зависит от доступной информации об UID, тайминга загрузки, предыдущих вызовов RNG и стоимости деривации. Свип 30 июля показал, что для кого-то цена оказалась приемлемой.
Пробитый генератор кормит далеко не одну функцию, и радиус поражения стоит осознать целиком. Тот же поток my_random_bytes() уходит в:
ngu.secp256k1.keypair() → my_random_bytes(32) отдаёт выход RNG напрямую в приватный ключ secp256k1, без промежуточных BIP39 и BIP32 (shared/paper.py:91, external/libngu/ngu/k1.c:428). Публичный адрес при этом работает готовым оракулом: генерируй кандидатов, выводи адрес, сравнивай, останавливайся на совпадении. Опция «Use Dice» задаёт ключ независимо и этот путь обходит;ngu.random не использует и не затронут;generate_seed() дважды, и энтропия от этого не удваивается – это два последовательных выхода одного маленького состояния.Вызовы, идущие через ckcc.rng_bytes, попадают в настоящий аппаратный RNG и не затронуты. Правильная дверь всё это время была в соседней стене.
И про мультисиг, раз уж он считается универсальным ответом на компрометацию устройства: если кворум собран исключительно из уязвимых Coldcard, схема уязвима целиком. Спасает только кворум здоровых устройств.
Есть в отчёте ещё один сюжет, и Block обращается с ним аккуратнее, чем большинство обошлось бы. Ранняя загрузка оборачивает mk4.init0() и q1.init0() широким обработчиком исключений, который продолжает работу после ловимых ошибок. Ловимое исключение, прилетевшее до реседа, оставило бы libngu в публичном начальном состоянии – том самом, с константами 0x0a8ce26f, 69 и 233 из открытого репозитория. Это 2^0.
При этом Block прямо оговаривает: в продакшене отказы связи и аутентификации с SE1/SE2 обычно уходят в невозвратные пути бутлоадера, так что утверждать, будто обычный аппаратный сбой тихо продолжает загрузку без реседа, оснований нет. Случай помечен как условный и отделён от доказанной 32-битной слабости.
Формулировка «широкое исключение остаётся опасной fail-open-конструкцией, но рассматривать её следует отдельно» – это, между прочим, образец того, как выглядит честный отчёт. Соблазн склеить условную дыру с доказанной и получить заголовок покрупнее здесь был размером с сам инцидент.
Хронология последних суток объясняет, почему отчёт вышел в таком виде.
Свип 30 июля начался примерно за тридцать часов до публичного раскрытия Coinkite. Атакующий пришёл первым, и раскрытие догоняло эксплуатацию, а не предупреждало её. Galaxy Research разметила транзакционный рисунок, насчитала три волны и передала федеральным следователям около 600 адресов, предположительно принадлежащих атакующему.
В тот же день Coinkite выпустила предварительное адвайзори – только по Mk3. Block, независимо докопавшись до корня, раскрыл свои находки вендору, отметил расхождения и опубликовался в тот же день с прямым объяснением спешки: активная эксплуатация уже идёт. Первого августа Coinkite обновила адвайзори, и периметр расширился ровно туда, куда указывал исследователь: Mk4, Mk5 и Q, около 72 бит вместо ожидаемых 128.
При этом сам Block свою работу не переоценивает: полного эмпирического подтверждения эксплуатируемости они не проводили, отчёт называют собственным мнением по внутреннему исследованию и рекомендуют дождаться окончательного технического разбора Coinkite. За вычетом одного «но»: пока идёт согласование формулировок, деньги уже уходят.
Практическая часть короткая, но в ней есть пара ловушек.
Исправленные версии: Mk2/Mk3 – 4.2.0, Mk4/Mk5 standard – 5.6.0, Q standard – 1.5.0Q, Mk4/Mk5 Edge – 6.6.0X, Q Edge – 6.6.0QX. Standard и Edge – разные релизные ветки. Старший номер Edge-сборки 6.x не означает, что фикс в ней есть; ставить нужно исправленный релиз своей ветки.
Дальше главное, и это надо усвоить до конца: обновление прошивки не чинит существующий сид. Оно чинит генерацию новых. Уязвимость живёт не в устройстве, а в конкретном числе, которое устройство когда-то выдало. Экспозиция определяется прошивкой на момент генерации секрета, а не датой выпуска железа. Экспортированный когда-то в другой кошелёк сид остаётся дырявым и там – он тот же самый.
Исключение ровно одно, и оно радует: если при создании сида вводились броски костей, независимая энтропия никуда не делась. Прошивка хешировала устройственный сид вместе со всеми введёнными бросками. От 50 до 98 честных приватных бросков дают минимум 128 бит одними костями, 99 и больше – около 256. Меньше пятидесяти, броски записывались или подсматривались, или просто «не помню» – мигрировать.
Стойкая уникальная BIP-39 passphrase даёт независимый барьер и снимает немедленную угрозу, но сид не ремонтирует – мигрировать всё равно, просто без пожара. TAPSIGNER, OPENDIME и SATSCARD живут на другой кодовой базе и не затронуты.
В этой истории нет ни одной поломанной криптографической примитивы. SHA-256 отработал безупречно. secp256k1 не подвёл. BIP39 честно посчитал контрольную сумму. Аппаратный TRNG на STM32 исправно крутился на плате все пять лет, готовый выдать свои 256 бит по первому запросу. Secure element аутентифицировал свои байты как положено. Блокчейн валидировал транзакции ровно по правилам – он и не мог поступить иначе, подписи-то настоящие.
Сломалось единственное место, которое никто не считал криптографией: сборка. Директива препроцессора, читающая макрос не тем способом, и предохранитель, поставленный ровно против этого сценария, но проверяющий существование вместо значения. Уровень абстракции, на котором не бывает CVE в красивых цветах, не пишут PoC и не выступают на конференциях.
Из этого следует вещь пренеприятная для всего класса устройств. Аппаратный кошелёк – это не «ключ в железе». Это цепочка из платы, конфигурации борда, чужой библиотеки, интерпретатора Python и трёх строк на Питоне сверху, и стойкость всей цепочки равна стойкости самого тихого её звена. Проверить эту цепочку снаружи нельзя в принципе: выход ngu.random неотличим от идеального шума по любому мыслимому статистическому тесту, потому что за ним стоит двойной SHA-256. Никакой аудит по чёрному ящику, никакая проверка сида на энтропию, никакой NIST-набор здесь не помогли бы – они и не помогли, все пять лет.
Единственное, что реально работало против такого отказа, – кости. Пятьдесят бросков, введённых руками, потому что владелец решил не доверять до конца даже устройству, купленному из недоверия ко всем остальным. Параноик, которого считали клиническим случаем, оказался единственным, чьи монеты никуда не делись.
Разбор корневой причины и поисковых пространств – Bitcoin Engineering and Security в Block, картирование свипа – Galaxy Research.
Автор: hacker@shifry.local