카테고리: 기타 🤖
<aside> 💡
이 글은 Team Corn의 Berlin이 작성한 해설입니다. 2026년 7월 30일 공개된 콜드카드(COLDCARD)의 난수 생성기(RNG) 결함과 이후 온체인에서 잇달아 확인된 대규모 자금 이동(이하 ‘스윕’)을 다룹니다. 피해 규모와 조사 상태는 2026년 8월 2일까지 공개된 자료를 기준으로 정리했습니다.
작성자는 니모닉을 만들 때 가장 바람직한 방법은 사용자가 네트워크에서 분리된 상태에서 공정한 주사위나 동전으로 충분한 난수를 직접 만들고, 가능한 범위에서 계산 과정을 독립적으로 확인하는 것이라고 봅니다. 이는 특정 기기·제조사·개발자의 명성이나 ‘오픈소스’라는 사실에 키 생성을 전적으로 맡기지 않기 위한 선택입니다. 다만 물리 난수 입력도 절차 오류, 기록 노출, 악성 펌웨어와 주소 바꿔치기를 자동으로 막지는 못합니다. 따라서 서로 다른 장치와 절차로 만든 키를 사용하는 멀티시그와 별도의 주소 검증도 함께 권장합니다.
7장의 지갑 비교는 제품 순위표가 아닙니다. 각 제조사가 난수를 어떻게 만들고 섞는다고 설명하는지 공개 문서를 바탕으로 비교했습니다. 피해 규모, 세 스윕을 같은 공격자가 벌였는지, 실제 공격에 얼마나 많은 계산이 필요한지처럼 아직 확정되지 않은 부분은 확인된 사실과 연구진의 추정, 작성자의 해석을 나눠 적었습니다.
</aside>
2026년 7월 30일 01:10–01:51 UTC에 벌어진 첫 번째 스윕에서는 1,196개 주소에서 1,082.65 BTC가 41분 만에 이동했다. 8월 2일까지 확인된 세 차례의 스윕을 합치면 4,585개 주소에서 1,367.05 BTC, 당시 시가 약 8,860만 달러가 이동했다. Galaxy Research는 각 스윕을 한 사람이 통제한 것으로 보았지만, 세 차례를 모두 같은 공격자가 벌였는지는 온체인 자료만으로 확정하지 못했다.
현재까지 확인된 원인은 기기 원격 침입이 아니었다. 새 코드를 넣는 과정에서 정상적으로 작동하던 난수 호출 경로가 깨졌다. 소프트웨어에서는 이런 문제를 **회귀 오류(regression)**라고 부른다. 2021년 3월 libNgU를 도입하는 과정에서 ngu.random이 STM32 하드웨어 RNG가 아니라 MicroPython의 결정론적 Yasmarang 소프트웨어 폴백(fallback)에 연결됐다. Mk2·Mk3의 취약 펌웨어에서는 시드 생성에 공격자가 예측할 수 없는 난수가 들어가지 않았다. 코인카이트는 특정 공격 조건에서 공격자가 뒤져야 할 후보 수가 약 2^40개에 해당한다고 추정한다. Mk4·Q·Mk5는 보안 칩(secure element, SE)에서 가져온 값으로 난수 생성기 상태를 한 번 더 초기화한다. 이를 재시딩이라고 하며, 제조사의 예비 추정치는 약 72비트다. Block의 2^32 계산은 공격자가 소프트웨어 폴백의 상태와 호출 이력을 이미 특정할 수 있다는 조건에서, SE 값을 섞은 뒤 나올 수 있는 난수열이 최대 2^32개라는 뜻이다. 40비트·72비트 추정치와 성격이 다른 수치이므로 서로 바꿔 쓰면 안 된다.
영향받은 펌웨어에서 시드를 만들었더라도, 공정한 주사위를 매번 따로 50회 이상 굴리고, 그 결과를 외부에 노출하지 않은 채 최종 시드에 추가했다면 코인카이트는 이 RNG 결함만으로는 위험하지 않다고 본다. 강하고 고유한 BIP-39 패스프레이즈도 추가 방어선이 되지만 취약한 시드를 안전한 시드로 바꾸지는 못하므로, 새 시드로 만든 지갑으로 자금을 옮기라는 공식 권고는 그대로 적용된다.
이 사건에서 얻을 수 있는 결론은 두 가지다. 사용자가 주사위나 동전으로 충분한 난수를 직접 만들어 넣으면, 기본 RNG 하나가 고장 나도 시드 전체가 함께 취약해질 위험을 줄일 수 있다. 서로 다른 장치와 절차로 각각 만든 세 키 가운데 두 개를 요구하는 2-of-3 멀티시그는 키 하나가 노출되더라도 지출에 필요한 두 번째 서명을 공격자가 확보하지 못하게 한다. 다만 주사위 입력은 악성 서명 펌웨어까지 막지 못하며, 취약한 콜드카드 여러 대의 키만으로 필요한 서명 수를 채우는 멀티시그도 안전하지 않다. 주사위는 난수 생성기의 실패에 대비하는 방법이다. 서로 다른 장치와 절차로 만든 키를 함께 쓰는 멀티시그는 키 하나가 뚫려도 곧바로 자금을 빼앗기지 않게 한다.
초기 보도는 25분 동안 약 594 BTC가 이동했다고 전했고, 당시 확인 범위는 약 500개 단일서명 지갑이었다. 이후 Galaxy Research가 첫 번째 스윕 전체를 다시 추적하면서 규모가 거의 두 배로 늘었다. 7월 30일 01:10부터 01:51 UTC까지 41분 동안 1,196개 주소에서 1,082.65 BTC가 이동했다. 관련 거래는 여섯 개 블록에 나뉘어 포함됐고, 그 사이 세 개 블록에는 공격 관련 거래가 없었다. 세 블록 자체가 비어 있었다는 뜻은 아니다. 공격 거래가 연속해서 전파된 것이 아니라 여러 차례에 나눠 전파됐음을 시사한다. 첫 번째 스윕의 자금은 네 개의 수집 주소로 모였고, 8월 1일 공개된 분석 시점에는 움직이지 않은 상태였다. 초기 보도가 이 가운데 한 주소만 포착했기 때문에 총액에 큰 차이가 생겼다.
공격 순서도 무작위로 보이지 않았다. 첫 번째 스윕은 주소당 평균 잔액이 약 0.91 BTC였지만, 세 번째 스윕은 주소당 평균 약 0.11 BTC의 더 작은 잔액을 겨냥했다. 이는 공격자가 후보 시드에서 파생한 주소와 잔액을 사전에 계산하고, 잔액이 큰 주소부터 처리했을 가능성과 일치한다. 다만 이는 온체인 순서와 잔액 분포에서 나온 해석이다. 공격자가 어떤 오프체인 자료를 보유했는지, 세 스윕을 같은 운영자가 수행했는지는 온체인 자료만으로 확정할 수 없다.
여기서 온체인 분석의 한계를 분명히 해야 한다. 블록체인에는 시드를 만든 날짜나 사용한 콜드카드 모델·펌웨어가 기록되지 않는다. 주소의 최초 입금 시점과 UTXO의 연령은 확인할 수 있지만, 그것이 곧 시드 생성 시점은 아니다. 따라서 “모든 피해 시드가 2021년 3월 17일 이후 생성됐다”는 문장은 온체인에서 직접 확인된 사실로 쓸 수 없다. 공개된 코드 분석과 피해자 보고, 온체인 패턴은 취약 경로에서 만들어진 키가 도난에 이용됐다는 설명과 일치한다. 그러나 주소별 기기 모델과 생성 펌웨어는 온체인 자료만으로 직접 확인되지 않는다.
코인카이트의 대응 범위도 조사와 함께 넓어졌다. 7월 30일 Mk2·Mk3 보안 권고가 나왔고, 이후 Mk4·Mk5·Q와 Standard·Edge 릴리스까지 포함한 공식 권고와 기술 분석이 갱신됐다. 8월 2일 Galaxy 집계에 따르면 세 차례의 스윕에서 누적 1,367.05 BTC, 약 8,860만 달러가 4,585개 주소에서 이동했다. 세 번째 스윕은 1,912개 주소에서 약 208 BTC를 이동시키며 더 작은 잔액을 겨냥했고, 각 피해 주소의 자금을 별도의 P2WSH 출력으로 보냈다. Galaxy는 각 스윕을 한 사람이 통제한 것으로 보았지만, 세 차례를 모두 같은 공격자가 벌였는지는 확인하지 못했다. 자금이 바로 다시 이동하지 않은 이유 역시 공개 자료만으로는 알 수 없다. 이 글의 피해 수치는 2026년 8월 2일 기준이며 이후 달라질 수 있다.
콜드카드에는 STM32의 하드웨어 난수를 읽는 ckcc.rng_bytes()가 있었고, 이 기능 자체는 정상으로 남아 있었다. 문제는 2021년 3월 시드 생성 코드를 ngu.random.bytes()로 바꾸면서 시작됐다. 이 함수가 호출한 rng_get()이 하드웨어 구현이 아니라 MicroPython의 소프트웨어 폴백에 연결됐다. 즉, TRNG가 고장 난 것이 아니라 시드를 만드는 코드가 TRNG를 사용하지 않은 것이다. 코인카이트의 기술적 사후 분석과 Block의 독립 코드 분석은 이 점에서 같은 결론을 내린다.
빌드 단계의 안전장치도 이 문제를 잡아내지 못했다. 이유는 전처리기 조건문에 있었다. 보드 설정은 보드 전용 난수 구현을 사용하기 위해 MICROPY_HW_ENABLE_RNG를 0으로 정의했다. 그러나 libNgU의 안전장치는 이 매크로의 값이 0인지가 아니라, 매크로가 정의돼 있는지만 검사했다.
#ifndef MICROPY_HW_ENABLE_RNG
#error "get a HW TRNG plz" ← TRNG 없으면 빌드 중단(의도)
#endif
#ifndef는 매크로의 값이 0인지 확인하지 않는다. MICROPY_HW_ENABLE_RNG가 0으로 정의돼 있어도 매크로는 “정의된 상태”이므로 해당 오류 블록에 들어가지 않고 빌드가 계속된다. 동시에 보드 전용 구현은 전역 함수 rng_get()이 아니라 random32()와 random_buffer()를 내보냈다. 그 결과 링커는 libNgU가 참조한 rng_get()을 MicroPython의 폴백 구현에 연결했다. 이것은 흔히 말하는 “한 글자 오타”가 아니었다. 매크로가 존재하는지와 그 값이 무엇인지를 혼동한 검사에, 같은 함수 이름을 가진 소프트웨어 폴백이 맞물리면서 생긴 오류였다.
MicroPython은 이 설정값이 0일 때 rng_get()을 제거하지 않고 Yasmarang 소프트웨어 폴백을 컴파일한다.