it-swarm-pt.tech

Como o ASLR e o DEP funcionam?

Como funcionam a Randomização do Layout do Espaço de Endereço (ASLR) e a Prevenção de Execução de Dados (DEP), em termos de impedir a exploração de vulnerabilidades? Eles podem ser ignorados?

115
Polynomial

A Randomização do Layout do Espaço de Endereço (ASLR) é uma tecnologia usada para ajudar a impedir que o código de shell seja bem-sucedido. Faz isso deslocando aleatoriamente a localização dos módulos e certas estruturas da memória. A Prevenção de Execução de Dados (DEP) impede certos setores da memória, por exemplo a pilha, de ser executada. Quando combinados, torna-se extremamente difícil explorar vulnerabilidades em aplicativos usando técnicas de código de shell ou de programação orientada a retornos (ROP).

Primeiro, vamos ver como uma vulnerabilidade normal pode ser explorada. Ignoraremos todos os detalhes, mas digamos que estamos usando uma vulnerabilidade de estouro de buffer de pilha. Carregamos um grande blob de valores 0x41414141 Em nossa carga útil e eip foi definido como 0x41414141, Portanto sabemos que é explorável. Em seguida, utilizamos uma ferramenta apropriada (por exemplo, pattern_create.rb Do Metasploit) para descobrir o deslocamento do valor sendo carregado em eip. Esse é o deslocamento inicial do nosso código de exploração. Para verificar, carregamos 0x41 Antes desse deslocamento, 0x42424242 No deslocamento e 0x43 Após o deslocamento.

Em um processo não ASLR e não DEP, o endereço da pilha é o mesmo toda vez que executamos o processo. Sabemos exatamente onde está a memória. Então, vamos ver como é a pilha com os dados de teste que descrevemos acima:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 42424242   > esp points here
 000ff6b4  | 43434343
 000ff6b8  | 43434343

Como podemos ver, esp aponta para 000ff6b0, Que foi definido como 0x42424242. Os valores anteriores a isso são 0x41 E os valores a seguir são 0x43, Como dissemos que deveriam ser. Agora sabemos que o endereço armazenado em 000ff6b0 Será saltado para. Portanto, configuramos o endereço de alguma memória que podemos controlar:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 000ff6b4
 000ff6b4  | cccccccc
 000ff6b8  | 43434343

Definimos o valor em 000ff6b0 De modo que eip seja definido como 000ff6b4 - o próximo deslocamento na pilha. Isso fará com que 0xcc Seja executado, que é uma instrução int3. Como int3 É um ponto de interrupção de interrupção de software, gera uma exceção e o depurador é interrompido. Isso nos permite verificar se a exploração foi bem-sucedida.

> Break instruction exception - code 80000003 (first chance)
[snip]
eip=000ff6b4

Agora podemos substituir a memória em 000ff6b4 Por shellcode, alterando nossa carga útil. Isso conclui nossa façanha.

Para impedir que essas explorações tenham êxito, foi desenvolvido o Data Execution Prevention. A DEP força certas estruturas, incluindo a pilha, a serem marcadas como não executáveis. Isso é reforçado pelo suporte da CPU com o bit No-Execute (NX), também conhecido como bit XD, bit EVP ou bit XN, que permite que a CPU imponha direitos de execução no nível do hardware. O DEP foi introduzido no Linux em 2004 (kernel 2.6.8) e a Microsoft o introduziu em 2004 como parte do WinXP SP2. Apple adicionou suporte a DEP quando eles se mudaram para a arquitetura x86 em 2006. Com a DEP ativada, nossa exploração anterior não funcionará:

> Access violation - code c0000005 (!!! second chance !!!)
[snip]
eip=000ff6b4

Isso falha porque a pilha está marcada como não executável e tentamos executá-la. Para contornar isso, uma técnica chamada Programação Orientada a Retorno (ROP) foi desenvolvida. Isso envolve procurar pequenos trechos de código, chamados de gadgets ROP, em módulos legítimos dentro do processo. Esses gadgets consistem em uma ou mais instruções, seguidas por um retorno. O encadeamento destes junto com os valores apropriados na pilha permite que o código seja executado.

Primeiro, vamos ver como está nossa pilha agora:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 000ff6b4
 000ff6b4  | cccccccc
 000ff6b8  | 43434343

Sabemos que não podemos executar o código em 000ff6b4, Portanto, precisamos encontrar algum código legítimo que possamos usar. Imagine que nossa primeira tarefa é obter um valor no registro eax. Procuramos uma combinação pop eax; ret Em algum lugar de qualquer módulo do processo. Depois que encontramos um, digamos em 00401f60, Colocamos seu endereço na pilha:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 00401f60
 000ff6b4  | cccccccc
 000ff6b8  | 43434343

Quando este código de shell é executado, obteremos uma violação de acesso novamente:

> Access violation - code c0000005 (!!! second chance !!!)
eax=cccccccc ebx=01020304 ecx=7abcdef0 edx=00000000 esi=7777f000 edi=0000f0f1
eip=43434343 esp=000ff6ba ebp=000ff6ff

A CPU agora fez o seguinte:

  • Saltou para a instrução pop eax Em 00401f60.
  • Apareceu cccccccc da pilha, em eax.
  • Executou o ret, inserindo 43434343 Em eip.
  • Lançada uma violação de acesso porque 43434343 Não é um endereço de memória válido.

Agora, imagine que, em vez de 43434343, O valor em 000ff6b8 Tenha sido definido como o endereço de outro dispositivo ROP. Isso significa que pop eax É executado e, em seguida, nosso próximo gadget. Podemos encadear gadgets juntos assim. Nosso objetivo final é geralmente encontrar o endereço de uma API de proteção de memória, como VirtualProtect, e marcar a pilha como executável. Incluiríamos um gadget ROP final para executar uma instrução equivalente jmp esp E executar o código de shell. Ignoramos com êxito o DEP!

Para combater esses truques, o ASLR foi desenvolvido. O ASLR envolve a deslocação aleatória das estruturas de memória e dos endereços base dos módulos, tornando muito difícil adivinhar a localização dos dispositivos e APIs ROP.

No Windows Vista e 7, o ASLR randomiza o local dos executáveis ​​e DLLs na memória, bem como a pilha e pilhas. Quando um executável é carregado na memória, o Windows obtém o contador de carimbo de data/hora do processador (TSC), o altera em quatro locais, executa o mod 254 da divisão e adiciona 1. Esse número é multiplicado por 64 KB e a imagem do executável é carregada nesse deslocamento. . Isso significa que existem 256 locais possíveis para o executável. Como as DLLs são compartilhadas na memória entre os processos, suas compensações são determinadas por um valor de viés em todo o sistema que é calculado na inicialização. O valor é calculado como o TSC da CPU quando a função MiInitializeRelocations é chamada pela primeira vez, deslocada e mascarada em um valor de 8 bits. Este valor é calculado apenas uma vez por inicialização.

Quando as DLLs são carregadas, elas entram em uma região de memória compartilhada entre 0x50000000 E 0x78000000. O primeiro DLL a ser carregado é sempre ntdll.dll, carregado em 0x78000000 - bias * 0x100000, Onde bias é o valor de polarização de todo o sistema calculado na inicialização. Como seria trivial calcular o deslocamento de um módulo se você souber o endereço base do ntdll.dll, a ordem na qual os módulos são carregados também será aleatória.

Quando os threads são criados, o local da base da pilha é aleatório. Isso é feito localizando 32 locais apropriados na memória e escolhendo um com base no TSC atual, mascarado em um valor de 5 bits. Uma vez calculado o endereço base, outro valor de 9 bits é derivado do TSC para calcular o endereço base da pilha final. Isso fornece um alto grau teórico de aleatoriedade.

Por fim, o local dos heaps e das alocações de heap são aleatórios. Isso é calculado como um valor derivado do TSC de 5 bits multiplicado por 64 KB, fornecendo um intervalo de heap possível de 00000000 A 001f0000.

Quando todos esses mecanismos são combinados com a DEP, somos impedidos de executar o código de shell. Isso ocorre porque não podemos executar a pilha, mas também não sabemos onde nossas instruções ROP estarão na memória. Certos truques podem ser feitos com nop sleds para criar uma exploração probabilística, mas eles não são totalmente bem-sucedidos e nem sempre são possíveis de serem criados.

A única maneira de contornar DEP e ASLR de maneira confiável é através de um vazamento de ponteiro. É uma situação em que um valor na pilha, em um local confiável, pode ser usado para localizar um ponteiro de função utilizável ou um gadget ROP. Feito isso, às vezes é possível criar uma carga útil que ignore os dois mecanismos de proteção de maneira confiável.

Fontes:

Leitura adicional:

153
Polynomial

Para complementar a resposta automática do @ Polynomial: a DEP pode ser aplicada em máquinas x86 mais antigas (anteriores ao bit NX), mas a um preço.

A maneira fácil, porém limitada, de executar DEP no hardware x86 antigo é usar registros de segmento. Com os sistemas operacionais atuais em tais sistemas, os endereços são valores de 32 bits em um espaço de endereço plano de 4 GB, mas internamente cada acesso à memória usa implicitamente um endereço de 32 bits e um registro especial de 16 bits , chamado de "registro de segmento".

No chamado modo protegido, os registradores de segmentos apontam para uma tabela interna (a "tabela de descritores" - na verdade existem duas dessas tabelas, mas isso é um detalhe técnico) e cada entrada na tabela especifica as características do segmento. Em particular, os tipos de acessos permitidos e o tamanho do segmento. Além disso, a execução do código usa implicitamente o registro do segmento CS, enquanto o acesso aos dados usa principalmente DS (e o acesso à pilha, por exemplo, com os códigos de operação Push e pop, usa SS). Isso permite que o sistema operacional divida o espaço de endereço em duas partes; os endereços inferiores estão dentro do intervalo para CS e DS, enquanto os endereços superiores estão fora do intervalo para CS. Por exemplo, o segmento descrito por CS é feito para o tamanho de 512 MB. Isso significa que qualquer endereço além de 0x20000000 estará acessível como dados (lido ou gravado usando DS como registro base), mas as tentativas de execução usarão CS, quando a CPU gerará uma exceção (que o kernel irá converter em um sinal adequado como SIGILL ou SIGSEGV, geralmente implicando a morte do processo infrator).

(Observe que os segmentos são aplicados no espaço de endereço; o MMU ainda está ativo, em uma camada inferior; portanto, o truque explicado acima é por processo.)

Isso é barato: o hardware x86 faz impõe segmentos sistematicamente (e o primeiro 80386 já estava fazendo isso; na verdade, o 80286 já tinha esses segmentos com limites, mas apenas compensações de 16 bits ) Geralmente, podemos esquecê-los porque os sistemas operacionais sãos definem os segmentos para começar no deslocamento zero e ter 4 GB de comprimento, mas defini-los de outra forma não implica nenhuma sobrecarga que ainda não tínhamos. No entanto, como um mecanismo DEP, é inflexível: quando algum bloco de dados é solicitado ao kernel, o kernel deve decidir se isso é para código ou não, porque o limite é fixo. Não podemos decidir converter dinamicamente nenhuma página entre o modo de código e o modo de dados.

A maneira divertida, mas um pouco mais cara, de fazer DEP usa algo chamado PaX . Para entender o que faz, é preciso entrar em alguns detalhes.

O MMU no hardware x86 usa tabelas na memória, que descrevem o status de cada página de 4 kB no espaço de endereço. O espaço de endereço é de 4 GB, portanto, existem 1048576 páginas. Cada página é descrita por uma entrada de 32 bits em uma sub-tabela; existem 1024 sub-tabelas, cada uma contendo 1024 entradas e uma tabela principal, com 1024 entradas que apontam para as 1024 sub-tabelas. Cada entrada informa onde o objeto apontado (uma sub-tabela ou uma página) está na RAM ou se está lá, e quais são seus direitos de acesso. A raiz do problema é que os direitos de acesso referem-se a níveis de privilégios (código do kernel x terra do usuário) e apenas um bit para o tipo de acesso, permitindo assim "leitura-gravação" ou "somente leitura". "Execução" é considerado um tipo de acesso de leitura. Portanto, o MMU não tem noção de "execução" sendo distinta do acesso a dados. O que é legível, é executável.

(Desde o Pentium Pro, no século anterior, os processadores x86 conhecem outro formato para as tabelas, chamado PAE . Dobra o tamanho das entradas, o que deixa espaço para endereçar mais RAM física e também para adicionar um bit NX - mas esse bit específico foi implementado pelo hardware somente por volta de 2004.)

No entanto, há um truque. RAM está lento. Para executar um acesso à memória, o processador deve primeiro ler a tabela principal para localizar a sub-tabela que deve consultar, depois fazer outra leitura nessa sub-tabela, e somente nesse ponto o processador sabe se o acesso à memória deve ser permitido ou não, e onde em físico RAM os dados acessados ​​realmente são. Estes são acessos de leitura com dependência total (cada acesso depende do valor lido pelo anterior), portanto, paga latência total, que, na CPU moderna, pode representar centenas de ciclos de clock. Portanto, a CPU inclui um cache específico que contém as entradas da tabela MMU acessadas mais recentemente. Este cache é o Translation Lookaside Buffer .

A partir do 80486, a CPU x86 não possui um TLB, mas dois. O armazenamento em cache funciona em heurísticas, e as heurísticas dependem dos padrões de acesso, e os padrões de acesso para código tendem a diferir dos padrões de acesso para dados. Portanto, as pessoas inteligentes da Intel/AMD/other acharam que vale a pena ter um TLB dedicado ao acesso ao código (execução) e outro para acesso aos dados. Além disso, o 80486 possui um código de operação (invlpg) que pode remover uma entrada específica do TLB.

Portanto, a idéia é a seguinte: faça com que os dois TLB tenham visualizações diferentes da mesma entrada. Todas as páginas são marcadas nas tabelas (na RAM) como "ausentes", desencadeando uma exceção no acesso. O kernel captura a exceção, e a exceção inclui alguns dados sobre o tipo de acesso, em particular se foi para execução de código ou não. O kernel invalida a entrada TLB recém-lida (a que diz "ausente"), preenche a entrada em RAM com alguns direitos que permitem o acesso e força um acesso ao tipo necessário (leitura de dados ou execução de código), que alimenta a entrada no TLB correspondente e somente esse. O kernel então prontamente define a entrada em RAM de volta para ausente e, finalmente, retorna ao processo (voltando a tentar novamente o código de operação que acionou a exceção).

O efeito líquido é que, quando a execução retorna ao código do processo, o TLB para código ou o TLB para dados contém a entrada apropriada, mas o outro TLB não e não vai Uma vez que as tabelas em RAM ainda dizem "ausente". Nesse ponto, o kernel está em posição de decidir se deve permitir a execução ou não, independentemente de permitir ou não o acesso aos dados. Assim, pode impor semântica semelhante ao NX.

O diabo se esconde nos detalhes; neste caso, há espaço para toda uma legião de demônios. Essa dança com o hardware não é fácil de implementar corretamente. Especialmente em sistemas multinúcleo.

A sobrecarga é a seguinte: quando um acesso é realizado e o TLB não contém a entrada relevante, as tabelas em RAM devem ser acessadas, e isso por si só implica em perder algumas centenas de ciclos. A esse custo, o PaX adiciona a sobrecarga da exceção e o código de gerenciamento que preenche o TLB correto, transformando assim "algumas centenas de ciclos" em "alguns milhares de ciclos". Felizmente, as falhas no TLB estão certas. O pessoal do PaX afirma ter medido uma desaceleração de apenas 2,7% em um grande trabalho de compilação (embora isso dependa do tipo de CPU).

O bit NX torna tudo isso obsoleto. Observe que o patchset PaX também contém alguns outros recursos relacionados à segurança, como o ASLR, que é redundante em alguns funcionalidade dos kernels oficiais mais recentes.

40
Thomas Pornin