Уязвимость аппаратных кошельков Coldcard: в атаке 2026 года похищено 1 719 BTC
Сводка рынка от ИИ
Сообщалось, что уязвимость энтропии в прошивке Coldcard позволяла офлайн перебором восстановить сид-фразы на устройствах с настройками по умолчанию, что привело к подтверждённой краже как минимум 1 719 BTC по тысячам адресов. Инцидент значим тем, что переосмысливает риск "холодного хранения" вокруг качества генерации ключей, а не компрометации сети, потенциально оказывая давление на доверие к аппаратным кошелькам, побуждая к срочным обновлениям прошивки и усиливая в ближайшей перспективе обусловленные безопасностью перемещения средств и контроль за практиками хранения BTC.
Степень влияния
● Высокая
Затронутые активы
BTC/USDT-1.73%
Инсайт ИИ · BTC/USDTИнсайт ИИ
▼ Медвежий
Торговать
⚠️ Инсайты, сгенерированные ИИ, основаны на новостном контенте и предоставляются исключительно в информационных целях. Они не являются инвестиционной рекомендацией и не отражают позицию BingX. Торговля сопряжена с риском. Пожалуйста, торгуйте ответственно.
Авторы: Johan и Lisa. Редактура: 77.
30 июля 2026 года в сети Bitcoin произошла серия последовательных транзакций: за 41 минуту злоумышленник опустошил 1 196 одноподписных адресов, что привело к исчезновению примерно 1 082 BTC. Это была лишь первая волна. К началу августа подтверждённые потери достигли как минимум 1 719 BTC (около $111 млн), затронув более 5 200 адресов; атака проходила в три-четыре волны.
Наиболее необычным оказался профиль пострадавших кошельков: многие из них годами не двигали средства и хранили их офлайн, а проблема лежала на уровне приватных ключей. Все скомпрометированные ключи были сгенерированы аппаратными кошельками Coldcard, при этом владельцы не добавляли дополнительную энтропию через броски кубика и не включали BIP39 passphrase. Во всех случаях использовалась самая простая конфигурация по умолчанию.
Масштаб риска зависит от линейки и прошивки. Для Mk2 и Mk3 с версиями 4.0.1–4.1.9 эффективная энтропия оценивается примерно в 40 бит — это самый тяжёлый сценарий. Для Mk4, Mk5 и Q на отдельных версиях прошивок энтропия около 72 бит, что всё ещё укладывается в рамки офлайн-брутфорса.
Производитель Coinkite отреагировал быстро: 30 июля выпустил предупреждение, а на следующий день — исправленные прошивки. Рекомендованные версии: для Mk2 и Mk3 — 4.2.0 и выше, для Mk4 и Mk5 — 5.6.0 и выше, для Q — 1.5.0Q и выше. Компания также уничтожила остатки уязвимых партий и приостановила отгрузки.
Случившееся не связано ни с удалённым взломом, ни с компрометацией цепочки поставок: злоумышленник не получал доступ к устройствам физически. Ключи были получены офлайн путём перебора — систематическим тестированием возможных сидов в пространстве поиска. Уязвимость годами оставалась незаметной, пока внешние исследователи не разработали инструмент для брутфорса. Позднее Muan воспроизвёл всю цепочку атаки. Разбор начинается с кода прошивки (на примере Mk3 v4.1.9) и показывает, как одна строка конфигурации позволила массово "осушить" тысячи устройств.
Причина уязвимости
Coldcard должен генерировать сиды с использованием встроенного аппаратного TRNG (true random number generator) чипа STM32L475. Но сочетание двух ошибок в прошивке фактически превратило генерацию в программный PRNG с почти одинаковым состоянием на устройствах по всему миру. Этот PRNG называется Yasmarang; для Mk3 он снижает эффективную энтропию примерно до 40 бит.
Ошибка 1: аппаратный RNG был отключён
В конфигурации сборки Coldcard явно отключает поддержку аппаратного RNG в MicroPython через mpconfigboard.h:
// stm32/COLDCARD/mpconfigboard.h
// The team has implemented their own version of ckcc.rng_bytes, so disable MicroPython's builtin version
#define MICROPY_HW_ENABLE_RNG (0)
На первый взгляд это выглядит безобидно: у Coinkite есть собственный ckcc.rng_bytes, который напрямую обращается к TRNG STM32 и даёт больше контроля, чем реализация на уровне порта MicroPython. Проблема скрыта ниже по цепочке: libngu в my_random_bytes() получает случайные данные через макрос CHIP_TRNG_32(), который после развёртки вызывает rng_get() из STM32-порта MicroPython, считая его аппаратным TRNG. При MICROPY_HW_ENABLE_RNG = 0 функция rng_get() незаметно переключается на программный фолбэк, что приводит ко второй ошибке.
Ошибка 2: программный фолбэк rng_get() приняли за аппаратный TRNG
При разборе libngu random.c и последовательном раскрытии макросов исследователи выходят на ветку else в ports/stm32/rng.c (порт MicroPython для STM32):
// external/libngu/ngu/random.c
#ifdef MICROPY_PY_STM
// ports/stm32/rng.c
extern uint32_t rng_get(void);
# define CHIP_TRNG_SETUP()
# define CHIP_TRNG_32() rng_get()
#endif
...
void my_random_bytes(uint8_t *dest, uint32_t count) {
uint32_t chip = CHIP_TRNG_32();
// Assumes reading from hardware TRNG, but may actually receive Yasmarang output
if (chip == last) ...
chip ^= my_yasmarang();
// XORs with a global constant Yasmarang stream (pad=0x0a8ce26f)
...
}
Сам rng_get() устроен так: если MICROPY_HW_ENABLE_RNG ненулевой — он читает RNG>DR (аппаратный TRNG). Если равен нулю — компилируется ветка else и возвращается вывод программного PRNG. В конфигурации Coldcard значение как раз 0, поэтому каждый Mk3 "из коробки" идёт по уязвимому пути.
// external/micropython/ports/stm32/rng.c
#if MICROPY_HW_ENABLE_RNG
uint32_t rng_get(void) { ... read RNG>DR hardware TRNG ... }
#else
// Vulnerability point: Fallback exists, and seed is almost entirely predictable
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; // pad = UID ^ SysTick
n = RTC>TR; // n = RTC_TR
d = RTC>SSR; // d = RTC_SSR
}
pad += dat + d * n;
pad = (pad > 29);
n = pad | 2;
d ^= (pad > 1);
dat ^= (char)pad ^ (d >> 8) ^ 1;
return pad ^ (d > 18) ^ (dat ...);
}
#endif
Семя для Yasmarang формируется из UID микроконтроллера и SysTick>VAL при первом вызове rng_get(). Чип работает на 80 MHz при значении перезагрузки 80 000, что даёт диапазон 0–79 999, то есть около 17 бит энтропии. RTC_TR и RTC_SSR — значения RTC-регистров сразу после включения питания, до инициализации приложением; их пространство значений небольшое. RTC>TR хранится в packed-BCD, а у rtc_ssr максимум MK3_RTC_SSR_MAX = 0xFF. По проверенным on-chain "попаданиям" оба значения были равны 0.
В сумме вклад UID оценивается примерно в 14 бит, SysTick — около 17 бит, оба RTC-регистра — 0 бит, нажатия кнопок добавляют порядка 5 бит. Истинный источник энтропии для генерации сида даёт примерно 36–37 бит. Заявление Coinkite о "примерно 40 битах" совпадает с оценкой пространства поиска, полученной при реверс-инжиниринге: такой объём перебирается GPU-кластером за несколько дней.
Как проводилась атака
При первом запуске Mk3 случайные данные расходуются по трём типовым профилям потребления. Задача атакующего — смоделировать каждый профиль и точно воспроизвести все шаги PRNG.
Фаза 1: начальное состояние
Сразу после включения питания происходят два события:
Power on
└── libngu static Yasmarang (ngu/random.c) pad=0x0a8ce26f n=69 d=233 dat=0 ← глобальная константа
└── rng_get() initial call seeding (ports/stm32/rng.c) pad=UID^SysTick n=RTC_TR=0 d=RTC_SSR=0 dat=0
На этом этапе вся "случайность" сжата до 32-битного pad плюс двух малых измерений, которые можно перечислить перебором. Глобальный pad Mk3 оказывается заперт в небольшом, исчерпаемом диапазоне.
Фаза 2: нажатия кнопок
При первичной настройке пользователь обязан задать PIN, подтвердить условия кнопкой OK и перемещаться по меню. Каждое нажатие вызывает _start_scan() в shared/mempad.py, откуда через shuffle(self.scan_order) трижды вызывается _rand_below(). Длина scan_order равна NUM_ROWS, то есть 4. shuffle определён в shared/random.py, а randbelow напрямую связан с ngu.random.uniform — C-реализацией _rand_below.
# shared/random.py
import ngu
randbelow = ngu.random.uniform
bytes = ngu.random.bytes
def shuffle(lst):
# Fisher-Yates
for i in reversed(range(1, len(lst))):
j = randbelow(i + 1)
lst[i], lst[j] = lst[j], lst[i]
# shared/mempad.py
# Each key press > _start_scan() > shuffle(self.scan_order)
# scan_order length = NUM_ROWS = 4
После анализа исходников прошивки v4.1.9 выделены три профиля.
Профиль A: стандартная первичная настройка. Пользователь дважды нажимает перед принятием условий; затем settings.save() ищет пустой слот среди 32, исключая my_pos, то есть остаётся 31 вариант и выполняется 30 вызовов _rand_below. Далее ввод PIN и навигация по меню (число нажатий kpad_b перебирается в диапазоне 4–34), после чего запрашивается random_bytes(32).
kpad_a × shuffle(4) # нажатия до accept_terms (kpad_a = 2)
shuffle(31) # settings.save(): 30 вызовов _rand_below
kpad_b × shuffle(4) # ввод PIN + меню (kpad_b ∈ [4, 34])
random_bytes(32) # сбор энтропии
Профиль B: первый запуск после прошивки или очистки при пустом NVRAM. nvstore делает один shuffle(32), затем потребляет 3 072 шага синхронно по схеме 3 слота × 16 блоков × 256 байт, не возвращая выход обратно (состояние лишь продвигается). При "пустом" запуске NVRAM добавляется ещё один shuffle(32), затем нажатия кнопок и финальный my_random_bytes(32).
nvstore shuffle(32) # 31 вызов _rand_below
nvstore blanking 3×16×256B # 3 072 шага потребления
emptynvram onboarding: shuffle(32)
Key press consumption (kpad_b ∈ [4, 34]) # каждый раз shuffle(4)
my_random_bytes(32)
Профиль C: paper wallet. В меню Paper Wallets Coldcard генерирует страницы для печати. После создания кошелька повторный вход в меню требует 8–25 нажатий, затем my_random_bytes(32) используется напрямую как приватный ключ, минуя BIP39-словарь.
Фаза 3: построение сида и вывод адреса
От random_bytes(32) до итогового Bitcoin-адреса Mk3 проходит детерминированный конвейер:
raw_bytes = random_bytes(32) # 4 байта из 8 итераций (mpy_step ^ yas_step)
entropy = ngu.hash.sha256s(raw_bytes) # один SHA256, не sha256d
mnemonic = BIP39(entropy, wordlist=english) # 24 слова
seed = PBKDF2HMACSHA512(mnemonic, "mnemonic", 2048)
master = HMACSHA512("Bitcoin seed", seed)
child = m/{44,49,84}'/0'/0'/0/0
address = bech32(hash160(compressed_pubkey)) # по умолчанию BIP84
Поскольку каждый шаг воспроизводим, достаточно угадать pad — и вся цепочка пересчитывается офлайн.
Фаза 4: брутфорс на GPU и поиск совпадений
Метод злоумышленника укладывается в четыре шага.
1) Сформировать набор кандидатных pad. Координаты кристалла X и Y берутся в диапазоне 0–72, комбинируются с пространством UID примерно из 5 300 значений и перемножаются с SysTick от 0 до 79 999. Итог — около 424 млн кандидатных pad. Для GPU это задача на несколько дней.
2) Для каждого pad перебрать число нажатий кнопок: kpad_b от 4 до 34. Так как по данным "попаданий" rtc_tr и rtc_ssr равны 0, их оставляют во внешних циклах, а ключевой перебор — по нажатиям.
3) Запустить полный конвейер на GPU: SHA256, BIP39, PBKDF2 (2048 раундов), BIP32, Hash160 и Bech32. Постоянный поток libngu можно заранее предвычислить и доставать по индексу, чтобы не заставлять каждый поток отдельно прокручивать константный PRNG.
4) Сопоставлять результаты через Bloom filter или отсортированный массив hash160 (сложность O(log n)), нацеливаясь на множество одноподписных P2WPKH-адресов.
Затраты на поиск оценены количественно. Прогон пространства профиля A на одном Apple M1 GPU (72×72 UID, 80 000 вариантов SysTick и 31 вариант числа нажатий) дал около 14,8 млрд кандидатов и занял примерно 8,6 дня. Кластер уровня дата-центра на A100 сжимает тот же поиск до нескольких часов.
Для Mk4, Mk5 и Q пространство около 72 бит примерно в 2^32 раз больше, чем у Mk3. Источник проблемы тот же, а подход с моделированием профилей сохраняется; меняется лишь масштаб вычислений: от "дней" к "неделям" и от одной машины к кластеру. По оценкам исследователей, такие затраты остаются приемлемыми для атакующих, что объясняет присутствие этих моделей среди списков жертв.