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.
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.
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'
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.
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);
}
}
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
àsdev-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.
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.
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
}
O compilador gera o seguinte código assembly:
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:
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
}
}
A execução de uma avaliação com fi_check
nesta função confirma que a reforçamos com sucesso:
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;
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
}
fi_check
confirma que este método funciona:
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
}
O compilador gera o seguinte código assembly:
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
:
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)
})
}
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)
})
}
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.
M. Otto, "Fault attacks and countermeasures". Dissertação de doutorado, Universidade de Paderborn, 2005
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
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.
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.
Oldest comments (0)