Resumen breve
A partir del 30 de julio de 2026, se vaciaron fondos de carteras hardware Bitcoin COLDCARD en múltiples oleadas que continuaron desarrollándose en los días siguientes. No hubo una única transacción de ataque en cadena ni un contrato vulnerable; la pérdida provino de un problema de recuperación de semilla offline seguido de vaciados en cadena. Al 7 de agosto de 2026, el rastreo en cadena había verificado un mínimo de aproximadamente 1,405 BTC (~$91M al precio de $64,700 del 7 de agosto) drenados de roughly 4,925 direcciones [1], la atribución por oleada alcanzó aproximadamente 1,433 BTC en diez oleadas [2], y la reconciliación por canal privado con las víctimas situó la cifra en hasta 2,055 BTC (~$133M) [3].
La causa raíz fue un fallo de entropía en la cartera: una migración de firmware de 2021 enrutó la generación de semillas a través de ngu.random.bytes(), que recurría a un generador de software determinista en lugar del RNG hardware STM32 previsto. Para los dispositivos afectados, esto redujo la recuperación de semillas de un problema criptográficamente inviable a una búsqueda offline, permitiendo a un atacante enumerar semillas candidatas y compararlas con datos de cartera públicos para recuperar las claves privadas. Basándonos en nuestro análisis semanal anterior del incidente, este análisis profundo cuantifica la entropía residual para cada generación de dispositivo afectado, rastrea cómo se identificaron las víctimas y los fondos robados en cadena, y examina una regresión de firmware posterior al hotfix que puede denegar el servicio antes del inicio de sesión.
Antecedentes
COLDCARD es una cartera hardware Bitcoin. Como otras carteras de autocustodia, su seguridad depende en última instancia de la imprevisibilidad de la frase semilla generada durante la configuración de la cartera. Una frase semilla BIP-39 es legible por humanos, pero la propiedad de seguridad subyacente sigue siendo la entropía de los bytes aleatorios utilizados para crearla. Si la salida de generación de semillas es predecible, la seguridad de la cartera colapsa, sin importar cuán cuidadosamente se almacene la semilla posteriormente.
COLDCARD abarca las revisiones de hardware Mk1 a Mk5 y el modelo Q. Mk2 y Mk3 usan la línea de firmware heredado. Mk4, Mk5 y Q usan líneas de lanzamiento Standard y Edge separadas.
La mayor parte de la lógica de aplicación de COLDCARD es Python ejecutándose sobre MicroPython, con módulos C nativos que proporcionan operaciones hardware y criptográficas. Una ruta de generación de semillas segura debería leer 32 bytes del RNG hardware STM32, fallar si el periférico se detiene o repite un valor, hashear el resultado y luego codificarlo como palabras BIP-39. En v3.2.2, make_new_wallet() seguía esta ruta:
make_new_wallet()
|- ckcc.rng_bytes(seed)
| `- random_buffer()
| |- read STM32 RNG->DR
| `- fail on timeout or repeated output
|- SHA-256(seed)
`- BIP-39 seed words
Análisis de la vulnerabilidad
La causa raíz fue un error de compilación e integración en torno a MICROPY_HW_ENABLE_RNG. La configuración de placa de producción de COLDCARD estableció esta macro en 0, porque el firmware usaba su propio envoltorio de RNG hardware local en lugar de la implementación de RNG hardware de MicroPython. Sin embargo, la ruta de generación de carteras había sido migrada a ngu.random.bytes(32), y la ruta STM32 de libngu dependía en última instancia del símbolo global rng_get().
La ruta afectada entraba en my_random_bytes() de libngu. Para cada palabra de salida, aplicaba XOR al valor devuelto por CHIP_TRNG_32() con su propia salida de Yasmarang:
generate_seed()
`- ngu.random.bytes(32)
`- my_random_bytes() [libngu]
|- CHIP_TRNG_32()
| `- rng_get() [MicroPython]
| `- Yasmarang A
| `- UID + SysTick + RTC
|
|- my_yasmarang() [libngu]
| `- Yasmarang B
| |- Mk2/Mk3: public initial state
| `- Mk4/Q/Mk5: 32-bit pad reseed
|
`- output = Yasmarang A XOR Yasmarang B
Yasmarang A era el fallback de software de MicroPython detrás de rng_get(), inicializado a partir del estado del dispositivo y los temporizadores. Yasmarang B pertenecía a libngu: usaba valores iniciales públicos en Mk2/Mk3, mientras que Mk4/Q/Mk5 reemplazaba únicamente su palabra pad de 32 bits con datos derivados del elemento seguro. El RNG STM32 local de la placa seguía existiendo, pero ngu.random.bytes() no lo llamaba.
La discrepancia es visible directamente en la configuración de compilación. El mpconfigboard.h de Mk4 deshabilitaba la rama de RNG hardware de MicroPython:
// We have our own version of this code.
#define MICROPY_HW_ENABLE_RNG (0)
El CHIP_TRNG_32() de libngu seguía tratando la macro como prueba suficiente de un RNG hardware porque solo verificaba si la macro existía, luego llamaba a rng_get():
extern uint32_t rng_get(void);
#define CHIP_TRNG_32() rng_get()
#ifndef MICROPY_HW_ENABLE_RNG
#error "get a HW TRNG plz"
#endif
Esa guarda omite el caso peligroso: una macro definida como 0 sigue estando definida. La compilación tuvo éxito, y rng_get() se resolvió en el módulo RNG STM32 de MicroPython en lugar del envoltorio local de placa separado de COLDCARD (random32() / random_buffer(), expuesto a Python como ckcc.rng_bytes). En la selección de rng_get() de MicroPython, MICROPY_HW_ENABLE_RNG == 0 seleccionaba la rama de fallback de software:
#if MICROPY_HW_ENABLE_RNG
// STM32 hardware RNG
#else
// Yasmarang software fallback
#endif
Mk2/Mk3: Aproximadamente 40 bits
El fallback compilado pyb_rng_yasmarang() inicializaba y avanzaba Yasmarang de la siguiente manera:
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;
n = RTC->TR;
d = RTC->SSR;
}
pad += dat + d * n;
pad = (pad << 3) + (pad >> 29);
n = pad | 2;
d ^= (pad << 31) + (pad >> 1);
dat ^= (char)pad ^ (d >> 8) ^ 1;
return pad ^ (d << 5) ^ (pad >> 18) ^ (dat << 1);
}
Las variables ocupan 104 bits, pero el tamaño del estado no es entropía. dat comienza en cero, y los demás valores son metadatos fijos o lecturas de temporizador correlacionadas en lugar de secretos independientes. Bajo el modelo aproximado de Mk2/Mk3 utilizado para la cifra aproximada de 40 bits:
| Entrada | Valores candidatos | Costo de enumeración |
|---|---|---|
UID_low32 conocido |
1 |
2^0 |
SysTick->VAL |
80,000 |
2^16.29 |
RTC->TR hora del día |
86,400 |
2^16.40 |
RTC->SSR subsegundo |
256 |
2^8 |
Tratar todos los campos de temporizador como independientes da un techo intencionalmente amplio:
80,000 * 86,400 * 256
= 1,769,472,000,000
= 2^40.69 candidate initial states
Por lo tanto, una búsqueda exhaustiva requiere como máximo 2^40.69 pruebas y, bajo una suposición de posición uniforme, aproximadamente 2^39.69 pruebas en promedio. Este es un límite superior de enumeración, no 40 bits de entropía criptográfica. Si los registros RTC son estáticos durante un arranque en frío normal, solo queda SysTick, reduciendo el techo a aproximadamente 2^16.29. Si el UID, los temporizadores y el número de llamadas RNG anteriores son conocidos, existe exactamente un flujo: 2^0. El historial de llamadas desconocido agrega solo el número de trazas de ejecución plausibles, no una fuente de entropía nueva. Del mismo modo, generar ocho palabras de 32 bits para una semilla de 256 bits no multiplica el espacio de búsqueda: cada palabra está determinada por el mismo estado inicial.
En la capa de libngu, my_random_bytes() mezclaba el fallback de MicroPython anterior con el generador Yasmarang separado de libngu. El código fuente no contiene literalmente chip = rng_get(): CHIP_TRNG_32() se expande a rng_get(). En Mk2/Mk3, el segundo generador comenzaba desde constantes públicas:
static uint32_t yasmarang_pad = 0x0a8ce26f, yasmarang_n = 69, yasmarang_d = 233;
static uint8_t yasmarang_dat = 0;
uint32_t chip = CHIP_TRNG_32();
// ... adjacent-output health check ...
chip ^= my_yasmarang();
Ambos flujos eran por lo tanto reproducibles una vez conocidos el estado del fallback de MicroPython y el historial de llamadas. XOR cambiaba los valores de salida pero no añadía entropía. Incluso si UID_low32 es desconocido, UID_low32 ^ SysTick colapsa ambas entradas en un único pad de 32 bits; sus recuentos nominales de bits no pueden sumarse.
Mk4/Q/Mk5: Aproximadamente 72 bits
Los modelos posteriores mantuvieron la misma construcción de dos generadores, pero rng_seeding() añadió material del elemento seguro al generador de libngu durante el arranque:
a = callgate.read_rng(1) # SE1
b = callgate.read_rng(2) # SE2
n = ngu.hash.sha256d(a + b)
n, = ustruct.unpack('I', n[0:4])
ngu.random.reseed(n)
Aunque 40 bytes entraron en el hash, solo sus primeros cuatro bytes llegaron a reseed() [4]. La implementación de random_reseed() luego reemplazaba únicamente la palabra pad de 32 bits de libngu:
STATIC mp_obj_t random_reseed(mp_obj_t arg)
{
yasmarang_pad = mp_obj_get_int_truncated(arg);
return mp_const_none;
}
Las otras palabras de estado de libngu mantenían sus valores públicos, y el fallback de MicroPython no era resembrado. La cifra aproximada de 72 bits combina el resembrado de 32 bits de libngu con un límite superior aproximado para el estado de temporizador de los modelos posteriores:
MicroPython fallback:
120,000 SysTick values * 86,400 RTC times * 256 subseconds
= 2^41.27 states
Libngu secure reseed:
2^32 values
Combined ceiling:
2^41.27 * 2^32 = 2^73.27 candidates
Average enumeration:
2^73.27 / 2 = 2^72.27 trials
Esta es la fuente de la cifra de "aproximadamente 72 bits". Es una estimación promedio del trabajo de ataque, no 72 bits suministrados por los elementos seguros. Los campos de temporizador están correlacionados y pueden reconstruirse; si el estado del fallback de MicroPython es conocido, solo quedan los 2^32 valores de resembrado, con un promedio de 2^31 pruebas. Hashear los 32 bytes aleatorios finales no puede aumentar el número de semillas posibles.
Versiones afectadas
| Dispositivo y línea | Fuera de esta regresión | Firmware generador de semillas afectado | Seguridad en bits efectiva | Primera versión corregida |
|---|---|---|---|---|
| Mk1 | Hasta v3.0.6 | Ninguno | - | N/A |
| Mk2/Mk3 | Hasta v3.2.2 | v4.0.0-v4.1.9 (aviso oficial desde v4.0.1) | Aproximadamente 40 bits cuando está afectado | v4.2.0 |
| Mk4/Mk5 Standard | N/A | Antes de v5.6.0 | Aproximadamente 72 bits antes de la corrección; al menos 128 bits después | v5.6.0 |
| Q Standard | N/A | Antes de v1.5.0Q | Aproximadamente 72 bits antes de la corrección; al menos 128 bits después | v1.5.0Q |
| Mk4/Mk5 Edge | N/A | Antes de v6.6.0X | Aproximadamente 72 bits antes de la corrección; al menos 128 bits después | v6.6.0X |
| Q Edge | N/A | Antes de v6.6.0QX | Aproximadamente 72 bits antes de la corrección; al menos 128 bits después | v6.6.0QX |
La versión relevante es el firmware que generó la semilla, no el firmware actualmente instalado. Las nuevas semillas generadas a partir de las versiones corregidas utilizan la ruta corregida, pero actualizar no repara una semilla existente. El rango afectado oficial para Mk2/Mk3 comienza en v4.0.1 [5], mientras que el análisis a nivel de código fuente también incluye v4.0.0 [4]. La entropía de dados independiente puede aumentar la seguridad de la semilla, mientras que una contraseña BIP-39 robusta añade una barrera separada sin reparar la semilla en sí [6].
Best Security Auditor for Web3
Validate design, code, and business logic before launch
Rastreo de víctimas y fondos
No hubo una transacción de explotación en cadena que rastrear; la recuperación fue necesariamente offline. Atacando una semilla generada por el firmware afectado, un atacante podría restringir y enumerar los estados RNG candidatos descritos anteriormente, reproducir el flujo de generación de semillas de cada candidato, derivar las claves de cartera resultantes y compararlas con datos de cartera públicos, para luego vaciar en cadena cualquier cartera financiada que coincidiera. Esto solo alcanzaba carteras derivables únicamente de la semilla: una contraseña BIP-39 robusta y única mezcla entropía independiente suministrada por el usuario, que el fallo del RNG nunca tocó, en la derivación de claves a través de PBKDF2, colocando dichas carteras fuera de la enumeración pura de semillas. Las carteras que fueron vaciadas fueron necesariamente aquellas sin dicha protección [6]. Dado que el robo se manifestó solo como vaciados en cadena en lugar de una explotación rastreable, identificar a las víctimas y seguir los fondos se convirtió en una cuestión de análisis forense en cadena.
Varios esfuerzos independientes rastrearon los fondos robados: sitios de seguimiento públicos (Coldcard Sweep Watch [1], coldcard.rip [2] y el Coldcard Hack Tracker [7]) y una reconciliación por canal privado de Galaxy Research [3], con sus totales reportados comparados a continuación. Dado que Coldcard Sweep Watch publicó su metodología, la usamos para ilustrar el proceso de identificación, un ciclo de retroalimentación entre informes fuera de cadena y análisis en cadena:
- Anclajes fuera de cadena. Las víctimas e investigadores aportaron direcciones públicas o IDs de transacciones, más contexto del dispositivo y de generación de semillas cuando estaba disponible. Cada informe se trató como una pista y se verificó en cadena; no se requirió frase semilla, clave privada ni xpub.
- Expansión en cadena. Partiendo de anclajes confirmados, los escáneres buscaron en los bloques relevantes las mismas características de vaciado: carteras vaciadas sin cambio, tipos de entrada similares, temporización estrecha, tasas de comisión repetidas, destinos comunes o co-gastos posteriores.
- Verificaciones cruzadas fuera de cadena. Se usaron nuevos informes de víctimas, conjuntos de datos de investigadores y atribución de servicios para confirmar o rechazar oleadas candidatas. Los clústeres verificados y los candidatos heurísticos permanecieron separados.
Bitcoin identifica direcciones, no personas ni modelos de cartera. Una cartera puede controlar muchas direcciones, por lo que el recuento de direcciones no es el recuento de víctimas. La primera oleada principal ampliamente reportada, 960188, representó 594.48 BTC; el escaneo y los informes continuos elevaron el total. Al 7 de agosto de 2026, Coldcard Sweep Watch reportó un mínimo verificado de aproximadamente 1,405.07 BTC (~$91M al precio de $64,700 del 7 de agosto) de roughly 4,925 direcciones [1], mientras que la instantánea de coldcard.rip del 3 de agosto atribuyó hasta 1,433.13 BTC brutos, 1,432.48 BTC a destinos después de comisiones, de 5,477 direcciones en diez oleadas [2]. Una reconciliación privada separada de Galaxy Research, extraída de la correspondencia con víctimas, situó la cifra aún más alta, desde aproximadamente 1,596 BTC hasta 2,055 BTC (~$133M al mismo precio) [3]. Las diferencias reflejan umbrales de evidencia, tiempo de descubrimiento y el canal de confirmación en el que cada rastreador se basa.
Los fondos fueron luego seguidos a través de tres capas observadas: direcciones de origen vaciadas, destinos directos de vaciado (holding) y destinos de consolidación posteriores (vault). La tabla muestra el recuento de direcciones distintas en cada capa:
| Patrón | Rutas de ejemplo | Consecuencia para el rastreo |
|---|---|---|
| Muchos vaciados a una o dos direcciones holding, luego un vault | 960183: 204 -> 2 -> 1; 960188: 500 -> 1 -> 1 |
La convergencia de destinos hace el clúster comparativamente sólido y fácil de seguir |
| Los vaciados se detienen en direcciones holding sin una consolidación posterior | 960352: 352 -> 1 -> 0; 960668: 795 -> 1 -> 0 |
La dirección holding sigue siendo rastreable, pero no hay co-gasto posterior que fortalezca la atribución |
| Destinos nuevos por vaciado, a veces seguidos de vaults separados | 960359: 13 -> 13 -> 0; 960395: 1,918 -> 294 -> 293 |
Un detector de colector compartido falla; la agrupación depende de temporización, tasa de comisión, plantilla de transacción y corroboración fuera de cadena |
Cuando las salidas rastreadas se mueven, el análisis sigue divisiones, fusiones y cadenas de pelado conservando el valor después de comisiones y limitando la atribución al monto vaciado. Los fondos que entran en un exchange u otro servicio combinado reducen la confianza; el servicio no se añade al clúster del atacante.
Una corrección que necesitaba corrección: ¿Una posible regresión de bloqueo permanente?
Independientemente del fallo de entropía, el hotfix que lo resolvió introdujo una regresión de firmware distinta [8, 9]. Al restaurar la ruta del RNG hardware, la corrección dejó sin manejar una condición de error de semilla hardware, lo que puede denegar el servicio antes del inicio de sesión y generó afirmaciones de dispositivos permanentemente bloqueados. Hasta qué punto se sostiene esa afirmación más grave depende de los detalles a nivel de registro.
El RNG hardware STM32 expone un registro de control (RNG_CR), un registro de estado (RNG_SR) y un registro de datos de 32 bits (RNG_DR). El estado relevante es:
| Bit | Función |
|---|---|
RNGEN |
Habilita el RNG y sus fuentes de ruido analógico |
DRDY |
Indica que los datos están listos en RNG_DR; el software aún debe rechazar el valor cero |
SECS / SEIS |
Fallo actual de prueba de salud de semilla / estado de error de semilla capturado |
CECS / CEIS |
Fallo actual de reloj RNG / estado de error de reloj capturado |
La distinción entre estado actual y capturado importa. SECS describe la condición presente de la fuente de ruido, mientras que SEIS registra que ocurrió un error de semilla hasta que el software lo borre. En el STM32L4S de la familia Mk4/Q, un error de semilla detiene la generación de nuevos números aleatorios; en el STM32L4 de Mk3, los datos pueden seguir disponibles pero no deben ser de confianza. Los errores de reloj son separados y no establecen este bloqueo por error de semilla.
La secuencia de recuperación requerida depende de la generación de STM32:
| Familia de dispositivo | Recuperación documentada de error de semilla |
|---|---|
| Mk3 STM32L4 (RM0351, gestión de errores RNG [10]) | Borrar SEIS, luego borrar y establecer RNGEN |
| Familia Mk4/Q STM32L4S (RM0432, gestión de errores RNG [11]) | Borrar SEIS, leer y descartar 12 palabras RNG_DR, luego confirmar que SEIS sigue borrado |
El hotfix de entropía del 31 de julio hizo correctamente que rng_get() resolviera al TRNG hardware, pero el rng_get_or_fault() de la familia Mk4/Q no implementa ninguna recuperación de error de semilla. rng_init() solo actúa cuando RNGEN está en cero, mientras que el bucle de lectura solo verifica DRDY. Si un error de semilla deja RNGEN habilitado pero suprime DRDY, la inicialización se convierte en una no-operación; cada lectura espera 10 ms y lanza OSError(EFAULT) sin borrar SEIS.
Esto puede alcanzar la interfaz de usuario antes del inicio de sesión. Tanto el teclado numérico mempad._start_scan() como el teclado Q keyboard._start_scan() mezclan su orden de escaneo desde la interrupción de tecla presionada. Una excepción de RNG allí puede bloquear la entrada del PIN y el menú normal de actualización de firmware durante el resto de esa sesión de hardware.
La ruta de fallo a nivel de código es creíble, pero la afirmación más grave de "ataque de bloqueo permanente" no está establecida. Los bits de control y estado del RNG se restablecen a cero en un reinicio hardware, por lo que un único error transitorio no debería dañar permanentemente el periférico; la persistencia tras un ciclo completo de apagado no ha sido demostrada. Tampoco existe una forma remota o controlable de manera confiable demostrada para desencadenar el fallo de la prueba de salud de semilla. Una publicación en X [8] afirmó que el bloqueo estaba confirmado, pero el PR al que apuntaba, el PR #692 [9] enviado por la comunidad, tiene al propio autor declarando que analizó el fallo con un simulador de registro, que no lo reprodujo en hardware Mk4/Q real y que no confirmó independientemente los informes de campo. La clasificación mejor respaldada es por lo tanto una potencial denegación de servicio previa al inicio de sesión y una regresión de fiabilidad, no un ataque de bloqueo permanente confirmado.
La propia corrección de los mantenedores, PR #693 [12], verifica los indicadores de error de semilla, añade recuperación y reintentos acotados, rechaza muestras sospechosas y captura únicamente el error de teclado esperado; fue fusionado el 5 de agosto de 2026, reemplazando al PR #692 [9] de la comunidad, que fue cerrado sin fusionar el 4 de agosto de 2026.
Conclusión
Este incidente fue un fallo de entropía de cartera que convirtió la recuperación de semillas de criptográficamente inviable en un problema de búsqueda offline para los dispositivos y flujos de trabajo afectados. El fallo de ingeniería clave fue que el firmware distribuido no probaba que la API de generación de semillas crítica para la seguridad realmente alcanzara el RNG hardware previsto. Las guardas de compilación deben verificar tanto la existencia de la macro como el valor de la macro, los fallbacks para entropía criptográfica deben fallar de forma segura, y la CI debería verificar la procedencia de símbolos y el flujo de entropía de extremo a extremo en la imagen de firmware final. Para los usuarios afectados, el remedio no es una actualización de firmware: actualizar no repara una semilla ya generada bajo la ruta defectuosa, y añadir una contraseña posteriormente no protege los fondos ya mantenidos en las direcciones de esa semilla. Esos fondos deben trasladarse a una cartera construida a partir de una nueva semilla en una versión corregida; solo los fondos que ya estaban detrás de una contraseña robusta y única permanecieron fuera de la enumeración solo por semilla [6].
Referencias
- [1] Coldcard Sweep Watch — conjunto de direcciones drenadas verificadas y rastreador de pérdidas
- [2] coldcard.rip — libro de incidentes, rutas y atribución
- [3] Galaxy Research — estimación de pérdidas COLDCARD a partir de informes de víctimas
- [4] Block Engineering — Fallback de RNG predecible y resembrado de 32 bits en el firmware COLDCARD
- [5] Coinkite — Aviso de seguridad de COLDCARD y rangos de firmware afectados
- [6] Coinkite — Análisis técnico profundo del problema de entropía
- [7] Coldcard Hack Tracker — totales de vaciado en vivo oleada por oleada
- [8] Publicación en X que afirma que el fallo de RNG posterior al hotfix podría bloquear permanentemente el dispositivo
- [9] PR #692 del firmware de Coldcard — propuesta de recuperación de fallo TRNG de la comunidad (cerrado sin fusionar)
- [10] STMicroelectronics RM0351 — Registros RNG de STM32L4 y gestión de errores
- [11] STMicroelectronics RM0432 — Registros RNG de STM32L4S y gestión de errores
- [12] PR #693 del firmware de Coldcard — recuperación TRNG con reintentos acotados fusionada y manejo de errores de teclado



