ช่องโหว่ Coldcard ถูกเปิดโปง: โจมตีปี 2026 ขโมยบิตคอยน์ 1,719 BTC
มีรายงานว่าช่องโหว่ด้านเอนโทรปีในเฟิร์มแวร์ Coldcard ทำให้สามารถกู้คืนซีด (seed) ด้วยการเดารหัสแบบบรูตฟอร์ซแบบออฟไลน์ได้สำหรับอุปกรณ์ที่ตั้งค่าเริ่มต้น ส่งผลให้ยืนยันการถูกขโมยอย่างน้อย 1,719 BTC ผ่านที่อยู่หลายพันรายการ เหตุการณ์นี้มีนัยสำคัญเพราะได้ปรับกรอบความเสี่ยงของ "การเก็บแบบเย็น" ไปอยู่ที่คุณภาพการสร้างคีย์มากกว่าการถูกเจาะเครือข่าย ซึ่งอาจกดดันความเชื่อมั่นต่อฮาร์ดแวร์วอลเล็ต กระตุ้นให้มีการอัปเดตเฟิร์มแวร์อย่างเร่งด่วน และเพิ่มการเคลื่อนไหวของเงินทุนที่ขับเคลื่อนด้วยความปลอดภัยในระยะใกล้ รวมถึงการตรวจสอบอย่างเข้มงวดขึ้นในแนวปฏิบัติด้านการดูแลสินทรัพย์ BTC
สินทรัพย์ที่ได้รับผลกระทบ
ข้อมูลเชิงลึกจาก AI · BTC/USDTข้อมูลเชิงลึกจาก AI
▼ ขาลง
⚠️ ข้อความเชิงลึกนี้สร้างขึ้นโดย AI โดยอ้างอิงจากเนื้อหาข่าวเพื่อใช้เป็นข้อมูลอ้างอิงเท่านั้น ไม่ถือเป็นคำแนะนำในการลงทุนหรือสะท้อนทัศนะของ BingX การลงทุนมีความเสี่ยง โปรดซื้อขายด้วยความระมัดระวัง
ผู้เขียน: Johan & Lisa แก้ไข: 77
ภาพรวมเหตุการณ์
วันที่ 30 กรกฎาคม 2026 มีการพบว่าที่อยู่บนบล็อกเชนจำนวนหนึ่งทยอยโอนเงินออกอย่างเป็นลำดับ ภายใน 41 นาที ที่อยู่แบบ single-signature จำนวน 1,196 รายการถูกดูดเกลี้ยง ส่งผลให้บิตคอยน์ราว 1,082 BTC หายไป นี่เป็นเพียงระลอกแรก ภายในต้นเดือนสิงหาคม ยอดความเสียหายที่ยืนยันแล้วเพิ่มเป็นอย่างน้อย 1,719 BTC คิดเป็นมูลค่าราว 111 ล้านดอลลาร์สหรัฐ ครอบคลุมมากกว่า 5,200 ที่อยู่ โดยการโจมตีเกิดขึ้นรวม 3–4 ระลอก
จุดที่น่าฉงนที่สุดคือสถานะของกระเป๋าเงินเหยื่อ เงินส่วนใหญ่ค้างอยู่ใน cold wallet ที่แทบไม่เคลื่อนไหวมาหลายเดือนหรือหลายปี ปัญหาไม่ได้อยู่ที่การเข้าถึงอุปกรณ์ แต่เกิดในระดับ private key โดย private key ทั้งหมดถูกสร้างจากฮาร์ดแวร์วอลเล็ต Coldcard และผู้ใช้ไม่ได้เพิ่มเอนโทรปีด้วยลูกเต๋า (dice entropy) หรือเปิดใช้ BIP39 passphrase กระเป๋าที่ถูกเจาะใช้การตั้งค่าเริ่มต้นแบบเรียบที่สุด
การประเมินผลกระทบแยกตามสายเฟิร์มแวร์:
- Mk2 และ Mk3 ที่ใช้เฟิร์มแวร์ 4.0.1 ถึง 4.1.9 มีเอนโทรปีที่มีผลจริงเพียงราว 40 บิต ถือว่ารุนแรงที่สุด
- Mk4, Mk5 และ Q บางเวอร์ชันเฟิร์มแวร์มีเอนโทรปีราว 72 บิต ซึ่งยังอยู่ในขอบเขตที่สามารถทำ offline brute force ได้
Coinkite ตอบสนองอย่างรวดเร็ว โดยออกคำเตือนในวันที่ 30 กรกฎาคม และวันถัดมาได้ปล่อยเฟิร์มแวร์ที่แก้ไขแล้ว: Mk2 และ Mk3 ให้อัปเดตเป็น 4.2.0 ขึ้นไป, Mk4 และ Mk5 เป็น 5.6.0 ขึ้นไป และ Q เป็น 1.5.0Q ขึ้นไป บริษัทระบุว่าทำลายสต็อกเวอร์ชันที่มีปัญหาทั้งหมดและระงับการจัดส่งชั่วคราว
เหตุโจมตีครั้งนี้ไม่เกี่ยวกับการบุกรุกจากระยะไกลหรือการวางยาพิษซัพพลายเชน ผู้โจมตีไม่เคยแตะต้องอุปกรณ์ แต่ได้ private key ผ่านการ brute force แบบออฟไลน์ โดยไล่ทดสอบ seed ทุกความเป็นไปได้ในพื้นที่ค้นหา ช่องโหว่นี้อยู่ในอุปกรณ์มาหลายปีจนกระทั่งนักวิจัยภายนอกพัฒนาเครื่องมือ brute force และทำให้ปัญหาถูกเปิดเผย ต่อมา Muan สามารถทำซ้ำห่วงโซ่การโจมตีทั้งหมดได้
บทความต้นทางเริ่มจากโค้ดเฟิร์มแวร์ โดยยก Mk3 เฟิร์มแวร์ 4.1.9 เป็นตัวอย่าง เพื่อย้อนรอยบรรทัดโค้ดเพียงบรรทัดเดียวที่เปิดทางให้ดูดเงินออกจาก Coldcard จำนวนมากได้
สาเหตุรากของช่องโหว่
Coldcard ถูกออกแบบให้สร้าง seed โดยใช้ฮาร์ดแวร์ TRNG (true random number generator) ภายในชิป STM32L475 แต่ความผิดพลาด 2 จุดในเฟิร์มแวร์ทำให้กระบวนการกลายเป็นซอฟต์แวร์ PRNG ที่มีสถานะใกล้เคียงกันมากในทุกอุปกรณ์ทั่วโลก PRNG ที่ถูกใช้นี้ชื่อ Yasmarang ทำให้เอนโทรปีที่มีผลจริงของ Mk3 ลดลงเหลือราว 40 บิต
ความผิดพลาดที่ 1: ปิดการใช้ฮาร์ดแวร์ RNG โดยตั้งใจ
จุดแรกอยู่ในค่าคอนฟิกการ build โดย 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 ของตัวเองที่เรียก STM32 TRNG โดยตรงและควบคุมได้มากกว่า แต่ปัญหาอยู่ที่ผลจากการขยายมาโครในส่วนถัดไป: ฟังก์ชัน my_random_bytes() ของ libngu อ่านเลขสุ่มผ่านมาโคร CHIP_TRNG_32() ซึ่งเมื่อขยายแล้วจะไปเรียก rng_get() ของพอร์ต MicroPython STM32 และถือว่าเป็นฮาร์ดแวร์ TRNG ทั้งที่ rng_get() จะทำงานแบบลดระดับเงียบ ๆ เมื่อ MICROPY_HW_ENABLE_RNG ถูกตั้งเป็น 0 นำไปสู่ความผิดพลาดข้อสอง
ความผิดพลาดที่ 2: ตีความ fallback ของ rng_get() ว่าเป็นฮาร์ดแวร์ TRNG ทั้งที่เป็นซอฟต์แวร์ PRNG
การไล่ดูจาก 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) ...
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.
การ seed ครั้งแรกของ Yasmarang ใช้แหล่งที่คาดเดาได้มาก: UID (unique ID) ของชิป, ค่า SysTick>VAL ตอนเรียกครั้งแรก และค่า RTC>TR กับ RTC>SSR หลังเปิดเครื่องก่อนที่แอปจะตั้งค่า RTC อย่างเหมาะสม
ใน Mk3 ชิปทำงานที่ 80 MHz และมี reload 80,000 ทำให้ SysTick มีค่าได้ตั้งแต่ 0 ถึง 79,999 หรือราว 17 บิตของเอนโทรปี ส่วน UID ให้เอนโทรปีราว 14 บิต ค่า RTC_TR เป็น packed BCD ของ RTC>TR และ RTC_SSR มีค่าสูงสุด 0xFF แต่จากเวกเตอร์บนเชนที่ตรวจสอบแล้ว ทั้ง RTC_TR และ RTC_SSR ให้ค่าเป็น 0 ในทุกกรณีที่ยืนยันได้ หากนับการกดปุ่มเพิ่มเอนโทรปีราว 5 บิต จะได้แหล่งเอนโทรปีจริงของการสร้าง seed ทั้งหมดราว 36–37 บิต ซึ่งสอดคล้องกับคำอธิบายของ Coinkite ที่ระบุ "ประมาณ 40 บิต" และเป็นขนาดพื้นที่ค้นหาที่ GPU cluster สามารถ brute force ได้ในไม่กี่วัน
กระบวนการโจมตี
ในการบูตครั้งแรกของ Mk3 ตั้งแต่โค้ดไปจนถึง private key จะมีรูปแบบการใช้เลขสุ่ม (random consumption profiles) หลัก ๆ 3 แบบ ภารกิจสำคัญของผู้โจมตีคือจำลองแต่ละโปรไฟล์และสร้างลำดับการทำงานของ PRNG ทุกขั้นให้ตรง
เฟส 1: สถานะเริ่มต้น
ทันทีที่เปิดเครื่องจะเกิดสองเหตุการณ์:
- libngu ตั้งค่า Yasmarang แบบสแตติก (ngu/random.c) ด้วยค่า pad=0x0a8ce26f, n=69, d=233, dat=0 ซึ่งเป็นค่าคงที่ระดับโกลบอล
- rng_get() ถูกเรียกครั้งแรกเพื่อ seed (ports/stm32/rng.c) โดยใช้ pad=UID^SysTick, n=RTC_TR=0, d=RTC_SSR=0, dat=0
จุดนี้ทำให้ "ความสุ่ม" ถูกบีบอยู่ใน pad 32 บิต บวกกับมิติค่าเล็ก ๆ ที่ไล่ enumerate ได้ ทำให้ pad ของ Mk3 ถูกจำกัดให้อยู่ในกล่องที่ค้นหาได้หมด
เฟส 2: การกดปุ่มของผู้ใช้
ระหว่างการตั้งค่าครั้งแรก ผู้ใช้ต้องตั้ง PIN กดยอมรับเงื่อนไข และเลื่อนเมนู แต่ละครั้งที่กดปุ่มจะเรียก _start_scan() ใน shared/mempad.py ซึ่งไปเรียก _rand_below() สามครั้งผ่าน shuffle(self.scan_order) โดย scan_order มีความยาวเท่ากับ NUM_ROWS ซึ่งคือ 4 ฟังก์ชัน shuffle อยู่ใน shared/random.py และ randbelow ผูกกับ ngu.random.uniform ซึ่งเป็น _rand_below ระดับ C ใน libngu
# 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 ระบุรูปแบบการใช้เลขสุ่มได้ 3 แบบ:
โปรไฟล์ A: ตั้งค่าเครื่องแบบผู้ใช้ทั่วไปครั้งแรก
- กดปุ่ม 2 ครั้งก่อนยอมรับเงื่อนไข (kpad_a = 2) โดยแต่ละครั้งเรียก shuffle(4)
- settings.save() ค้นหาช่องว่างใน 32 สล็อต โดยตัด my_pos ออกเหลือ 31 สล็อต ทำให้เกิดการเรียก _rand_below 30 ครั้งผ่าน shuffle(31)
- จากนั้นเป็นการกดปุ่มระหว่างใส่ PIN และเลื่อนเมนู (kpad_b อยู่ในช่วง [4, 34]) โดยแต่ละครั้งเป็น shuffle(4)
- สุดท้ายดึงเอนโทรปีด้วย random_bytes(32)
โปรไฟล์ B: บูตครั้งแรกหลังแฟลช/ล้างเครื่อง โดย NVRAM ว่าง
- nvstore ทำ shuffle(32) หนึ่งครั้ง (31 ครั้งของ _rand_below)
- จากนั้นมีการ consume แบบ lockstep รวม 3072 สเต็ป (3 slots × 16 blocks × 256 bytes) โดยเอาต์พุตไม่ป้อนกลับ ทำให้สถานะเดินหน้าอย่างเดียว
- ถ้าเป็น onboarding ของ NVRAM ว่าง จะมี shuffle(32) เพิ่มอีกครั้งหลังสองขั้นตอนด้านบน
- ต่อด้วยการกดปุ่ม (kpad_b ช่วง [4, 34]) และจบด้วย my_random_bytes(32)
โปรไฟล์ C: Paper wallet
Paper wallet คือการเก็บ private key บนกระดาษและเก็บออฟไลน์ ใช้กันมากสำหรับการให้เป็นของขวัญหรือถือยาว เมนู Paper Wallets ของ Coldcard ออกแบบมาเพื่อสร้างหน้าเอกสารสำหรับพิมพ์ โดยรูปแบบการใช้เอนโทรปีตรงที่สุด: หลังสร้างวอลเล็ตแล้ว กลับเข้าเมนูอีกครั้งต้องกดปุ่ม 8 ถึง 25 ครั้ง ก่อนจะใช้ my_random_bytes(32) เป็น private key โดยตรง และข้ามขั้นตอนรายการคำ BIP39
เฟส 3: สร้าง seed และอนุพันธ์ที่อยู่
จาก random_bytes(32) ไปเป็นที่อยู่บิตคอยน์ปลายทาง Mk3 ใช้ลำดับดังนี้:
raw_bytes = random_bytes(32)
entropy = ngu.hash.sha256s(raw_bytes)
mnemonic = BIP39(entropy, wordlist=english)
seed = PBKDF2HMACSHA512(mnemonic, "mnemonic", 2048)
master = HMACSHA512("Bitcoin seed", seed)
child = m/{44,49,84}'/0'/0'/0/0
address = bech32(hash160(compressed_pubkey))
ทุกขั้นเป็นฟังก์ชันกำหนดแน่นอนและทำซ้ำได้สำหรับทั้งสามโปรไฟล์ เมื่อผู้โจมตีเดา pad ได้ถูกต้อง ก็สามารถคำนวณห่วงโซ่ทั้งหมดซ้ำแบบออฟไลน์เพื่อไปถึง private key ได้
เฟส 4: Brute force และการ "เจอเป้า" บน GPU
วิธีของผู้โจมตีแบ่งเป็น 4 ขั้น:
1) สร้างชุด pad ผู้ต้องสงสัย: นำพิกัดเวเฟอร์ X และ Y ตั้งแต่ 0 ถึง 72 รวมกับพื้นที่ UID ราว 5,300 ค่า แล้วทำคาร์ทีเซียนโปรดักต์กับค่า SysTick ตั้งแต่ 0 ถึง 79,999 ได้ผู้สมัคร pad ราว 424 ล้านค่า ซึ่งแม้ดูใหญ่ แต่ GPU ใช้เวลาเพียงไม่กี่วัน
2) ไล่จำนวนการกดปุ่มสำหรับแต่ละ pad โดย kpad_b อยู่ช่วง 4 ถึง 34 และเนื่องจาก rtc_tr กับ rtc_ssr มีฮิตเป็นศูนย์ จึงวางไว้ในลูปนอกสุด ส่วนลูปในเป็นการ enumerate จำนวนการกดปุ่ม
3) รันทั้ง pipeline ในเคอร์เนล GPU ได้แก่ SHA256, BIP39, PBKDF2 2048 รอบ, BIP32, Hash160 และ Bech32 สตรีมค่าคงที่จาก libngu สามารถพรีคอมพิวต์ไว้ล่วงหน้า แล้วให้เคอร์เนลดึงคำตามอินเด็กซ์เพื่อลดโอเวอร์เฮด
4) จับคู่ด้วย Bloom filter หรืออาเรย์ที่เรียงลำดับของค่า hash160 ด้วยความซับซ้อน O(log n) โดยเล็งชุดที่อยู่ single-signature P2WPKH ทั้งหมดบนเครือข่าย
ต้นทุนการค้นหามีการประมาณไว้: การรันพื้นที่ของโปรไฟล์ A บน Apple M1 GPU เพียงตัวเดียว (UID 72x72, SysTick 80,000 ค่า และจำนวนการกดปุ่ม 31 ค่า) รวมผู้สมัครราว 14.8 พันล้านรายการ ใช้เวลาประมาณ 8.6 วัน ขณะที่คลัสเตอร์ระดับดาต้าเซ็นเตอร์ที่ใช้ A100 สามารถย่นเวลาเหลือเพียงไม่กี่ชั่วโมง
สำหรับ Mk4, Mk5 และ Q ที่มีพื้นที่ราว 72 บิต จะใหญ่กว่า Mk3 ประมาณ 2^32 เท่า แต่ช่องโหว่มีต้นตอเดียวกัน และแนวทางสร้างโมเดลโปรไฟล์ยังเหมือนเดิม ความต่างหลักคือเครื่องมือ brute force ต้องขยับจากเครื่องเดียวไปเป็นคลัสเตอร์ ทำให้เวลาจาก "วัน" ขยายเป็น "สัปดาห์" ซึ่งยังเป็นต้นทุนที่ผู้โจมตียอมจ่ายได้ นี่อธิบายว่าทำไมรุ่นเหล่านี้จึงปรากฏในรายชื่อเหยื่อเช่นกัน