Esta é a segunda parte da série de artigos da Fearless Protection. No primeiro, falamos sobre segurança de memóriaOs aplicativos modernos são multithread: em vez de executar tarefas sequencialmente, o programa usa threads para executar simultaneamente várias tarefas. Todos nós observamos
trabalho simultâneo e
simultaneidade todos os dias:
- Os sites atendem a vários usuários ao mesmo tempo.
- A interface do usuário faz um trabalho em segundo plano que não incomoda o usuário (imagine que toda vez que você digita um caractere, o aplicativo congela para verificar a ortografia).
- Um computador pode executar vários aplicativos ao mesmo tempo.
Fluxos paralelos aceleram o trabalho, mas introduzem um conjunto de problemas de sincronização, como deadlocks e condições de corrida. Do ponto de vista da segurança, por que nos preocupamos com a segurança do encadeamento? Porque a segurança da memória e dos threads tem o mesmo problema principal: uso inadequado de recursos. Os ataques aqui têm os mesmos efeitos que os ataques de memória, incluindo escalonamento de privilégios, execução arbitrária de código (ACE) e ignorar as verificações de segurança.
Erros de simultaneidade, como erros de implementação, estão intimamente relacionados à correção do programa. Embora as vulnerabilidades de memória sejam quase sempre perigosas, os erros de implementação / lógica nem sempre indicam um problema de segurança se não ocorrerem na parte do código relacionada à conformidade com os contratos de segurança (por exemplo, permissão para ignorar uma verificação de segurança). Mas os erros de concorrência têm uma peculiaridade. Se problemas de segurança devido a erros lógicos geralmente aparecem ao lado do código correspondente, os erros de simultaneidade geralmente ocorrem
em outras funções, e não na onde o erro foi cometido diretamente , o que dificulta o rastreamento e a eliminação deles. Outra dificuldade é uma certa sobreposição entre o processamento inadequado da memória e os erros de concorrência, que vemos nas corridas de dados.
As linguagens de programação desenvolveram várias estratégias de simultaneidade para ajudar os desenvolvedores a gerenciar os problemas de desempenho e segurança de aplicativos multithread.
Problemas de simultaneidade
É geralmente aceito que a programação paralela é mais difícil do que o habitual: nosso cérebro está melhor adaptado ao raciocínio seqüencial. O código paralelo pode ter interações inesperadas e indesejadas entre threads, incluindo deadlocks, contenção e corridas de dados.
Um conflito ocorre quando vários encadeamentos esperam um ao outro executar determinadas ações para continuar funcionando. Embora esse comportamento indesejado possa causar um ataque de negação de serviço, não causará vulnerabilidades como o ACE.
Uma condição de corrida é uma situação em que o tempo ou a ordem das tarefas podem afetar a correção de um programa. A corrida de dados ocorre quando vários fluxos tentam acessar simultaneamente o mesmo local de memória com pelo menos uma tentativa de gravação. Acontece que uma condição de corrida e uma corrida de dados
ocorrem independentemente uma da outra. Mas
as corridas de dados são sempre perigosas .
Consequências potenciais de erros de simultaneidade
- Impasse
- Perda de informações: outro segmento substitui as informações
- Perda de integridade: informações de vários fluxos estão entrelaçadas
- Perda de viabilidade: problemas de desempenho devido ao acesso desigual a recursos compartilhados
O tipo mais famoso de ataque de simultaneidade é chamado
TOCTOU (tempo de verificação até o tempo de uso): em essência, o estado de uma corrida é entre verificar condições (por exemplo, credenciais de segurança) e usar os resultados. Um ataque TOCTOU resulta em perda de integridade.
Bloqueios mútuos e perda de capacidade de sobrevivência são considerados problemas de desempenho, não de segurança, enquanto a perda de informações e a integridade provavelmente estão relacionadas à segurança. Um
artigo da Red Balloon Security analisa algumas das possíveis explorações. Um exemplo é a corrupção do ponteiro seguida pela escalada de privilégios ou pela execução remota de código. Na exploração, uma função que carrega a biblioteca compartilhada ELF (formato executável e vinculável) inicia corretamente um semáforo apenas na primeira chamada e limita incorretamente o número de threads, o que causa corrupção na memória do kernel. Este ataque é um exemplo de perda de informações.
A parte mais difícil da programação paralela é testar e depurar, porque os erros de simultaneidade são difíceis de reproduzir. Tempo de eventos, decisões do sistema operacional, tráfego de rede e outros fatores ... tudo isso muda o comportamento do programa a cada início.
Às vezes, é realmente mais fácil remover o programa inteiro do que procurar um bug. HeisenbugsNão apenas o comportamento muda cada vez que é iniciado, mas mesmo operadores de saída ou depuração podem mudar o comportamento, resultando em "bugs de Heisenberg" (erros não determinísticos e difíceis de reproduzir, típicos da programação paralela) que surgem e desaparecem misteriosamente.
A programação paralela é difícil. É difícil prever como o código paralelo irá interagir com outro código paralelo. Quando os erros aparecem, são difíceis de encontrar e corrigir. Em vez de confiar nos testadores, vamos ver maneiras de desenvolver programas e o uso de linguagens que facilitam a escrita de códigos paralelos.
Primeiro, formulamos o conceito de "segurança de threads":
“Um tipo de dado ou método estático é considerado seguro para threads se se comportar corretamente quando chamado de vários threads, independentemente de como esses threads são executados, e não requer coordenação adicional do código de chamada.” MIT
Como as linguagens de programação funcionam com paralelismo
Em idiomas sem segurança de thread estática, os programadores precisam monitorar constantemente a memória compartilhada com outro thread e podem mudar a qualquer momento. Na programação seqüencial, somos ensinados a evitar variáveis globais se outra parte do código as alterar silenciosamente. É impossível exigir que os programadores garantam uma alteração segura nos dados compartilhados, além do gerenciamento manual da memória.
"Vigilância constante!"Normalmente, as linguagens de programação são limitadas a duas abordagens:
- Limitação de mutabilidade ou restrição de acesso compartilhado
- Segurança manual da linha (por exemplo, travas, semáforos)
Os idiomas com restrição de encadeamento colocam um limite de 1 encadeamento para variáveis mutáveis ou exigem que todas as variáveis comuns sejam imutáveis. Ambas as abordagens abordam o problema básico da corrida de dados - dados compartilhados incorretamente modificáveis - mas as restrições são muito severas. Para resolver o problema, os idiomas criaram primitivas de sincronização de baixo nível, como mutexes. Eles podem ser usados para criar estruturas de dados seguras para encadeamento.
Python e bloqueio global por intérprete
A implementação de referência em Python e Cpython possui um tipo de mutex chamado Global Interpreter Lock (GIL), que bloqueia todos os outros threads quando um thread acessa um objeto. O Python multithread é notório por sua
ineficiência devido à latência do GIL. Portanto, a maioria dos programas Python simultâneos trabalha em vários processos para que cada um tenha seu próprio GIL.
Exceções de Java e tempo de execução
Java suporta programação simultânea por meio de um modelo de memória compartilhada. Cada encadeamento possui seu próprio caminho de execução, mas pode acessar qualquer objeto no programa: o programador deve sincronizar o acesso entre os encadeamentos usando as primitivas Java integradas.
Embora o Java tenha blocos de construção para a criação de programas seguros para encadeamento, a
segurança do encadeamento não é garantida pelo compilador (em oposição à segurança da memória). Se ocorrer acesso não sincronizado à memória (ou seja, corrida de dados), o Java lançará uma exceção em tempo de execução, mas os programadores devem usar corretamente as primitivas de simultaneidade corretamente.
C ++ e o cérebro do programador
Enquanto o Python evita condições de corrida com o GIL e o Java lança exceções em tempo de execução, o C ++ espera que o programador sincronize manualmente o acesso à memória. Antes do C ++ 11, a biblioteca padrão
não incluía primitivas de simultaneidade .
A maioria dos idiomas fornece ferramentas para escrever códigos seguros para threads, e existem métodos especiais para detectar dados de corrida e status de corrida; mas não oferece nenhuma garantia de segurança do encadeamento e não protege contra a corrida de dados.
Como resolver o problema de ferrugem?
O Rust adota uma abordagem multifacetada para eliminar as condições de corrida, usando regras de propriedade e tipos seguros para proteger completamente contra as condições de corrida em tempo de compilação.
No
primeiro artigo, introduzimos o conceito de propriedade, este é um dos conceitos básicos do Rust. Cada variável possui um proprietário único e a propriedade pode ser transferida ou emprestada. Se outro encadeamento quiser alterar o recurso, transferimos a propriedade movendo a variável para um novo encadeamento.
Mover gera uma exceção: vários threads podem gravar na mesma memória, mas nunca ao mesmo tempo. Como o proprietário está sempre sozinho, o que acontece se outro encadeamento emprestar uma variável?
Em Rust, você tem um empréstimo mutável ou vários empréstimos imutáveis. Não é possível introduzir simultaneamente empréstimos mutáveis e imutáveis (ou vários mutáveis). Na segurança da memória, é importante que os recursos sejam liberados corretamente e, na segurança do encadeamento, é importante que apenas um encadeamento tenha o direito de alterar uma variável a qualquer momento. Além disso, em tal situação, nenhum outro fluxo se refere a empréstimos obsoletos: a gravação ou o compartilhamento são possíveis, mas não os dois.
O conceito de propriedade foi projetado para solucionar vulnerabilidades de memória. Descobriu-se que também impede a corrida de dados.
Embora muitos idiomas possuam métodos de segurança de memória (como contagem de links e coleta de lixo), eles geralmente contam com sincronização manual ou proibições de compartilhamento simultâneo para impedir a corrida de dados. A abordagem Rust aborda os dois tipos de segurança, tentando resolver o principal problema de determinar o uso aceitável dos recursos e garantir essa validade no tempo de compilação.
Mas espera! Isso não é tudo!
As regras de propriedade impedem que vários threads gravem dados no mesmo local de memória e proíbem a troca simultânea de dados entre threads e mutabilidade, mas isso não fornece necessariamente estruturas de dados seguras para threads. Cada estrutura de dados no Rust é thread thread safe ou não. Isso é passado para o compilador usando um sistema de tipos.
"Um programa bem digitado não pode cometer um erro." - Robin Milner, 1978
Nas linguagens de programação, os sistemas de tipos descrevem um comportamento aceitável. Em outras palavras, um programa bem digitado está bem definido. Enquanto nossos tipos forem expressivos o suficiente para capturar o significado pretendido, um programa bem digitado se comportará como pretendido.
Rust é uma linguagem de tipo seguro, aqui o compilador verifica a consistência de todos os tipos. Por exemplo, o código a seguir não é compilado:
let mut x = "I am a string"; x = 6;
error[E0308]: mismatched types --> src/main.rs:6:5 | 6 | x = 6;
Todas as variáveis em Rust são do tipo frequentemente implícitas. Também podemos definir novos tipos e descrever os recursos de cada tipo usando
o sistema de características . Os traços fornecem uma abstração da interface. Duas características internas importantes são
Send
e
Sync
, que são fornecidas por padrão pelo compilador para cada tipo:
Send
indica que a estrutura pode ser transferida com segurança entre encadeamentos (necessário para transferir a propriedade)
Sync
indica que os threads podem usar a estrutura com segurança.
O exemplo abaixo é uma versão simplificada do
código da biblioteca padrão que gera threads:
fn spawn<Closure: Fn() + Send>(closure: Closure){ ... } let x = std::rc::Rc::new(6); spawn(|| { x; });
A função de
spawn
usa um único argumento,
closure
e requer um tipo para o último que implementa os traços
Send
e
Fn
. Ao tentar criar um fluxo e transmitir o valor de
closure
com a variável
x
compilador gera um erro:
erro [E0277]: `std :: rc :: Rc <i32>` não pode ser enviado entre threads com segurança
-> src / main.rs: 8: 1
|
8 desovar (mover || {x;});
| ^^^^^ `std :: rc :: Rc <i32>` não pode ser enviado entre threads com segurança
|
= help: dentro de `[encerramento@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]`, o atributo `std :: marker :: Send` não está implementado para `std :: rc :: Rc <i32>`
= note: obrigatório porque aparece dentro do tipo `[encerramento@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]`
nota: requerido por `spawn`
As características de Send
e
Sync
permitem que o sistema do tipo Rust entenda quais dados podem ser compartilhados. Ao incluir essas informações no sistema de tipos, a segurança da rosca se torna parte do tipo de segurança. Em vez de documentação, a
segurança do thread é implementada pela lei do compilador .
Os programadores veem claramente objetos comuns entre os encadeamentos, e o compilador garante a confiabilidade dessa instalação.
Embora ferramentas de programação paralela estejam disponíveis em vários idiomas, não é fácil impedir as condições de corrida. Se você precisar que os programadores alternem instruções complexamente e interajam entre os threads, os erros serão inevitáveis. Embora as violações de segurança de thread e memória tenham consequências semelhantes, as proteções tradicionais de memória, como contagem de links e coleta de lixo, não impedem as condições de corrida. Além da garantia estática de segurança da memória, o modelo de propriedade Rust também evita alterações inseguras de dados e compartilhamento incorreto de objetos entre threads, enquanto o sistema de tipos fornece segurança de thread no tempo de compilação.