Back to Blog

Incidente COLDCARD: Quando a Semente "Aleatória" de uma Carteira Não Era Aleatória

Code Auditing
August 7, 2026
17 min read
Key Insights
  • As perdas do COLDCARD decorrem de um único erro de configuração de build — uma guarda de macro verificava a existência (#ifndef) em vez do valor (#if), redirecionando silenciosamente a geração de seed para um fallback de RNG de software determinístico.

  • O ataque foi inteiramente offline: nenhuma transação de exploit on-chain, apenas enumeração de seeds (~40 bits no Mk2/Mk3, ~72 bits no Mk4/Q/Mk5) comparada contra dados públicos de carteiras.

  • Atualizar o firmware não corrige seeds já geradas — fundos comprometidos devem ser movidos para uma nova seed criada em uma versão corrigida.

  • O caso demonstra por que os caminhos de entropia criptográfica devem falhar de forma fechada e ser verificados de ponta a ponta no firmware distribuído, não apenas em tempo de compilação.

Resumo Breve

A partir de 30 de julho de 2026, fundos foram varridos de carteiras de hardware Bitcoin COLDCARD em múltiplas ondas que continuaram a se desdobrar nos dias seguintes. Não houve uma única transação de ataque on-chain nem contrato vulnerável; a perda veio de um problema de recuperação de seed offline seguido de varrimentos on-chain. Em 7 de agosto de 2026, o rastreamento on-chain havia verificado um piso de cerca de 1.405 BTC (~$91M ao preço de $64.700 em 7 de agosto) drenados de aproximadamente 4.925 endereços [1], a atribuição por onda chegou a cerca de 1.433 BTC em dez ondas [2], e a reconciliação por canais privados com vítimas colocou o valor em até 2.055 BTC (~$133M) [3].

A causa raiz foi uma falha de entropia da carteira: uma migração de firmware em 2021 roteou a geração de seed através de ngu.random.bytes(), que recorreu a um gerador de software determinístico em vez do RNG de hardware STM32 pretendido. Para os dispositivos afetados, isso reduziu a recuperação de seed de um problema criptograficamente inviável para uma busca offline, permitindo que um atacante enumerasse seeds candidatos e os confrontasse com dados públicos de carteira para recuperar as chaves privadas. Com base em nossa análise semanal anterior do incidente, esta análise aprofundada quantifica a entropia residual para cada geração de dispositivo afetado, rastreia como vítimas e fundos roubados foram identificados on-chain, e examina uma regressão separada de firmware pós-hotfix que pode negar serviço antes do login.

Contexto

O COLDCARD é uma carteira de hardware Bitcoin. Como outras carteiras de autocustódia, sua segurança depende fundamentalmente da imprevisibilidade da frase seed gerada durante a configuração da carteira. Uma frase seed BIP-39 é legível por humanos, mas a propriedade de segurança subjacente ainda é a entropia dos bytes aleatórios usados para criá-la. Se a saída de geração de seed for previsível, a segurança da carteira entra em colapso, por mais cuidadosamente que a seed seja posteriormente armazenada.

O COLDCARD abrange as revisões de hardware Mk1 a Mk5 e o modelo Q. Mk2 e Mk3 usam a linha de firmware legada. Mk4, Mk5 e Q usam trilhas de lançamento Standard e Edge separadas.

A maior parte da lógica de aplicação do COLDCARD é Python rodando em MicroPython, com módulos C nativos fornecendo operações de hardware e criptografia. Um caminho seguro de geração de seed deve ler 32 bytes do RNG de hardware STM32, falhar se o periférico travar ou repetir um valor, calcular o hash do resultado e então codificá-lo como palavras BIP-39. Na v3.2.2, make_new_wallet() seguia esse caminho:

make_new_wallet()
  |- ckcc.rng_bytes(seed)
  |    `- random_buffer()
  |         |- leitura do STM32 RNG->DR
  |         `- falha em timeout ou saída repetida
  |- SHA-256(seed)
  `- palavras seed BIP-39

Análise de Vulnerabilidade

A causa raiz foi um erro de build e integração em torno de MICROPY_HW_ENABLE_RNG. A configuração de placa de produção do COLDCARD definiu essa macro como 0, porque o firmware usava seu próprio wrapper de RNG de hardware local em vez da implementação de RNG de hardware do MicroPython. No entanto, o caminho de geração de carteira havia sido migrado para ngu.random.bytes(32), e o caminho STM32 da libngu dependia em última instância do símbolo global rng_get().

O caminho afetado entrava em my_random_bytes() da libngu. Para cada palavra de saída, ele aplicava XOR ao valor retornado por CHIP_TRNG_32() com sua própria saída 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: estado inicial público
            |         `- Mk4/Q/Mk5: reseed de pad de 32 bits
            |
            `- saída = Yasmarang A XOR Yasmarang B

O Yasmarang A era o fallback do MicroPython por trás de rng_get(), inicializado a partir do estado do dispositivo e dos temporizadores. O Yasmarang B pertencia à libngu: usava valores iniciais públicos no Mk2/Mk3, enquanto Mk4/Q/Mk5 substituía apenas seu pad de 32 bits com dados derivados do elemento seguro. O RNG STM32 local da placa ainda existia, mas ngu.random.bytes() não o chamava.

A incompatibilidade é visível diretamente na configuração de build. O mpconfigboard.h do Mk4 desativava o ramo de RNG de hardware do MicroPython:

// Temos nossa própria versão deste código.
#define MICROPY_HW_ENABLE_RNG (0)

O CHIP_TRNG_32() da libngu ainda tratava a macro como prova suficiente de um RNG de hardware porque verificava apenas se a macro existia e então chamava 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

Essa guarda perde o caso perigoso: uma macro definida como 0 ainda está definida. O build, portanto, teve sucesso, e rng_get() resolveu para o módulo RNG STM32 do MicroPython em vez do wrapper local de placa separado do COLDCARD (random32() / random_buffer(), exposto ao Python como ckcc.rng_bytes). Na seleção de rng_get() do MicroPython, MICROPY_HW_ENABLE_RNG == 0 selecionava o ramo de fallback de software:

#if MICROPY_HW_ENABLE_RNG
    // RNG de hardware STM32
#else
    // Fallback de software Yasmarang
#endif

Mk2/Mk3: Cerca de 40 Bits

O fallback compilado pyb_rng_yasmarang() inicializava e avançava o Yasmarang da seguinte forma:

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);
}

As variáveis ocupam 104 bits, mas o tamanho do estado não é entropia. dat começa em zero, e os outros valores são metadados fixos ou leituras de temporizador correlacionadas em vez de segredos independentes. Sob o modelo frouxo Mk2/Mk3 usado para a figura aproximada de 40 bits:

Entrada Valores candidatos Custo de enumeração
UID_low32 conhecido 1 2^0
SysTick->VAL 80.000 2^16,29
RTC->TR hora do dia 86.400 2^16,40
RTC->SSR subsegundo 256 2^8

Tratar todos os campos de temporizador como independentes fornece um teto intencionalmente amplo:

80.000 * 86.400 * 256
= 1.769.472.000.000
= 2^40,69 estados iniciais candidatos

Uma busca exaustiva, portanto, leva no máximo 2^40,69 tentativas e, sob uma suposição de posição uniforme, cerca de 2^39,69 tentativas em média. Este é um limite superior de enumeração, não 40 bits de entropia criptográfica. Se os registradores RTC forem estáticos durante uma inicialização a frio normal, apenas o SysTick permanece, reduzindo o teto para cerca de 2^16,29. Se o UID, os temporizadores e o número de chamadas RNG anteriores forem conhecidos, há exatamente um fluxo: 2^0. O histórico de chamadas desconhecido acrescenta apenas o número de rastros de execução plausíveis, não uma nova fonte de entropia. Da mesma forma, gerar oito palavras de 32 bits para uma seed de 256 bits não multiplica o espaço de busca: cada palavra é determinada pelo mesmo estado inicial.

Na camada libngu, my_random_bytes() misturou o fallback do MicroPython acima com o gerador Yasmarang separado da libngu. O código-fonte não contém literalmente chip = rng_get(): CHIP_TRNG_32() expande para rng_get(). No Mk2/Mk3, o segundo gerador começava a partir de 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();
// ... verificação de saúde de saída adjacente ...
chip ^= my_yasmarang();

Ambos os fluxos eram, portanto, reproduzíveis uma vez que o estado de fallback do MicroPython e o histórico de chamadas fossem conhecidos. O XOR alterou os valores de saída, mas não adicionou entropia. Mesmo que UID_low32 seja desconhecido, UID_low32 ^ SysTick ainda colapsa ambas as entradas em um único pad de 32 bits; suas contagens nominais de bits não podem ser somadas.

Mk4/Q/Mk5: Cerca de 72 Bits

Os modelos posteriores mantiveram a mesma construção de dois geradores, mas rng_seeding() adicionou material do elemento seguro ao gerador da libngu durante a inicialização:

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)

Embora 40 bytes tenham entrado no hash, apenas seus primeiros quatro bytes chegaram a reseed() [4]. A implementação de random_reseed() então substituiu apenas a palavra pad de 32 bits da libngu:

STATIC mp_obj_t random_reseed(mp_obj_t arg)
{
    yasmarang_pad = mp_obj_get_int_truncated(arg);
    return mp_const_none;
}

As outras palavras de estado da libngu mantiveram seus valores públicos, e o fallback do MicroPython não foi resemeado. A figura aproximada de 72 bits combina o reseed de 32 bits da libngu com um limite superior frouxo para o estado do temporizador dos modelos posteriores:

Fallback do MicroPython:
    120.000 valores SysTick * 86.400 horários RTC * 256 subsegundos
    = 2^41,27 estados

Reseed seguro da libngu:
    2^32 valores

Teto combinado:
    2^41,27 * 2^32 = 2^73,27 candidatos

Enumeração média:
    2^73,27 / 2 = 2^72,27 tentativas

Esta é a origem da figura "cerca de 72 bits". É uma estimativa média de trabalho de ataque, não 72 bits fornecidos pelos elementos seguros. Os campos do temporizador são correlacionados e podem ser reconstruídos; se o estado de fallback do MicroPython for conhecido, apenas os 2^32 valores de reseed permanecem, com média de 2^31 tentativas. Calcular o hash dos 32 bytes aleatórios finais não pode aumentar o número de seeds possíveis.

Versões Afetadas

Dispositivo e trilha Fora desta regressão Firmware de geração de seed afetado Segurança em bits efetiva Primeira versão corrigida
Mk1 Até v3.0.6 Nenhum - N/A
Mk2/Mk3 Até v3.2.2 v4.0.0-v4.1.9 (aviso oficial a partir de v4.0.1) Cerca de 40 bits quando afetado v4.2.0
Mk4/Mk5 Standard N/A Antes de v5.6.0 Cerca de 72 bits antes da correção; pelo menos 128 bits depois v5.6.0
Q Standard N/A Antes de v1.5.0Q Cerca de 72 bits antes da correção; pelo menos 128 bits depois v1.5.0Q
Mk4/Mk5 Edge N/A Antes de v6.6.0X Cerca de 72 bits antes da correção; pelo menos 128 bits depois v6.6.0X
Q Edge N/A Antes de v6.6.0QX Cerca de 72 bits antes da correção; pelo menos 128 bits depois v6.6.0QX

A versão relevante é o firmware que gerou a seed, não o firmware atualmente instalado. Novas seeds geradas na versão corrigida ou posteriores usam o caminho corrigido, mas atualizar não repara uma seed existente. O intervalo oficial afetado para Mk2/Mk3 começa em v4.0.1 [5], enquanto a análise em nível de código-fonte também inclui v4.0.0 [4]. A entropia independente de dados pode aumentar a segurança da seed, enquanto uma passphrase BIP-39 forte adiciona uma barreira separada sem reparar a seed em si [6].

Best Security Auditor for Web3

Validate design, code, and business logic before launch

Rastreamento de Vítimas e Fundos

Não houve transação de exploit on-chain para rastrear; a recuperação foi necessariamente offline. Visando uma seed gerada por firmware afetado, um atacante poderia restringir e enumerar os estados candidatos de RNG descritos acima, reproduzir o fluxo de geração de seed de cada candidato, derivar as chaves de carteira resultantes e confrontá-las com dados públicos de carteira, depois varrer on-chain qualquer carteira financiada correspondente. Isso só atingiu carteiras deriváveis apenas da seed: uma passphrase BIP-39 forte e única mistura entropia independente fornecida pelo usuário — que a falha do RNG nunca tocou — na derivação de chaves através de PBKDF2, colocando tais carteiras fora da enumeração pura de seed. As carteiras que foram varridas eram necessariamente aquelas sem tal proteção [6]. Como o roubo surgiu apenas como varrimentos on-chain em vez de um exploit rastreável, identificar as vítimas e seguir os fundos tornou-se uma questão de análise forense on-chain.

Vários esforços independentes rastrearam os fundos roubados: sites públicos de rastreamento (Coldcard Sweep Watch [1], coldcard.rip [2] e o Coldcard Hack Tracker [7]) e uma reconciliação por canal privado pela Galaxy Research [3], com seus totais reportados comparados abaixo. Como o Coldcard Sweep Watch publicou sua metodologia, a usamos para ilustrar o processo de identificação, um ciclo de retroalimentação entre relatórios off-chain e análise on-chain:

  1. Âncoras off-chain. Vítimas e pesquisadores forneceram endereços públicos ou IDs de transação, além de contexto de dispositivo e geração de seed quando disponível. Cada relato foi tratado como uma pista e verificado on-chain; nenhuma frase seed, chave privada ou xpub foi necessária.
  2. Expansão on-chain. A partir de âncoras confirmadas, os scanners pesquisaram blocos relevantes com as mesmas características de varredura: carteiras esvaziadas sem troco, tipos de entrada semelhantes, timing apertado, taxas de taxa repetidas, destinos comuns ou co-gastos posteriores.
  3. Verificações cruzadas off-chain. Novos relatos de vítimas, conjuntos de dados de pesquisadores e atribuição de serviços foram usados para confirmar ou rejeitar ondas candidatas. Clusters verificados e candidatos heurísticos permaneceram separados.

O Bitcoin identifica endereços, não pessoas ou modelos de carteira. Uma carteira pode controlar muitos endereços, portanto a contagem de endereços não é a contagem de vítimas. A primeira onda principal amplamente relatada, 960188, contabilizou 594,48 BTC; varreduras e relatos contínuos elevaram o total. Em 7 de agosto de 2026, o Coldcard Sweep Watch reportou um piso verificado de cerca de 1.405,07 BTC (~$91M ao preço de $64.700 em 7 de agosto) de aproximadamente 4.925 endereços [1], enquanto o snapshot de 3 de agosto do coldcard.rip atribuiu até 1.433,13 BTC brutos, 1.432,48 BTC para destinos após taxas, de 5.477 endereços em dez ondas [2]. Uma reconciliação separada por canal privado pela Galaxy Research, baseada em correspondência com vítimas, colocou o valor ainda mais alto, de cerca de 1.596 BTC até 2.055 BTC (~$133M ao mesmo preço) [3]. As diferenças refletem limiares de evidência, tempo de descoberta e qual canal de confirmação cada rastreador utiliza.

Os fundos foram então seguidos através de três camadas observadas: endereços de origem varridos, destinos diretos de varredura (holding) e destinos de consolidação subsequentes (vault). A tabela mostra a contagem de endereços distintos em cada camada:

Padrão Rotas de exemplo Consequência para o rastreamento
Muitas varridas para um ou dois endereços holding, depois um vault 960183: 204 -> 2 -> 1; 960188: 500 -> 1 -> 1 A convergência de destino torna o cluster comparativamente forte e fácil de seguir
Varridas param nos endereços holding sem consolidação posterior 960352: 352 -> 1 -> 0; 960668: 795 -> 1 -> 0 O endereço holding permanece rastreável, mas não há co-gasto posterior para fortalecer a atribuição
Destinos novos por varredura, às vezes seguidos por vaults separados 960359: 13 -> 13 -> 0; 960395: 1.918 -> 294 -> 293 Um detector de coletor compartilhado falha; o agrupamento depende de timing, taxa de taxa, template de transação e corroboração off-chain

Quando as saídas rastreadas se movem, a análise segue divisões, fusões e cadeias de descascamento conservando o valor após taxas e limitando a atribuição ao valor varrido. Fundos que entram em uma exchange ou outro serviço commingled reduzem a confiança; o serviço não é adicionado ao cluster do atacante.

Explore MetaSleuth Investigation

Trace flows and build evidence for investigations

Try now for free

Uma Correção que Precisava de Correção: Uma Possível Regressão de Bloqueio Permanente?

Separadamente da falha de entropia, o hotfix que a resolveu introduziu uma regressão de firmware distinta [8, 9]. Ao restaurar o caminho do RNG de hardware, a correção deixou uma condição de erro de seed de hardware sem tratamento, o que pode negar serviço antes do login e gerou alegações de dispositivos permanentemente bloqueados. O quanto essa afirmação mais forte se sustenta depende dos detalhes a nível de registrador.

O RNG de hardware STM32 expõe um registrador de controle (RNG_CR), um registrador de status (RNG_SR) e um registrador de dados de 32 bits (RNG_DR). O status relevante é:

Bit Função
RNGEN Habilita o RNG e suas fontes de ruído analógico
DRDY Indica que os dados estão prontos em RNG_DR; o software ainda deve rejeitar zero
SECS / SEIS Falha atual no teste de saúde de seed / status de erro de seed travado
CECS / CEIS Falha atual no clock do RNG / status de erro de clock travado

A distinção entre status atual e travado é importante. SECS descreve a condição presente da fonte de ruído, enquanto SEIS registra que um erro de seed ocorreu até que o software o limpe. No STM32L4S da família Mk4/Q, um erro de seed interrompe a geração de novos números aleatórios; no STM32L4 do Mk3, os dados podem permanecer disponíveis, mas não devem ser confiados. Erros de clock são separados e não estabelecem esse bloqueio de erro de seed.

A sequência de recuperação necessária depende da geração do STM32:

Família de dispositivo Recuperação de erro de seed documentada
Mk3 STM32L4 (RM0351, gerenciamento de erros RNG [10]) Limpar SEIS, depois limpar e definir RNGEN
Família Mk4/Q STM32L4S (RM0432, gerenciamento de erros RNG [11]) Limpar SEIS, ler e descartar 12 palavras RNG_DR, depois confirmar que SEIS permanece limpo

O hotfix de entropia de 31 de julho corretamente fez rng_get() resolver para o TRNG de hardware, mas o rng_get_or_fault() da família Mk4/Q não implementa recuperação de erro de seed. rng_init() age apenas quando RNGEN está limpo, enquanto o loop de leitura verifica apenas DRDY. Se um erro de seed deixar RNGEN habilitado, mas suprimir DRDY, a inicialização se torna uma operação nula; cada leitura aguarda 10 ms e levanta OSError(EFAULT) sem limpar SEIS.

Isso pode atingir a UI antes do login. Tanto o teclado numérico mempad._start_scan() quanto o Q keyboard._start_scan() embaralham sua ordem de varredura a partir da interrupção de pressionamento de tecla. Uma exceção de RNG ali pode bloquear a entrada de PIN e o menu normal de atualização de firmware pelo restante daquela sessão de hardware.

O caminho de falha no nível de código é plausível, mas a afirmação mais forte de "ataque de bloqueio permanente" não está estabelecida. Os bits de controle e status do RNG são redefinidos para zero em um reset de hardware, portanto, um único erro transitório não deve danificar permanentemente o periférico; a persistência após um ciclo completo de energia não foi demonstrada. Tampouco existe uma maneira remota ou confiavelmente controlada demonstrada de acionar a falha no teste de saúde de seed. Uma publicação no X [8] afirmou que o bloqueio foi confirmado, mas o PR apontado, o PR #692 [9] enviado pela comunidade, tem o próprio autor declarando que analisou a falha com um mock de registrador, não a reproduziu em hardware Mk4/Q real e não confirmou independentemente os relatos de campo. A classificação mais bem fundamentada é, portanto, uma potencial negação de serviço pré-login e regressão de confiabilidade, não um ataque de bloqueio permanente confirmado.

A própria correção dos mantenedores, PR #693 [12], verifica os flags de erro de seed, adiciona recuperação e novas tentativas limitadas, rejeita amostras suspeitas e captura apenas o erro esperado do teclado; foi mesclado em 5 de agosto de 2026, substituindo o PR #692 [9] da comunidade, que foi fechado sem mesclar em 4 de agosto de 2026.

Conclusão

Este incidente foi uma falha de entropia de carteira que transformou a recuperação de seed de criptograficamente inviável em um problema de busca offline para dispositivos e fluxos de trabalho afetados. A principal falha de engenharia foi que o firmware enviado não provou que a API de geração de seed crítica para segurança realmente atingiu o RNG de hardware pretendido. As guardas de build devem verificar tanto a existência da macro quanto o valor da macro, fallbacks para entropia criptográfica devem falhar de forma fechada, e o CI deve verificar a proveniência dos símbolos e o fluxo de entropia de ponta a ponta na imagem final do firmware. Para os usuários afetados, o remédio não é uma atualização de firmware: atualizar não repara uma seed já gerada sob o caminho defeituoso, e adicionar uma passphrase depois não protege os fundos já mantidos nos endereços dessa seed. Esses fundos devem ser movidos para uma carteira construída a partir de uma nova seed em uma versão corrigida; apenas os fundos que já estavam protegidos por uma passphrase forte e única ficaram fora da enumeração somente por seed [6].

Referências

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit