WEB3DEV

Cover image for Integrando injeção de falhas no desenvolvimento de fluxos de trabalho - Ledger
Panegali
Panegali

Posted on • Atualizado em

Integrando injeção de falhas no desenvolvimento de fluxos de trabalho - Ledger

As vulnerabilidades de injeção de falhas podem ser difíceis de avaliar, mesmo com as ferramentas e os especialistas certos. Poderíamos melhorar esta situação dando ferramentas apropriadas de código aberto aos desenvolvedores?

Como um primeiro passo para mitigar os ataques de injeção de falhas, introduzimos uma nova ferramenta de avaliação de código aberto que se integra sem problemas a uma IDE ou pipelines de teste de integração contínua. Ilustramos o uso desta ferramenta usando a linguagem de programação Rust.

1

Tabela de conteúdos

  • Simulação de injeção de falhas com Rainbow
  • Integração em fluxos de trabalho de desenvolvimento em Rust
    • Escrevendo testes de avaliação de injeção de falhas em Rust
    • Como isso funciona?
  • Exemplos de avaliação de código e mitigação
    • Exemplo 1: comparação de código PIN no estilo imperativo
    • Exemplo 2: comparação de código PIN com estilo funcional
  • Conclusão

Simulação de injeção de falhas com Rainbow

Os efeitos da injeção de falhas são geralmente simulados como corrupções durante a escrita do registro (modelo bit stuck-at) ou como saltos de instruções¹. Dispositivos críticos de segurança incorporados, como cartões inteligentes e hardware wallets, precisam ser reforçados usando estes modelos para garantir que estes efeitos não introduzam vulnerabilidades em seu processamento.

2 Dispositivo de injeção de falhas eletromagnética utilizando uma Scaffold board e uma SiliconToaster.

Ledger Donjon tem desenvolvido o canal paralelo Python de código aberto e o simulador de injeção de falhas chamado Rainbow desde 2019. Recentemente, adicionamos suporte de primeira classe para a simulação de ataques de injeção de falha, fornecendo modelos de falha:

  • fault_skip modela ataques de falha, fazendo com que uma instrução seja ignorada durante a execução,
  • fault_stuck_at modela ataques de falha causando a anulação do registro de destino de uma instrução com um valor falho durante a execução. Um modelo “stuck-at zeros” é muitas vezes referido como um ataque de redefinição de bits, e um modelo “stuck-at ones” é muitas vezes referido como um ataque de conjunto de bits.

O uso destes modelos com Rainbow torna possível simular rapidamente os efeitos de diferentes ataques de injeção de falhas sem necessidade de acesso a equipamentos caros, como visto acima, mas também eliminando incertezas e medindo efeitos como o jitter da equação. Também abre a possibilidade de verificar exaustivamente que um determinado modelo de falha não pode ser aplicado a um determinado código.

Vamos ilustrar o uso do modelo fault_stuck_at na terceira instrução de um processo de verificação do PIN retirado de um firmware Trezor mais antigo:

>>> from rainbow.devices.stm32 import rainbow_stm32f215
>>> from rainbow.fault_models import fault_stuck_at
>>>
>>> # Instancie o dispositivo emulado e carregue o firmware
>>> emulator = rainbow_stm32f215()
>>> emulator.load("examples/HW_analysis/trezor.elf")
134454507
>>> emulator.trace_regs = True  # also output register values
>>>
>>> # Configure o PIN de referência e teste
>>> emulator[0x08008110 + 0x189] = b"1874\x00"
>>> emulator[0xCAFECAFE] = b"0000\x00"
>>>
>>> # Falha na 3ª instrução dentro da função "storage_containsPin" 
>>> # com o modelo de falha "stuck_at_0xFFFFFFFF". Este modelo 
>>> # substitui o registro de destino da instrução atual com       
>>> # 0xFFFFFFFF
>>> emulator["r0"] = 0xCAFECAFE  # memory address of PIN attempt
>>> emulator["lr"] = 0xAAAAAAAA  # function return address
>>> begin = emulator.functions["storage_containsPin"]
>>> emulator.start_and_fault(fault_stuck_at(0xFFFFFFFF), 2, begin, 0xAAAAAAAA)

 8012458  push    {r0, r1, r2, r4, r5, r6, r7, lr}; sp = 3fffffdf
 801245A  ldr     r2, [pc, #0x38]     ;# r2 = 2001fff8
 801245C  mov     r5, r0              ;# /!\ fault_stuck_at_0xFFFFFFFF /!\  r5 = ffffffff
 801245E  ldr     r3, [r2]            ;# r3 = 00000000
 8012460  ldr     r7, [pc, #0x34]     ;# r7 = 08008110
 8012462  str     r3, [sp, #4]        ;#
 8012464  movs    r3, #0              ;# r3 = 00000000
 8012466  subs    r4, r5, r0          ;# r4 = 35013501
 8012468  ldrb    r6, [r5], #1        ;# r6 = 00000000  r5 = 00000000
 801246C  add     r4, r7              ;# r4 = 3d01b611
 801246E  ldrb.w  r1, [r4, #0x189]    ;# r1 = 00000000
 8012472  cbnz    r6, #0x8012488      ;#
 8012474  orrs    r3, r1              ;# r3 = 00000000
 8012476  ldr     r1, [sp, #4]        ;# r1 = 00000000
 8012478  ldr     r3, [r2]            ;# r3 = 00000000
 801247A  ite     eq                  ;# itstate = 00000000
 801247C  movs    r0, #1              ;# r0 = 00000001
 8012480  cmp     r1, r3              ;# cpsr = 600001f3
 8012482  beq     #0x8012490          ;# pc = 08012490
 8012490  add     sp, #0xc            ;# sp = 3fffffeb
 8012492  pop     {r4, r5, r6, r7, pc};#134292572
>>> emulator["r0"]  # r0 is the function return value register
1
>>> hex(emulator["pc"])
'0xaaaaaaaa'
Enter fullscreen mode Exit fullscreen mode

Falhamos com sucesso a saída da função de comparação do código PIN: em vez de retornar 0 como esperado (1874 != 0000), ele retornou 1.

Podemos encontrar todas as instruções vulneráveis a um ataque de falha única com estes modelos de falha se repetirmos esta simulação de falha em cada instrução. Entretanto, este método não pode encontrar vulnerabilidades causadas por efeitos de injeção de falha que não são modeladas. Outra falha é que não esperamos que os desenvolvedores de firmware escrevam código Python para cada pedaço de código crítico que eles precisem fortalecer a qualquer momento.

Integração dos fluxos de trabalho de desenvolvimento no Rust

Os desenvolvedores estão acostumados a utilizar pipelines de verificação e teste de estilo de código em seus fluxos de trabalho diários. Inspirando-se na forma como essas ferramentas são utilizadas, propomos uma nova ferramenta chamada fi_check que verifica as possíveis vulnerabilidades de injeção de falhas. Esta ferramenta foi projetada para ser facilmente incorporada em uma IDE ou pipeline de teste contínuo, permitindo que os desenvolvedores sejam alertados por modificações de código que introduzem vulnerabilidades de injeção de falha única.

3

Consideramos apenas ataques de injeção de falha única para simplificar o problema. Uma generalização ingênua para ataques de injeção de N falhas aumentaria exponencialmente o tempo de avaliação. Proteger o código contra injeções de falha única ainda é um objetivo importante, pois dificulta muito os ataques em potencial.

Escrevendo testes de avaliação de injeção de falhas no Rust

Vamos considerar que compare_pin é uma função crítica de segurança que precisa ser reforçada contra ataques de injeção de falha única. Para definir o comportamento esperado desta função e prepará-la para avaliação automática de injeção de falhas, pode-se anexar ao seu código-fonte Rust:

#[cfg(test)]
mod tests_fi {
    // rust_fi é uma crate Rust contendo ganchos de fi_check
    use rust_fi::{assert_eq, rust_fi_faulted_behavior, rust_fi_nominal_behavior};

    const CORRECT_PIN: [u8; 4] = [1, 2, 3, 4];

    // Um primeiro teste de injeções de falhas contra uma verificação de código PIN
    #[no_mangle]
    #[inline(never)]
    fn test_fi_compare_pin() {
        assert_eq!(compare_pin(&[0, 0, 0, 0], &CORRECT_PIN), false);
    }

    // Mesmo teste de injeções de falhas, mas com apenas um dígito diferente
    #[no_mangle]
    #[inline(never)]
    fn test_fi_compare_pin_variant() {
        assert_eq!(compare_pin(&[2, 2, 3, 4], &CORRECT_PIN), false);
    }
}
Enter fullscreen mode Exit fullscreen mode

Esta estrutura se parece com os testes clássicos de Rust, declarando que a função compare_pin retorna false quando os códigos PIN não correspondem. fi_check pode reconhecer estes testes e avalia se pode fazer compare_pin retornar true ao falhar em suas instruções. Não usamos o macro #[test], pois só precisamos que o símbolo da função exista no binário compilado para executá-lo posteriormente com o Rainbow.

Graças a esta ferramenta, as crates Rust podem ser rapidamente avaliadas quanto a potencial vulnerabilidade à injeção de falha única ao:

  • Adicionar a crate rust_fi às dev-dependencies do projeto,
  • Escrever testes de robustez de injeção de falhas usando a estrutura acima,
  • Executa fi_check.py na crate.

Por padrão, o fi_check.py instancia um emulador Rainbow configurado para destinos ARM, mas isso pode ser facilmente alterado para atingir outras arquiteturas.

Como isto funciona?

Detecção bem sucedida de falhas por injeção:

Consideramos uma função que não recebe argumentos e retorna um valor Booleano (true ou false). Esta lógica de função é escrita para sempre retornar false verificando uma condição inválida². Em teoria, essa função deve sempre retornar false. No entanto, se executarmos esta função em hardware real, ela pode resultar em 3 estados diferentes:

  • Comportamento nominal: o código retornou false conforme o esperado,
  • Comportamento com falha: o código retornou true, significando que a execução interrompida fez com que a verificação fosse ignorada,
  • Em pânico ou travado: uma exceção foi gerada durante a execução, como fora dos limites, ou o dispositivo recebeu uma instrução inesperada e travou.

Hardware seguro que não esteja em condições extremas deve sempre se comportar como comportamento nominal. No nosso caso, queremos detectar se um ataque criando uma única falha no processamento seria capaz de obter um comportamento falho sem gerar uma exceção ou travar o dispositivo.

4

assert_eq! é um macro que gera um pânico se os operandos diferem. Podemos distinguir entre estes 3 estados usando um macro assert_eq! modificado no Rust.

Algoritmo de avaliação proposto:

Escolhemos um dos modelos de falha propostos, então:

  • Executamos a função várias vezes, mas em cada execução, aplicamos o modelo de falha escolhido na instrução i-th. i começa a partir da primeira instrução e incrementa até chegarmos ao final da função.
  • Quando a função retorna true sem entrar em pânico ou travar, sabemos qual instrução torna a função vulnerável a este modelo de falha.

5

Se o desenvolvedor não estiver trabalhando diretamente no código assembly, usamos a ferramenta³ addr2line, que pode recuperar qual linha de código gerou a instrução assembly problemática. Isso requer a compilação do código com símbolos de depuração⁴.

Exemplos de avaliação e mitigação de código

Ilustraremos o uso desta ferramenta com alguns trechos de código que são vulneráveis ​​a ataques de injeção de falha única, uma vez compilados no assembly ARM Cortex-M3 (ARM Thumb). Uma função comumente usada para esse tipo de benchmark é a comparação crítica do código PIN.

Exemplo 1: comparação de código PIN de estilo imperativo

Vamos considerar a seguinte função de comparação de código PIN escrita em um código Rust de estilo imperativo:

/// Retorna true se os pins forem iguais, false caso contrário
pub fn compare_pin(user_pin: &[u8], ref_pin: &[u8]) -> bool {
   let mut good = true;
   for i in 0..ref_pin.len() {
       if user_pin[i] != ref_pin[i] {
           good = false;  // src/lib.rs:22
       }
   }
   good
}
Enter fullscreen mode Exit fullscreen mode

O compilador gera o seguinte código assembly:

6

Como esperado pela convenção de chamada utilizada pelo Rust, a matriz user_pin é representada por um ponteiro em r0 e um tamanho em r1, a matriz ref_pin é representada por um ponteiro em r2 e um tamanho em r3 e o valor retornado é representado por r0.

Executamos ./fi_check.py --cli test_fi_simple para verificar se há falhas interessantes:

7

A saída indica instruções vulneráveis na função test_fi_simple, que é a função de teste que chama compare_pin, então podemos ignorá-las. Também indica que esta função é vulnerável a um ataque de falha de conjunto de bits. Ao analisar o código-fonte, entendemos que isso se deve ao fato de o desenvolvedor inicializar o valor retornado como true e, em seguida, defini-lo como false durante a comparação. Essa exploração de vulnerabilidade consiste em definir good=0xFFFFFFFF na última iteração do loop, que Rust considera equivalente a true.

Em uma nota lateral, também observamos que o compilador Rust faz com que o código entre em pânico se a matriz user_pin for acessada fora dos limites (verificado em 0x90) como esperado de uma linguagem segura para a memória.

Reforço através de chamada dupla e inlining:

Esta função compare_pin é vulnerável a um ataque de falha simples. Uma mitigação comum é simplesmente executar o teste duas vezes.

#[inline(always)]
pub fn compare_pin_double_inline(user_pin: &[u8], ref_pin: &[u8]) -> bool {
    if compare_pin(user_pin, ref_pin) {
        // Se a segunda chamada compare_pin retornar false, sabemos 
        // que ocorreu uma falha e devemos tratá-la. Escolhemos 
        // simplificar este exemplo retornando false.
        compare_pin(ref_pin, user_pin)
    } else {
        false
    }
}
Enter fullscreen mode Exit fullscreen mode

A execução de uma avaliação com fi_check nesta função confirma que a reforçamos com sucesso:

8

Reforço utilizando um tipo Booleano protegido:

Os valores Booleanos geralmente são codificados no primeiro bit de um registro, o que significa que os ataques de injeção de falhas “stuck-at” podem inverter seu valor. Um método para proteger esses valores contra vulnerabilidades de injeção de falhas é alterar a representação de “true” e “false”. Escolhemos a seguinte representação em 32 bits:

// TRUE não é o oposto de FALSE para forçar o compilador a não usar // NEG o primeiro e último bits são 0, caso o compilador determine  // que esse bit pode ser lançado em um Booleano
const TRUE: u32 = 0b0010_1010_1010_1010_1010_1110_1010_1010;
const FALSE: u32 = 0b0110_0101_0101_0110_1100_0011_0101_1100;
Enter fullscreen mode Exit fullscreen mode

Isso nos permite usar os 31 bits extras para fazer a verificação de erros. Implementamos essas verificações como um tipo Rust Bool.

Podemos então usá-lo em nossa função de verificação de PIN:

pub fn compare_pin_protected(user_pin: &[u8], ref_pin: &[u8]) -> Bool {
    let mut good = Bool::from(true);
    for i in 0..ref_pin.len() {
        if user_pin[i] != ref_pin[i] {
            good = Bool::from(false);
        }
    }
    good
}
Enter fullscreen mode Exit fullscreen mode

fi_check confirma que este método funciona:

11

Exemplo 2: comparação de código PIN de estilo funcional

Algumas vezes pode ser difícil prever como uma função será montada. Para fins de ilustração, vamos mudar para o código de estilo funcional:

pub fn compare_pin_fp(user_pin: &[u8], ref_pin: &[u8]) -> bool {
   user_pin
       .iter()
       .zip(ref_pin.iter())
       .fold(0, |acc, (a, b)| acc | (a ^ b))
       == 0
}
Enter fullscreen mode Exit fullscreen mode

O compilador gera o seguinte código assembly:

10

Nossa ferramenta pode encontrar 9 pontos vulneráveis, 4 vulnerabilidades com o modelo fault_skip, 3 com o modelo stuck_at_0x0 e 2 com o modelo stuck_at_0xFFFFFFFF:

9

Reforço utilizando um tipo Booleano protegido:

Vamos usar o tipo Booleano protegido que descrevemos anteriormente:

use fault_detection::bool::Bool;

pub fn compare_pin_fp_protected(user_pin: &[u8], ref_pin: &[u8]) -> Bool {
   !user_pin
       .iter()
       .zip(ref_pin.iter())
       .fold(Bool::from(false), |acc, (a, b)| {
           acc | Bool::from(a != b)
       })
}
Enter fullscreen mode Exit fullscreen mode

Utilizando o tipo Booleano protegido, agora temos 2 instruções vulneráveis. Essas duas últimas vulnerabilidades são devidas a uma verificação de tamanho antecipado na entrada que faz com que a função retorne true se uma matriz estiver vazia. Em nosso contexto, devemos tratar esses casos manualmente.

use fault_detection::bool::Bool;

pub fn compare_pin_fp_protected(user_pin: &[u8], ref_pin: &[u8]) -> Bool {
   if ref_pin.is_empty() || user_pin.len() != ref_pin.len(){
       return Bool::from(false);
   }

   !user_pin
       .iter()
       .zip(ref_pin.iter())
       .fold(Bool::from(false), |acc, (a, b)| {
           acc | Bool::from(a != b)
       })
}
Enter fullscreen mode Exit fullscreen mode

Agora nossa ferramenta não encontra mais nenhuma instrução vulnerável, voilà!

Conclusão

Publicamos o script de avaliação e as crates Rust associadas em https://github.com/Ledger-Donjon/fault_injection_checks_demo/.

Mostramos que somos capazes de simular o efeito de ataques de injeção de falhas modelados usando Rainbow. Em seguida, integramos fortemente esse simulador com o ecossistema Rust para demonstrar um cenário em que essas avaliações são relativamente fáceis de configurar para os desenvolvedores.

Para demonstrar a integração de tais ferramentas em fluxos de trabalho, abrimos uma pull request que apresenta uma vulnerabilidade: https://github.com/Ledger-Donjon/fault_injection_checks_demo/pull/13. As verificações automatizadas falham devido o fi_check encontrar uma vulnerabilidade.

Essa ferramenta permite que os desenvolvedores projetem novos tipos Rust protegidos contra ataques de injeção de falhas. Propomos um projeto inicial de um tipo Boleano protegido e uma estrutura Protected que reforce os traços PartialEq.


  1. M. Otto, "Fault attacks and countermeasures". Dissertação de doutorado, Universidade de Paderborn, 2005

  2. Consideramos que o compilador não otimiza a condição. Isto sempre pode ser aplicado com alguns truques, se necessário, por exemplo, com https://doc.rust-lang.org/std/hint/fn.black_box.html

  3. Do GNU Binutils, disponível na maioria das distribuições GNU/Linux. Uma versão multiplataforma também pode ser instalada a partir de https://github.com/gimli-rs/addr2line.

  4. Usamos o perfil de lançamento em Rust com debug=true. Isto não aumenta o tamanho binário final em flash para os binários integrados.


Este artigo foi escrito por Alexandre looss e traduzido por Marcelo Panegali. O original pode ser encontrado aqui.

Top comments (0)