Краткое резюме
Начиная с 30 июля 2026 года, средства были выведены с аппаратных Bitcoin-кошельков COLDCARD в несколько волн, которые продолжали разворачиваться в последующие дни. Не было ни единой атакующей транзакции в блокчейне, ни уязвимого смарт-контракта; потери стали следствием офлайн-проблемы восстановления сид-фразы с последующими on-chain-выводами средств. По состоянию на 7 августа 2026 года on-chain-отслеживание подтвердило минимальный объём примерно в 1 405 BTC (~$91M по цене $64 700 на 7 августа), выведенных примерно с 4 925 адресов [1], атрибуция по волнам достигла около 1 433 BTC в рамках десяти волн [2], а сверка с пострадавшими по закрытым каналам дала цифру вплоть до 2 055 BTC (~$133M) [3].
Первопричиной стал сбой энтропии кошелька: при миграции прошивки в 2021 году генерация сид-фразы была направлена через ngu.random.bytes(), который откатился к детерминированному программному генератору вместо предполагаемого аппаратного ГСЧ STM32. Для затронутых устройств это превратило восстановление сид-фразы из криптографически неразрешимой задачи в офлайн-перебор, позволив злоумышленнику перечислять кандидатные сид-фразы и сопоставлять их с публичными данными кошельков для восстановления приватных ключей. Опираясь на наш предыдущий еженедельный анализ инцидента, данное углублённое исследование количественно оценивает остаточную энтропию для каждого поколения затронутых устройств, отслеживает способы идентификации пострадавших и похищенных средств в блокчейне, а также рассматривает отдельную регрессию прошивки после выпуска хотфикса, способную вызвать отказ в обслуживании до авторизации.
Предыстория
COLDCARD — это аппаратный Bitcoin-кошелёк. Как и в других кошельках с самостоятельным хранением, его безопасность в конечном счёте зависит от непредсказуемости сид-фразы, генерируемой при настройке кошелька. Сид-фраза BIP-39 читабельна человеком, однако базовое свойство безопасности по-прежнему определяется энтропией случайных байтов, использованных при её создании. Если вывод генерации сид-фразы предсказуем, безопасность кошелька рушится — сколь бы тщательно сид-фраза ни хранилась впоследствии.
COLDCARD охватывает аппаратные ревизии от Mk1 до Mk5 и модель Q. Mk2 и Mk3 используют устаревшую линейку прошивок. Mk4, Mk5 и Q используют отдельные ветки релизов Standard и Edge.
Большая часть логики приложения COLDCARD написана на Python и выполняется на MicroPython, а нативные модули на C обеспечивают аппаратные и криптографические операции. Безопасный путь генерации сид-фразы должен считывать 32 байта из аппаратного ГСЧ STM32, завершаться с ошибкой при зависании периферии или повторении значения, хэшировать результат, а затем кодировать его в виде слов BIP-39. В версии v3.2.2 make_new_wallet() следовал именно этому пути:
make_new_wallet()
|- ckcc.rng_bytes(seed)
| `- random_buffer()
| |- read STM32 RNG->DR
| `- fail on timeout or repeated output
|- SHA-256(seed)
`- BIP-39 seed words
Анализ уязвимости
Первопричиной стала ошибка сборки и интеграции, связанная с MICROPY_HW_ENABLE_RNG. В конфигурации производственной платы COLDCARD этот макрос был установлен в 0, поскольку прошивка использовала собственную локальную обёртку аппаратного ГСЧ вместо реализации аппаратного ГСЧ MicroPython. Однако путь генерации кошелька был перенесён на ngu.random.bytes(32), а STM32-путь libngu в конечном счёте зависел от глобального символа rng_get().
Затронутый путь входил в my_random_bytes() библиотеки libngu. Для каждого выходного слова выполнялась операция XOR значения, возвращаемого CHIP_TRNG_32(), с собственным выводом Yasmarang:
generate_seed()
`- ngu.random.bytes(32)
`- my_random_bytes() [libngu]
|- CHIP_TRNG_32()
| `- rng_get() [MicroPython]
| `- Yasmarang A
| `- UID + SysTick + RTC
|
|- my_yasmarang() [libngu]
| `- Yasmarang B
| |- Mk2/Mk3: public initial state
| `- Mk4/Q/Mk5: 32-bit pad reseed
|
`- output = Yasmarang A XOR Yasmarang B
Yasmarang A был программным запасным вариантом MicroPython за rng_get(), инициализируемым из состояния устройства и таймеров. Yasmarang B принадлежал libngu: на Mk2/Mk3 он использовал публичные начальные значения, тогда как на Mk4/Q/Mk5 только 32-битное слово pad заменялось данными из защищённого элемента. Локальный аппаратный ГСЧ STM32 по-прежнему существовал, но ngu.random.bytes() его не вызывал.
Несоответствие непосредственно видно в конфигурации сборки. Файл mpconfigboard.h для Mk4 отключал ветку аппаратного ГСЧ MicroPython:
// We have our own version of this code.
#define MICROPY_HW_ENABLE_RNG (0)
CHIP_TRNG_32() библиотеки libngu по-прежнему считал наличие макроса достаточным доказательством аппаратного ГСЧ, поскольку проверял лишь факт существования макроса, а затем вызывал rng_get():
extern uint32_t rng_get(void);
#define CHIP_TRNG_32() rng_get()
#ifndef MICROPY_HW_ENABLE_RNG
#error "get a HW TRNG plz"
#endif
Эта проверка упускает опасный случай: макрос, определённый как 0, всё равно определён. Сборка поэтому завершилась успешно, а rng_get() разрешился в STM32 RNG-модуль MicroPython вместо отдельной локальной обёртки COLDCARD (random32() / random_buffer(), доступной в Python как ckcc.rng_bytes). В выборе rng_get() в MicroPython условие MICROPY_HW_ENABLE_RNG == 0 выбирало программную запасную ветку:
#if MICROPY_HW_ENABLE_RNG
// STM32 hardware RNG
#else
// Yasmarang software fallback
#endif
Mk2/Mk3: около 40 бит
Скомпилированный запасной вариант pyb_rng_yasmarang() инициализировал и продвигал Yasmarang следующим образом:
STATIC uint32_t pyb_rng_yasmarang(void) {
static bool seeded = false;
static uint32_t pad = 0, n = 0, d = 0;
static uint8_t dat = 0;
if (!seeded) {
seeded = true;
rtc_init_finalise();
pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL;
n = RTC->TR;
d = RTC->SSR;
}
pad += dat + d * n;
pad = (pad << 3) + (pad >> 29);
n = pad | 2;
d ^= (pad << 31) + (pad >> 1);
dat ^= (char)pad ^ (d >> 8) ^ 1;
return pad ^ (d << 5) ^ (pad >> 18) ^ (dat << 1);
}
Переменные занимают 104 бита, однако размер состояния не равен энтропии. dat начинается с нуля, а остальные значения представляют собой фиксированные метаданные или коррелированные показания таймеров, а не независимые секреты. В рамках приближённой модели Mk2/Mk3, используемой для оценки примерно в 40 бит:
| Входное значение | Возможные значения | Стоимость перебора |
|---|---|---|
Известный UID_low32 |
1 |
2^0 |
SysTick->VAL |
80 000 |
2^16.29 |
RTC->TR время суток |
86 400 |
2^16.40 |
RTC->SSR субсекунды |
256 |
2^8 |
Рассматривая все поля таймеров как независимые, получаем намеренно широкий верхний предел:
80,000 * 86,400 * 256
= 1,769,472,000,000
= 2^40.69 candidate initial states
Полный перебор требует не более 2^40.69 попыток и, при допущении о равномерном распределении, в среднем около 2^39.69 попыток. Это верхняя граница перебора, а не 40 бит криптографической энтропии. Если регистры RTC статичны во время обычной холодной загрузки, остаётся только SysTick, что снижает верхнюю границу примерно до 2^16.29. Если UID, значения таймеров и количество предшествующих вызовов ГСЧ известны, существует ровно один поток: 2^0. Неизвестная история вызовов добавляет лишь количество вероятных трасс выполнения, а не новый источник энтропии. Аналогично, генерация восьми 32-битных слов для 256-битной сид-фразы не умножает пространство поиска: каждое слово определяется одним и тем же начальным состоянием.
На уровне libngu my_random_bytes() смешивал описанный выше запасной вариант MicroPython с отдельным генератором Yasmarang из libngu. В исходном коде буквально нет chip = rng_get(): CHIP_TRNG_32() раскрывается в rng_get(). На Mk2/Mk3 второй генератор начинал с публичных констант:
static uint32_t yasmarang_pad = 0x0a8ce26f, yasmarang_n = 69, yasmarang_d = 233;
static uint8_t yasmarang_dat = 0;
uint32_t chip = CHIP_TRNG_32();
// ... adjacent-output health check ...
chip ^= my_yasmarang();
Таким образом, оба потока были воспроизводимы, как только становились известны состояние запасного варианта MicroPython и история вызовов. XOR изменял выходные значения, но не добавлял энтропии. Даже если UID_low32 неизвестен, выражение UID_low32 ^ SysTick всё равно сворачивает оба входа в одно 32-битное значение pad; их номинальное количество бит не суммируется.
Mk4/Q/Mk5: около 72 бит
В более поздних моделях сохранялась та же двухгенераторная конструкция, но rng_seeding() добавлял материал из защищённого элемента в генератор libngu во время загрузки:
a = callgate.read_rng(1) # SE1
b = callgate.read_rng(2) # SE2
n = ngu.hash.sha256d(a + b)
n, = ustruct.unpack('I', n[0:4])
ngu.random.reseed(n)
Несмотря на то что в хэш поступало 40 байт, до reseed() доходили лишь первые четыре байта результата [4]. Реализация random_reseed() затем заменяла только 32-битное слово pad в libngu:
STATIC mp_obj_t random_reseed(mp_obj_t arg)
{
yasmarang_pad = mp_obj_get_int_truncated(arg);
return mp_const_none;
}
Остальные слова состояния libngu сохраняли свои публичные значения, а запасной вариант MicroPython не пересевался. Приблизительная цифра в 72 бита складывается из 32-битного ресида libngu и приблизительного верхнего предела для состояния таймеров более поздних моделей:
MicroPython fallback:
120,000 SysTick values * 86,400 RTC times * 256 subseconds
= 2^41.27 states
Libngu secure reseed:
2^32 values
Combined ceiling:
2^41.27 * 2^32 = 2^73.27 candidates
Average enumeration:
2^73.27 / 2 = 2^72.27 trials
Отсюда берётся цифра «около 72 бит». Это оценка среднего объёма работы атакующего, а не 72 бита, предоставленных защищёнными элементами. Поля таймеров коррелированы и могут быть восстановлены; если состояние запасного варианта MicroPython известно, остаётся лишь 2^32 значений ресида, в среднем 2^31 попыток. Хэширование финальных 32 случайных байтов не может увеличить количество возможных сид-фраз.
Затронутые версии
| Устройство и ветка | Вне данной регрессии | Затронутые версии прошивки для генерации сид-фразы | Эффективная битовая безопасность | Первый исправленный релиз |
|---|---|---|---|---|
| Mk1 | До v3.0.6 | Нет | - | Н/Д |
| Mk2/Mk3 | До v3.2.2 | v4.0.0–v4.1.9 (по официальному advisory — с v4.0.1) | Около 40 бит при наличии уязвимости | v4.2.0 |
| Mk4/Mk5 Standard | Н/Д | До v5.6.0 | Около 72 бит до исправления; не менее 128 бит после | v5.6.0 |
| Q Standard | Н/Д | До v1.5.0Q | Около 72 бит до исправления; не менее 128 бит после | v1.5.0Q |
| Mk4/Mk5 Edge | Н/Д | До v6.6.0X | Около 72 бит до исправления; не менее 128 бит после | v6.6.0X |
| Q Edge | Н/Д | До v6.6.0QX | Около 72 бит до исправления; не менее 128 бит после | v6.6.0QX |
Значимой является версия прошивки, которая генерировала сид-фразу, а не версия, установленная в данный момент. Новые сид-фразы, сгенерированные в исправленных релизах и позднее, используют скорректированный путь, однако обновление не исправляет уже существующую сид-фразу. Официальный диапазон затронутых версий для Mk2/Mk3 начинается с v4.0.1 [5], тогда как анализ исходного кода также включает v4.0.0 [4]. Независимая энтропия кубиков может повысить безопасность сид-фразы, тогда как надёжная парольная фраза BIP-39 добавляет отдельный барьер, не исправляя саму сид-фразу [6].
Best Security Auditor for Web3
Validate design, code, and business logic before launch
Отслеживание пострадавших и средств
Атакующей транзакции в блокчейне для отслеживания не существовало; восстановление происходило исключительно в офлайн-режиме. Нацелившись на сид-фразу, сгенерированную уязвимой прошивкой, злоумышленник мог ограничить и перебрать описанные выше кандидатные состояния ГСЧ, воспроизвести поток генерации сид-фразы для каждого кандидата, вывести из него ключи кошелька и сопоставить их с публичными данными кошельков, а затем вывести средства из найденных пополненных кошельков в блокчейне. Это затрагивало только кошельки, выводимые из одной сид-фразы: надёжная уникальная парольная фраза BIP-39 подмешивает независимую энтропию, предоставленную пользователем и никогда не затронутую уязвимостью ГСЧ, в процесс выведения ключей через PBKDF2, выводя такие кошельки за рамки чистого перебора сид-фраз. Выведенные кошельки по необходимости не имели такой защиты [6]. Поскольку кража проявилась лишь в виде on-chain-выводов, а не отслеживаемого эксплойта, идентификация пострадавших и отслеживание средств стали задачей on-chain-криминалистики.
Несколько независимых усилий отслеживали похищенные средства: публичные сайты мониторинга (Coldcard Sweep Watch [1], coldcard.rip [2] и Coldcard Hack Tracker [7]) и частная сверка Galaxy Research [3] с пострадавшими, при этом их суммарные данные сравниваются ниже. Поскольку Coldcard Sweep Watch опубликовал свою методологию, мы используем её для иллюстрации процесса идентификации — цикла обратной связи между офлайн-отчётами и on-chain-анализом:
- Офлайн-якоря. Пострадавшие и исследователи предоставляли публичные адреса или идентификаторы транзакций, а при наличии — контекст устройства и генерации сид-фразы. Каждое сообщение рассматривалось как зацепка и проверялось в блокчейне; сид-фраза, приватный ключ или xpub не требовались.
- On-chain-расширение. Начиная с подтверждённых якорей, сканеры искали в соответствующих блоках те же характеристики вывода: опустошённые кошельки без сдачи, схожие типы входов, плотный временной интервал, повторяющиеся комиссии, общие адреса назначения или последующие совместные траты.
- Офлайн-перекрёстные проверки. Новые сообщения пострадавших, наборы данных исследователей и атрибуция сервисов использовались для подтверждения или отклонения кандидатных волн. Проверенные кластеры и эвристические кандидаты оставались раздельными.
Bitcoin идентифицирует адреса, а не людей или модели кошельков. Один кошелёк может управлять множеством адресов, поэтому количество адресов не равно количеству пострадавших. Первая широко освещавшаяся крупная волна, 960188, составила 594,48 BTC; продолжение сканирования и поступление новых сообщений увеличивали общую сумму. По состоянию на 7 августа 2026 года Coldcard Sweep Watch зафиксировал подтверждённый минимум около 1 405,07 BTC (~$91M по цене $64 700 на 7 августа) примерно с 4 925 адресов [1], тогда как снимок coldcard.rip от 3 августа атрибутировал до 1 433,13 BTC брутто, 1 432,48 BTC на адреса назначения после вычета комиссий, с 5 477 адресов в рамках десяти волн [2]. Отдельная частная сверка Galaxy Research, основанная на переписке с пострадавшими, дала ещё более высокую цифру — от около 1 596 BTC до 2 055 BTC (~$133M по той же цене) [3]. Расхождения объясняются разными порогами доказательности, временем обнаружения и каналом подтверждения, на который опирается каждый из трекеров.
Затем средства отслеживались через три наблюдаемых слоя: исходные адреса вывода, прямые адреса назначения вывода (holding) и последующие адреса консолидации (vault). В таблице показано количество уникальных адресов на каждом слое:
| Паттерн | Примеры маршрутов | Следствие для отслеживания |
|---|---|---|
| Множество выводов на один или два holding-адреса, затем один vault | 960183: 204 -> 2 -> 1; 960188: 500 -> 1 -> 1 |
Конвергенция адресов назначения делает кластер сравнительно надёжным и лёгким для отслеживания |
| Выводы останавливаются на holding-адресах без дальнейшей консолидации | 960352: 352 -> 1 -> 0; 960668: 795 -> 1 -> 0 |
Holding-адрес остаётся отслеживаемым, однако последующих совместных трат для усиления атрибуции нет |
| Свежие адреса назначения для каждого вывода, иногда с последующими отдельными vault-адресами | 960359: 13 -> 13 -> 0; 960395: 1 918 -> 294 -> 293 |
Детектор общего коллектора не срабатывает; группировка зависит от временно́го интервала, размера комиссии, шаблона транзакции и внецепочечного подтверждения |
При движении отслеживаемых выходов анализ следует за разбиениями, слияниями и цепочками снятия сдачи, сохраняя стоимость за вычетом комиссий и ограничивая атрибуцию суммой вывода. Средства, поступающие на биржу или другой сервис с объединёнными средствами, снижают уверенность; такой сервис не добавляется в кластер злоумышленника.
Исправление, которое потребовало исправления: потенциальная регрессия с риском «окирпичивания»?
Отдельно от сбоя энтропии, хотфикс, устранивший его, привнёс самостоятельную регрессию прошивки [8, 9]. При восстановлении пути к аппаратному ГСЧ исправление оставило необработанным состояние ошибки аппаратного сида, что может вызвать отказ в обслуживании до авторизации и повлекло утверждения о безвозвратном «окирпичивании» устройств. Насколько это более серьёзное утверждение справедливо, зависит от деталей на уровне регистров.
Аппаратный ГСЧ STM32 предоставляет регистр управления (RNG_CR), регистр статуса (RNG_SR) и 32-битный регистр данных (RNG_DR). Релевантные статусные биты:
| Бит | Роль |
|---|---|
RNGEN |
Включает ГСЧ и его аналоговые источники шума |
DRDY |
Указывает, что данные готовы в RNG_DR; программа всё равно должна отклонять нулевые значения |
SECS / SEIS |
Текущий сбой теста качества сида / зафиксированный статус ошибки сида |
CECS / CEIS |
Текущая неисправность тактирования ГСЧ / зафиксированный статус ошибки тактирования |
Различие между текущим и зафиксированным статусом существенно. SECS описывает текущее состояние источника шума, тогда как SEIS фиксирует факт возникновения ошибки сида до явной очистки программным обеспечением. На STM32L4S семейства Mk4/Q ошибка сида останавливает генерацию новых случайных чисел; на STM32L4 для Mk3 данные могут оставаться доступными, но доверять им нельзя. Ошибки тактирования являются отдельным случаем и не вызывают данной блокировки из-за ошибки сида.
Требуемая последовательность восстановления зависит от поколения STM32:
| Семейство устройств | Документированное восстановление после ошибки сида |
|---|---|
| Mk3 STM32L4 (RM0351, управление ошибками ГСЧ [10]) | Сбросить SEIS, затем сбросить и установить RNGEN |
| STM32L4S семейства Mk4/Q (RM0432, управление ошибками ГСЧ [11]) | Сбросить SEIS, прочитать и отбросить 12 слов RNG_DR, затем убедиться, что SEIS остаётся сброшенным |
Хотфикс для устранения уязвимости энтропии от 31 июля корректно обеспечил разрешение rng_get() в аппаратный TRNG, однако rng_get_or_fault() семейства Mk4/Q не реализует никакого восстановления после ошибки сида. rng_init() действует только при сброшенном RNGEN, тогда как цикл чтения проверяет лишь DRDY. Если ошибка сида оставляет RNGEN включённым, но подавляет DRDY, инициализация становится бесполезной операцией; каждое чтение ждёт 10 мс и выбрасывает OSError(EFAULT) без очистки SEIS.
Это может затронуть пользовательский интерфейс до авторизации. Как mempad._start_scan() для цифровой клавиатуры, так и keyboard._start_scan() для Q перемешивают порядок сканирования по прерыванию от нажатия клавиши. Исключение ГСЧ там может заблокировать ввод PIN-кода и обычное меню обновления прошивки до конца данной аппаратной сессии.
Отказ на уровне кода является достоверным, однако более серьёзное утверждение о «постоянном окирпичивании» не подтверждено. Биты управления и статуса ГСЧ сбрасываются в ноль при аппаратном сбросе, поэтому единичная временная ошибка не должна необратимо повредить периферию; сохранение состояния после полного цикла питания не было продемонстрировано. Равным образом не существует продемонстрированного удалённого или надёжно контролируемого способа вызвать сбой теста качества сида. Пост в X [8] называл «окирпичивание» подтверждённым, однако указанный в нём PR, поданный сообществом PR #692 [9], содержит собственное заявление автора о том, что он анализировал сбой с помощью макета регистров, не воспроизвёл его на реальном оборудовании Mk4/Q и не подтвердил самостоятельно полевые отчёты. Наиболее обоснованная классификация — это потенциальный отказ в обслуживании до авторизации и регрессия надёжности, но не подтверждённая атака с постоянным «окирпичиванием».
Собственное исправление разработчиков, PR #693 [12], проверяет флаги ошибок сида, добавляет ограниченное восстановление с повторными попытками, отклоняет подозрительные образцы и перехватывает только ожидаемые ошибки клавиатуры; оно было влито 5 августа 2026 года, вытеснив поданный сообществом PR #692 [9], который был закрыт без слияния 4 августа 2026 года.
Заключение
Данный инцидент представлял собой сбой энтропии кошелька, превративший восстановление сид-фразы из криптографически неразрешимой задачи в задачу офлайн-поиска для затронутых устройств и рабочих процессов. Ключевой инженерной ошибкой стало то, что поставляемая прошивка не проверяла, действительно ли критически важный для безопасности API генерации сид-фразы обращается к предполагаемому аппаратному ГСЧ. Защитные проверки сборки должны проверять как факт существования макроса, так и его значение, запасные варианты для криптографической энтропии должны завершаться с ошибкой в закрытом состоянии, а CI должен проверять происхождение символов и сквозной поток энтропии в финальном образе прошивки. Для пострадавших пользователей решением не является обновление прошивки: обновление не исправляет сид-фразу, уже сгенерированную по уязвимому пути, а последующее добавление парольной фразы не защищает средства, уже хранящиеся на адресах данной сид-фразы. Эти средства необходимо перевести в кошелёк, созданный на основе новой сид-фразы в исправленном релизе; только средства, уже защищённые надёжной уникальной парольной фразой, оставались вне досягаемости чистого перебора сид-фраз [6].
Ссылки
- [1] Coldcard Sweep Watch — подтверждённый набор опустошённых адресов и трекер потерь
- [2] coldcard.rip — реестр инцидента, маршруты и атрибуция
- [3] Galaxy Research — оценка потерь COLDCARD по сообщениям пострадавших
- [4] Block Engineering — предсказуемый запасной ГСЧ и 32-битный ресид в прошивке COLDCARD
- [5] Coinkite — уведомление о безопасности COLDCARD и диапазоны затронутых версий прошивки
- [6] Coinkite — технический анализ проблемы энтропии
- [7] Coldcard Hack Tracker — актуальные суммарные данные по выводам для каждой волны
- [8] Публикация в X с утверждением о возможном постоянном «окирпичивании» устройства из-за сбоя ГСЧ после хотфикса
- [9] PR #692 прошивки Coldcard — предложение сообщества по восстановлению после сбоя TRNG (закрыт без слияния)
- [10] STMicroelectronics RM0351 — регистры ГСЧ STM32L4 и управление ошибками
- [11] STMicroelectronics RM0432 — регистры ГСЧ STM32L4S и управление ошибками
- [12] PR #693 прошивки Coldcard — влитое восстановление TRNG с ограниченными повторными попытками и обработка ошибок клавиатуры



