Вразливість апаратних гаманців Coldcard: у атаці 2026 року викрадено 1 719 BTC

Ринкове зведення ШІ
Повідомлялося, що вразливість ентропії у прошивці Coldcard дозволила офлайн-відновлення сід-фраз методом повного перебору для пристроїв із налаштуваннями за замовчуванням, що призвело до підтвердженої крадіжки щонайменше 1,719 BTC через тисячі адрес. Інцидент є значущим, оскільки він переосмислює ризики "холодного зберігання" навколо якості генерації ключів, а не компрометації мережі, потенційно посилюючи тиск на довіру до апаратних гаманців, спонукаючи до термінових оновлень прошивки та збільшуючи у короткостроковій перспективі рухи коштів, зумовлені міркуваннями безпеки, і підвищену увагу до практик зберігання BTC.
Рівень впливу
● Високий
Активи, яких стосується
BTC/USDT-1.03%
Інсайт ШІ · BTC/USDTІнсайт ШІ
▼ Ведмежий
Торгувати
⚠️ Інсайти, згенеровані ШІ, ґрунтуються на новинних матеріалах і надаються виключно з інформаційною метою. Вони не є інвестиційною порадою та не відображають поглядів BingX. Інвестування пов’язане з ризиком. Будь ласка, торгуйте відповідально.
Автори: Johan і Lisa Редакція: 77 Контекст 30 липня 2026 року низка адрес у блокчейні почала послідовно виводити кошти. За 41 хвилину було спорожнено 1 196 одно-підписних адрес, зникло близько 1 082 BTC. Це була лише перша хвиля. На початку серпня підтверджені втрати сягнули щонайменше 1 719 BTC (приблизно $111 млн) і охопили понад 5 200 адрес; атака відбулася у три–чотири хвилі. Найбільша загадка — стан цих гаманців. Більшість монет трималися в "холодних" сховищах і не рухалися місяцями або роками, тож проблема виявилась на рівні приватних ключів. Усі скомпрометовані ключі були згенеровані апаратними гаманцями Coldcard, причому власники не додавали додаткової ентропії кидками кубиків і не вмикали BIP39-парольну фразу. Використовувалися найпростіші налаштування за замовчуванням. Масштаб впливу оцінюють окремо за лінійками прошивок. Для 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 або вище. Також компанія знищила складські запаси уразливих версій і призупинила відвантаження. Атака не пов'язана з віддаленим зламом чи "отруєнням" ланцюга постачання: нападник не торкався пристроїв. Приватні ключі було отримано через офлайн-брутфорс — систематичне тестування можливих seed у просторі пошуку. Вразливість роками залишалася непоміченою, доки зовнішні дослідники не розробили інструмент для перебору, що й вивело проблему на поверхню. Згодом Muan відтворив повний ланцюжок атаки. Матеріал розбирає код прошивки (на прикладі Mk3 v4.1.9) і відновлює одну критичну строку, яка відкрила шлях до масового спустошення тисяч пристроїв Coldcard. Першопричина Coldcard мав генерувати seed на основі вбудованого апаратного TRNG (true random number generator) у чипі STM32L475. Дві помилки в прошивці разом перетворили генерацію seed на програмний 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's my_random_bytes() отримує "випадкові" числа через CHIP_TRNG_32(), який після розгортання викликає rng_get() з STM32-порту MicroPython і сприймає його як апаратний TRNG. Але при MICROPY_HW_ENABLE_RNG=0 rng_get() непомітно деградує до софтверного PRNG, що веде до другої помилки. Помилка 2: fallback rng_get() помилково сприймався як апаратний TRNG Під час аналізу random.c у libngu і послідовного розгортання макросів ланцюжок приводить до гілки 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) ... // Reports error if two consecutive words are identical chip ^= my_yasmarang(); // XORs with a global constant Yasmarang stream (pad=0x0a8ce26f) ... } Функція rng_get() визначається у ports/stm32/rng.c. Вона компілюється за умовою #if 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 // MICROPY_HW_ENABLE_RNG // 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 VAL when rng_get() is first called. Пояснення джерел "ентропії" у цій гілці: - UID мікроконтролера потрапляє в pad. - SysTick>VAL на першому виклику rng_get() додає ще одну складову. Чип працює на 80 МГц із reload 80 000, тому значення лежить у межах 0–79 999 — це близько 17 біт ентропії. - RTC_TR і RTC_SSR беруться одразу після подачі живлення, ще до ініціалізації застосунком. Кожен дає дуже малий простір значень. rtc_tr — це packed BCD-кодування RTC>TR, rtc_ssr має максимум MK3_RTC_SSR_MAX = 0xFF. У всіх перевірених on-chain збігах обидва значення (RTC_TR і RTC_SSR) дорівнювали 0. У сумі виходить: UID дає приблизно 14 біт, SysTick — близько 17 біт, обидва RTC — 0 біт, натискання кнопок додають близько 5 біт. Отже, реальна ентропія всього процесу генерації seed — 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, що тричі викликає _rand_below() через shuffle(self.scan_order). Довжина scan_order дорівнює NUM_ROWS = 4. shuffle визначено у shared/random.py, а randbelow напряму прив'язано до ngu.random.uniform у libngu (C-рівень _rand_below). # shared/random.py import ngu randbelow = ngu.random.uniform # Equivalent to _rand_below in libngu random.c bytes = ngu.random.bytes # Equivalent to my_random_bytes def shuffle(lst): # Fisher-Yates, same as CPython random.py for i in reversed(range(1, len(lst))): j = randbelow(i + 1) # ← споживає 1 крок chip + 1 крок mixer (з урахуванням повторів rejection sampling) lst[i], lst[j] = lst[j], lst[i] # shared/mempad.py # Кожне натискання > _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, навігація меню, ще кілька натискань і завершальний збір "ентропії" random_bytes(32). kpad_a × shuffle(4) # натискання до accept_terms (kpad_a = 2) shuffle(31) # settings.save(): 31 слот = 30 викликів _rand_below kpad_b × shuffle(4) # введення PIN + навігація (kpad_b перебирається в діапазоні [4, 34]) random_bytes(32) # збір ентропії Профіль B: перший запуск після прошивки або стирання, NVRAM порожня nvstore спочатку робить shuffle(32), потім у "крок-у-крок" режимі споживає 3072 кроки по 3 слоти × 16 блоків × 256 байт без зворотного зв'язку (стан лише просувається). Для запуску з порожньою NVRAM додається ще один shuffle(32), далі споживання натискань і завершальний my_random_bytes(32). nvstore shuffle(32) # 31 виклик _rand_below nvstore blanking 3×16×256B # 3072 lockstep-споживання emptynvram onboarding profile # додатковий shuffle(32) після двох кроків вище Key press consumption (kpad_b [4, 34]) my_random_bytes(32) Профіль C: Paper Wallet Paper wallet — метод холодного зберігання, коли приватний ключ друкують на папері й зберігають офлайн. У меню Paper Wallets Coldcard генерує сторінки для друку. Споживання ентропії тут найпряміше: після створення існуючого гаманця повторний вхід у меню потребує 8–25 натискань, після чого my_random_bytes(32) використовується як приватний ключ напряму, минаючи BIP39-словник. Фаза 3: побудова seed і виведення адреси Від 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 # перша receiving-адреса address = bech32(hash160(compressed_pubkey)) # за замовчуванням BIP84 Усі кроки — детерміновані й відтворювані. Якщо нападник правильно вгадає pad, весь ланцюжок можна перерахувати офлайн. Фаза 4: брутфорс і знаходження збігів на GPU Метод нападника зводиться до чотирьох кроків. 1) Зібрати набір кандидатів pad: взяти wafer-координати X і Y від 0 до 72, поєднати з простором UID (~5 300 значень) і зробити декартовий добуток із SysTick у діапазоні 0–79 999. Виходить 424 млн кандидатів pad. Обсяг великий лише на вигляд — для GPU це кілька днів. 2) Для кожного pad перебрати кількість натискань (kpad_b від 4 до 34). Оскільки для rtc_tr і rtc_ssr зафіксовано нульові збіги, їх можна винести у зовнішній цикл, а ключові натискання — у внутрішній. 3) Запустити весь seed-конвеєр у GPU-ядрі: SHA256, BIP39, PBKDF2 на 2048 раундів, BIP32, Hash160 і Bech32. Константний потік libngu можна попередньо обчислити офлайн; у ядрі лишається лише доступ до слів за індексом, без накладних витрат на просування PRNG у кожному потоці. 4) Виконати зіставлення через Bloom filter або відсортований масив hash160 зі складністю O(log n), націлюючись на множину всіх одно-підписних P2WPKH-адрес у мережі. Оцінка вартості пошуку показова: перебір простору фази A на одному GPU Apple M1 (72×72 UID, 80 000 варіацій SysTick і 31 варіант кількості натискань), тобто близько 14,8 млрд кандидатів, зайняв приблизно 8,6 дня. Кластер рівня A100 у дата-центрі стискає той самий пошук до кількох годин. Простір ~72 біт для Mk4, Mk5 і Q у 2^32 рази більший, ніж у Mk3. Вразливості мають спільне походження, а підхід із моделюванням профілів зберігається. Змінюється лише масштаб ресурсів: замість однієї машини потрібен кластер, а горизонт атаки зсувається з днів до тижнів — витрати, на які зловмисники все ще готові йти. Саме тому ці моделі також трапляються у списках постраждалих.