Lộ lỗ hổng ví cứng Coldcard: 1.719 BTC bị đánh cắp trong cuộc tấn công năm 2026
Tóm tắt thị trường bằng AI
Một lỗ hổng entropy trong firmware Coldcard được báo cáo đã cho phép khôi phục seed bằng cách brute-force ngoại tuyến đối với các thiết bị ở cấu hình mặc định, dẫn đến hành vi trộm cắp đã được xác nhận ít nhất 1.719 BTC trên hàng nghìn địa chỉ. Sự cố này đáng chú ý vì nó định hình lại rủi ro của "lưu trữ lạnh" xoay quanh chất lượng tạo khóa thay vì việc bị xâm nhập mạng, có thể gây áp lực lên niềm tin đối với ví cứng, thúc đẩy các bản cập nhật firmware khẩn cấp, và làm gia tăng các dịch chuyển quỹ do an ninh thúc đẩy trong ngắn hạn cũng như sự giám sát chặt chẽ hơn trên các thực hành lưu ký BTC.
Mức ảnh hưởng
● Cao
Tài sản bị ảnh hưởng
BTC/USDT-1.73%
Quan điểm AI · BTC/USDTQuan điểm AI
▼ Giá giảm
Khám phá ngay
⚠️ Nhận định từ AI được tổng hợp từ tin tức và chỉ có giá trị tham khảo. Đây không phải là lời khuyên đầu tư và không thể hiện quan điểm của BingX. Đầu tư luôn đi kèm rủi ro. Vui lòng giao dịch có trách nhiệm.
Tác giả: Johan & Lisa | Biên tập: 77
Bối cảnh
Ngày 30/7/2026, một loạt địa chỉ trên blockchain lần lượt chuyển sạch số dư. Chỉ trong 41 phút, 1.196 địa chỉ singlesignature bị vét trống, tương đương khoảng 1.082 BTC biến mất. Đây mới là làn sóng đầu tiên. Đến đầu tháng 8, tổng thiệt hại đã được xác nhận tối thiểu 1.719 BTC (xấp xỉ 111 triệu USD), liên quan hơn 5.200 địa chỉ; vụ việc diễn ra qua 3–4 đợt.
Điểm khó hiểu nằm ở tình trạng ví: phần lớn số tiền vốn nằm yên trong cold wallet suốt nhiều tháng, thậm chí nhiều năm. Vấn đề không đến từ hoạt động trực tuyến mà nằm ở private key. Tất cả private key bị lộ đều được tạo bằng ví cứng Coldcard, chủ ví không bổ sung thêm entropy bằng xúc xắc và cũng không bật BIP39 passphrase. Các ví bị ảnh hưởng đều dùng cấu hình mặc định, đơn giản nhất.
Phạm vi tác động được phân tách theo dòng firmware. Với Mk2 và Mk3 chạy firmware 4.0.1 đến 4.1.9, entropy hiệu dụng chỉ khoảng 40 bit, nghiêm trọng nhất. Với Mk4, Mk5 và Q trên một số phiên bản firmware nhất định, entropy khoảng 72 bit, vẫn nằm trong vùng có thể brute force offline.
Coinkite phản ứng nhanh. Ngày 30/7 họ phát cảnh báo, ngày 31/7 phát hành firmware vá: Mk2 và Mk3 cần cập nhật lên 4.2.0 trở lên, Mk4 và Mk5 lên 5.6.0 trở lên, Q lên 1.5.0Q trở lên. Công ty cũng tiêu hủy toàn bộ tồn kho các phiên bản dễ tổn thương và tạm dừng giao hàng.
Vụ tấn công không liên quan xâm nhập từ xa hay đầu độc chuỗi cung ứng. Kẻ tấn công không hề chạm vào thiết bị; private key bị thu được bằng brute force offline, thử có hệ thống từng seed có thể có trong không gian tìm kiếm. Lỗ hổng đã tồn tại âm thầm nhiều năm cho đến khi các nhà nghiên cứu bên ngoài phát triển công cụ brute force và làm nó lộ diện. Muan sau đó tái hiện được toàn bộ chuỗi tấn công.
Bài viết này đi từ mã firmware, lấy Mk3 firmware 4.1.9 làm ví dụ, để lần ra đúng một dòng code đã mở đường cho việc rút sạch hàng nghìn thiết bị Coldcard.
Nguyên nhân gốc rễ
Coldcard được thiết kế để sinh seed bằng TRNG (true random number generator) phần cứng tích hợp trong chip STM32L475. Hai lỗi trong firmware đã kết hợp với nhau, biến quá trình sinh seed thành PRNG phần mềm có trạng thái gần như giống nhau trên mọi thiết bị toàn cầu. PRNG đó là Yasmarang, kéo entropy hiệu dụng trên Mk3 xuống khoảng 40 bit.
Sai lầm 1: Chủ động tắt RNG phần cứng
Lỗi đầu tiên nằm ở cấu hình build: Coldcard tắt hỗ trợ RNG phần cứng của MicroPython trong 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)
Thoạt nhìn, macro này có vẻ vô hại vì Coinkite có ckcc.rng_bytes riêng, gọi trực tiếp TRNG phần cứng của STM32 và cho phép kiểm soát tốt hơn so với lớp port của MicroPython. Vấn đề nằm ở chuỗi macro phía dưới: my_random_bytes() của libngu đọc số ngẫu nhiên qua CHIP_TRNG_32(); sau khi bung macro, nó gọi rng_get() do lớp port STM32 của MicroPython cung cấp và coi đó là TRNG phần cứng. Khi MICROPY_HW_ENABLE_RNG bị đặt bằng 0, rng_get() âm thầm rơi về PRNG phần mềm, dẫn sang sai lầm thứ hai.
Sai lầm 2: Nhầm cơ chế fallback của rng_get() là TRNG phần cứng
Nhóm nghiên cứu xem random.c của libngu. Lần theo từng bước bung macro, cuối cùng họ chạm vào nhánh else trong ports/stm32/rng.c (lớp port STM32 của MicroPython).
// 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() nằm trong ports/stm32/rng.c. Hàm này dùng #if MICROPY_HW_ENABLE_RNG: nếu macro khác 0, nó đọc RNG>DR (TRNG phần cứng). Nếu macro bằng 0, nhánh else được biên dịch, trả về PRNG phần mềm. Cấu hình của Coldcard đặt đúng bằng 0, nên mọi Mk3 xuất xưởng đều đi theo đường này.
// 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 ...
}
#endif
Trong đó, UID chỉ đóng góp khoảng 14 bit. SysTick>VAL tại lần gọi rng_get() đầu tiên nằm trong khoảng 0–79.999 (chip chạy 80 MHz với reload 80.000), tương đương khoảng 17 bit entropy. RTC_TR và RTC_SSR là giá trị thanh ghi RTC ngay sau khi cấp nguồn, trước khi ứng dụng khởi tạo. Mỗi thành phần chỉ tạo thêm một chiều liệt kê nhỏ. rtc_tr là RTC>TR mã BCD đóng gói; rtc_ssr có giá trị tối đa MK3_RTC_SSR_MAX = 0xFF. Trên các hit vector on-chain đã kiểm chứng, cả hai giá trị đều bằng 0.
Cộng gộp lại: UID ~14 bit, SysTick ~17 bit, hai giá trị RTC đóng góp 0 bit, thao tác bấm phím thêm khoảng 5 bit. Nguồn entropy thật sự cho toàn bộ quá trình sinh seed chỉ khoảng 36–37 bit. Con số "khoảng 40 bit" mà Coinkite nêu ra khớp với không gian tìm kiếm được suy ra từ reverse engineering—một không gian có thể bị GPU cluster brute force trong vài ngày.
Quy trình tấn công
Trong lần khởi động đầu tiên của Mk3, từ code đến private key, số ngẫu nhiên được tiêu thụ theo ba "profile" điển hình. Nhiệm vụ cốt lõi của kẻ tấn công là mô hình hóa từng profile và tái dựng chính xác từng bước tiến hóa của PRNG.
Giai đoạn 1: Trạng thái ban đầu
Ngay khi cấp nguồn, có hai việc xảy ra:
Power on
└── libngu static Yasmarang (ngu/random.c) pad=0x0a8ce26f n=69 d=233 dat=0 ← hằng số toàn cục
└── rng_get() initial call seeding (ports/stm32/rng.c) pad=UID^SysTick n=RTC_TR=0 d=RTC_SSR=0 dat=0
Tại đây, toàn bộ "ngẫu nhiên" bị nén vào một pad 32 bit, cộng thêm hai chiều nhỏ có thể liệt kê. Pad của Mk3 bị khóa trong một hộp tìm kiếm nhỏ, có thể vét cạn.
Giai đoạn 2: Tiêu thụ ngẫu nhiên từ thao tác bấm nút
Ở lần thiết lập đầu tiên, người dùng buộc phải đặt PIN, chấp nhận điều khoản bằng nút OK và di chuyển qua menu. Mỗi lần bấm phím kích hoạt _start_scan() trong shared/mempad.py, gọi _rand_below() ba lần thông qua shuffle(self.scan_order). scan_order có độ dài bằng NUM_ROWS = 4. shuffle nằm trong shared/random.py; randbelow được bind trực tiếp vào ngu.random.uniform của libngu (hàm C-level _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) # tiêu thụ 1 bước chip + 1 bước mixer (kèm retry do rejection sampling)
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
Các mẫu tiêu thụ này phải được mô hình hóa riêng. Phân tích mã nguồn firmware v4.1.9 cho thấy có ba pattern sử dụng chính:
Profile A (thiết lập lần đầu kiểu bán lẻ): bấm 2 lần trước khi chấp nhận điều khoản; settings.save() tìm slot trống trong 32 slot, loại my_pos còn 31 slot, dẫn đến 30 lần gọi _rand_below; sau đó nhập PIN, điều hướng menu, bấm thêm vài lần, cuối cùng lấy entropy bằng random_bytes(32).
kpad_a × shuffle(4) # bấm trước accept_terms (kpad_a = 2)
shuffle(31) # settings.save(): 31 slot → 30 lần _rand_below
kpad_b × shuffle(4) # nhập PIN + điều hướng menu (kpad_b trong [4, 34])
random_bytes(32) # gom entropy
Profile B (khởi động lần đầu sau khi flash hoặc wipe, NVRAM trống): nvstore thực hiện một shuffle(32), sau đó tiêu thụ 3.072 bước đồng bộ theo 3 slots × 16 blocks × 256 bytes, không xuất dữ liệu phản hồi, chỉ tiến trạng thái. Với NVRAM trống, hệ thống thực hiện thêm một shuffle(32), rồi mới đến thao tác bấm phím và cuối cùng my_random_bytes(32).
nvstore shuffle(32) # 31 lần _rand_below
nvstore blanking 3×16×256B # 3.072 bước tiêu thụ đồng bộ
emptynvramonboarding profile # thêm shuffle(32) sau hai bước trên
Key press consumption (kpad_b [4, 34])
my_random_bytes(32)
Profile C (paper wallet): sau khi đã có ví, vào lại menu Paper Wallets thường cần bấm 8–25 lần, rồi my_random_bytes(32) được dùng trực tiếp làm private key, bỏ qua hoàn toàn danh sách từ BIP39.
Giai đoạn 3: Dựng seed và suy ra địa chỉ
Từ random_bytes(32) đến địa chỉ Bitcoin cuối cùng, Mk3 đi theo pipeline sau:
raw_bytes = random_bytes(32)
entropy = ngu.hash.sha256s(raw_bytes) # SHA256 một lần
mnemonic = BIP39(entropy, wordlist=english) # 24 từ
seed = PBKDF2HMACSHA512(mnemonic, "mnemonic", 2048)
master = HMACSHA512("Bitcoin seed", seed)
child = m/{44,49,84}'/0'/0'/0/0
address = bech32(hash160(compressed_pubkey)) # mặc định BIP84
Mọi bước đều là hàm xác định và có thể tái lập. Khi đoán đúng pad, kẻ tấn công có thể tính lại toàn bộ chuỗi offline.
Giai đoạn 4: Brute force trên GPU và đối sánh hit
Cách làm của kẻ tấn công gồm bốn bước:
1) Tạo tập pad ứng viên: lấy tọa độ wafer X và Y từ 0 đến 72, kết hợp không gian UID khoảng 5.300 giá trị, rồi nhân Descartes với SysTick từ 0 đến 79.999, tạo ra 424 triệu pad ứng viên. Nghe lớn nhưng GPU có thể xử lý trong vài ngày.
2) Liệt kê số lần bấm phím cho mỗi pad, với kpad_b trong khoảng 4–34. Do rtc_tr và rtc_ssr đều có hit bằng 0, có thể đặt chúng ở vòng lặp ngoài cùng để giảm chi phí.
3) Chạy toàn bộ pipeline seed trong kernel GPU: SHA256, BIP39, PBKDF2 2.048 vòng, BIP32, Hash160 và Bech32. Luồng hằng số từ libngu có thể precompute offline; kernel chỉ cần lấy theo chỉ số, tránh việc mỗi thread tự tiến PRNG.
4) Đối sánh bằng Bloom filter hoặc mảng đã sắp xếp các giá trị hash160, độ phức tạp O(log n), nhắm vào tập địa chỉ P2WPKH singlesignature trên mạng.
Chi phí tìm kiếm có thể định lượng: chạy không gian Phase A trên một GPU Apple M1 (UID 72×72, 80.000 biến SysTick và 31 mức số lần bấm phím), tổng khoảng 14,8 tỷ ứng viên, mất khoảng 8,6 ngày. Với cụm A100 cấp data center, thời gian có thể rút xuống vài giờ.
Không gian ~72 bit của Mk4, Mk5 và Q lớn hơn Mk3 khoảng 2^32 lần. Dù vậy, các lỗ hổng có cùng nguồn gốc và cách mô hình hóa profile vẫn tương tự. Khác biệt chủ yếu nằm ở quy mô: từ một máy lên cả cụm GPU, thời gian từ vài ngày kéo thành vài tuần—mức chi phí mà kẻ tấn công vẫn sẵn sàng trả. Vì thế các dòng này cũng xuất hiện trong danh sách nạn nhân.