Um dos conceitos mais interessantes e comumente usados na arquitetura x86 é o modo Protegido e seu suporte em 4 modos(aka rings):

Foi uma idéia desafiadora de se entender e vou tentar explicar o mais claramente possível neste post. Vamos cobrir os seguintes conceitos:

  • GDT, LDT, IDT.
  • Translação de memória virtual.
  • ASLR e Kernel ASLR (KASLR).

Comecemos com o básico, qualquer computador tem pelo menos (esperançosamente) os seguintes componentes: CPU, Disco e RAM. Cada um destes componentes tem um papel fundamental no fluxo do sistema. A CPU executa os comandos e operações na memória (RAM), a RAM guarda os dados que estamos usando e permite o acesso rápido e confiável a eles, o disco guarda dados persistentes que precisamos que existam mesmo após o reinício ou desligamento. Começo por aqui porque, embora este seja o mais básico, é importante ter isso em mente e, ao ler este artigo, pergunte-se de que componente estamos a falar naquele momento.

O sistema operativo é o software que orquestra tudo isto, e também o que permite uma interface rápida, conveniente, consistente e eficiente para aceder a todas as suas capacidades – algumas das quais é o acesso a esse hardware, e outras é para melhorar a conveniência e performance.

Como qualquer bom software o SO funciona em camadas, o kernel é a primeira camada e – na minha opinião – a mais importante. Para entendermos a importância do kernel precisamos primeiro entender suas ações e os desafios que enfrenta, então vamos olhar algumas de suas responsabilidades:

  • Handle system calls (a mesma interface que falamos).
  • Alocar recursos (RAM, CPU e muito mais) aos processos/threads em mãos.
  • Segurar as operações realizadas.
  • Intermediário entre o hardware e o software.

Muitas destas acções realizadas com a generosa ajuda do processador, no caso do x86, o modo Protected é o modo que nos permite limitar a potência (conjunto de instruções) do contexto de execução em curso.

Vamos assumir que temos dois mundos – o mundo do utilizador e o mundo do supervisor. A qualquer momento, você só pode estar em um desses mundos. Quando você no mundo do usuário você vê o mundo como o supervisor quer que você o veja. Vamos ver o que quero dizer com isso:

Vamos dizer que você é um processo. Um processo é um recipiente com um ou mais fios. Um thread é um contexto de execução, é a unidade lógica da qual as instruções da máquina são executadas. Isto significa que quando a thread está executando, digamos, lendo desde o endereço de memória 0x80808080, ela se refere realmente ao endereço virtual 0x80808080 do processo atual. Como você pode adivinhar, o conteúdo do endereço vai ser diferente entre dois processos. Agora, o espaço de endereço virtual está no nível do processo, o que significa que todos os threads do mesmo processo têm o mesmo espaço de endereço e podem acessar a mesma memória virtual. Para dar um exemplo de recurso que está no nível de thread vamos usar a famosa stack.

Então eu tenho um thread que executa o seguinte código:

Nosso thread executa a função principal que chamará nossa função “func”. Digamos que quebramos o thread na linha 9. a disposição da pilha será a seguinte:

  1. variable_a.
  2. parameter.
  3. endereço de retorno – endereço da linha 20.
  4. variable_b.

Para ilustrar:

No código dado criamos 3 fios para o nosso processo e cada um deles imprime o seu id, segmento de pilha e ponteiro de pilha.

Uma saída possível desse programa é:

Como você pode ver todos os threads tinham o mesmo segmento de pilha porque eles têm o mesmo espaço de endereço virtual. o ponteiro da pilha para cada um é diferente porque cada um tem sua própria pilha para armazenar seus valores em.

Nota lateral sobre o segmento da pilha – explicarei mais sobre os registros de segmento na seção GDT/LDT – por enquanto acredite na minha palavra para isso.

Então por que isso é importante? A qualquer momento, o processador pode congelar a thread e dar o controle para qualquer outra thread que queira. Como parte do kernel, o scheduler é o que aloca a CPU às threads actualmente existentes (e “prontas”). Para que as threads possam rodar de forma confiável e eficiente é essencial que cada uma tenha sua própria pilha que possa salvar seus valores relevantes nela (variáveis locais e endereços de retorno, por exemplo).

Para gerenciar suas threads, o sistema operacional mantém uma estrutura especial para cada thread chamada TCB (Thread Control Block), nessa estrutura ele salva – entre outras coisas – o contexto dessa thread e seu estado (rodando / pronto / etc…). O contexto contém – novamente – entre outras coisas, os valores dos registros da CPU:

  • EBP -> Endereço base da pilha, cada função usa este endereço como endereço base a partir do qual ele faz offset para acessar variáveis e parâmetros locais.
  • ESP -> O ponteiro atual para o último valor (o primeiro a aparecer) na pilha.
  • Registros de propósito geral -> EAX, EBX, etc…
  • Registro de Flags.
  • C3 -> contêm a localização do diretório da página (será discutido mais tarde).
  • EIP – A próxima instrução a ser executada.

Besides threads que o sistema operacional precisa para manter o controle depois de um monte de outras coisas, incluindo processos. Para processos o SO salva a estrutura PCB (Process Control Block), nós dissemos que para cada processo há um espaço de endereçamento isolado. Por enquanto vamos assumir que há uma tabela que mapeia cada endereço virtual para um físico e essa tabela é salva na PCB, o SO responsável por atualizar essa tabela e mantê-la atualizada para o estado correto da memória física. Toda vez que o agendador mudar a execução para uma determinada thread a tabela que foi salva para o processo de propriedade dessa thread é aplicada na CPU para que ele possa traduzir corretamente os endereços virtuais.

Só isso é suficiente para os conceitos, vamos entender como isso é feito de fato. Para isso vamos olhar para o mundo da perspectiva do processador:

Tabela de descritores globais

Todos sabemos que o processador tem registros que o ajudam a fazer cálculos, alguns registros mais do que outros (;)). Ao projetar o x86 suporta múltiplos modos mas os mais importantes são usuário e supervisionado, a CPU tem um registro especial chamado gdtr (Global Descriptor Table Register) que mantém o endereço em uma tabela muito importante. essa tabela mapeia cada endereço virtual para o modo do processador correspondente, ela também contém as permissões para esse endereço (LEIA | WRITE | EXECUTE). obviamente esse registro só pode ser alterado a partir do modo supervisor. Como parte da execução do processador, ele verifica qual instrução executar a seguir (e em qual endereço ele está), ele verifica esse endereço em relação ao GDT e dessa forma ele sabe se é uma instrução válida baseada em seu modo desejado (combine o modo atual da CPU com o modo no GDT) e permissões (se não executável – inválido). Um exemplo é ‘lgdtr’ a instrução que carrega valor para o registrador gdtr e só pode ser executada a partir do modo supervisionado, conforme indicado. O ponto chave para enfatizar aqui é que qualquer proteção sobre as operações de memória (execução de instrução / gravação em local inválido / leitura a partir de local inválido) é feita pelo GDT e LDT (próximo) no nível do processador usando estas estruturas que foram construídas pelo SO.

Este é o aspecto do conteúdo de uma entrada no GDT / LDT:

http://wiki.osdev.org/Global_Descriptor_Table

>

>

>

>

>>

> Como você pode ver, tem o intervalo de endereços relevantes para a entrada, e seus atributos (permissões), como você esperaria.

Local Descriptor Table

Tudo o que dissemos sobre o GDT também é verdade para o LDT com pequena (mas grande) diferença. Como seu nome sugere que o GDT é aplicado globalmente no sistema enquanto o LDT é local, o que quero dizer com global e localmente? O GDT mantém o controle sobre as permissões de todos os processos, para cada thread e não muda entre as trocas de contexto, o LDT por outro lado é. Só faz sentido que se cada processo tiver seu próprio espaço de endereço, é possível que para um endereço de processo 0x10000000 seja executável e para outro seja apenas leitura/escrita. Isto é especialmente verdade com o ASLR ligado (será discutido mais tarde). O LDT é responsável por manter as permissões que distinguem cada processo.

Uma coisa a notar é que tudo o que foi dito é o propósito da estrutura, mas na realidade alguns SOs podem ou não usar alguma da estrutura. por exemplo, é possível usar apenas o GDT e mudá-lo entre mudanças de contexto e nunca usar o LDT. Tudo isso faz parte do projeto do sistema operacional e das trocas. As entradas dessa tabela são semelhantes às do GDT.

Selectors

Como o processador sabe onde procurar no GDT ou no LDT quando executa uma instrução específica? O processador tem registros especiais que chamam registros de segmento:

https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture

- Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').

Cada registro tem 16 bits de comprimento e sua estrutura é a seguinte:

http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm

>

>

>

Então temos o índice para o GDT/LDT, temos também o bit que diz se é o LDT ou o GDT, e qual o modo que precisa ser (RPL 0 é supervisor, 4 é usuário).

Interrupt Descriptor Table

Beside the GDT and LDT também temos o IDT (Interrupt Descriptor Table), o IDT é simplesmente uma tabela que contém os endereços de uma função muito importante, algumas delas pertencem ao SO, outras a drivers e dispositivos físicos ligados ao PC. Tal como o gdtr, temos o idtr que, como provavelmente adivinhou, o registo contém o endereço do IDT. O que torna o IDT tão especial? Quando iniciamos a interrupção, a CPU muda automaticamente para o modo supervisionado, o que significa que todas as funções dentro do IDT estão a correr em modo supervisionado. Cada thread de cada modo pode desencadear uma interrupção ao emitir a instrução ‘int’ seguida de um número que indica ao CPU em que índice reside a função alvo. Dito isto, é agora óbvio que cada função dentro do IDT é um gateway potencial para o modo supervisionado.

Então sabemos que temos o GDT/LDT que diz ao CPU as permissões para cada endereço virtual e temos o IDT que aponta as funções ‘gateway’ para o nosso querido kernel (que obviamente reside dentro da secção supervisionada da memória). Como estas estruturas se comportam num sistema em execução?

Memória virtual

Antes de podermos entender como tudo funciona em conjunto precisamos de cobrir mais um conceito – Memória virtual. Lembra-se quando eu disse que existe uma tabela que mapeia cada endereço de memória virtual para a sua memória física? Na verdade é um pouco mais complicada do que isso. Primeiro não podemos simplesmente mapear cada endereço virtual, pois ele tomará mais espaço do que realmente temos, e colocando a necessidade de ser eficiente de lado, o SO também pode trocar páginas de memória no disco (para eficiência e desempenho), é possível que a página de memória do endereço virtual necessário não esteja na memória no momento, então além de traduzir o endereço virtual para físico também precisamos salvar se a memória estiver na RAM e se não estiver, onde está (pode haver mais de um arquivo de uma página). A MMU (Memory Management Unit) é o componente responsável por traduzir a memória virtual para uma memória física.

Uma coisa realmente importante a entender é que cada instrução em cada modo está passando pelo processo de tradução de endereço virtual, mesmo código em modo supervisionado. Uma vez a CPU em modo protegido, cada instrução que executa usa endereço virtual – nunca física (há alguns truques que o endereço virtual real sempre irá traduzir exatamente para a mesma memória virtual, mas isso está fora do escopo deste post).

Então, uma vez em modo protegido, como a CPU sabe onde procurar quando precisa traduzir o endereço virtual? a resposta é o registro CR3, este registro mantém o endereço na estrutura que contém as informações necessárias – diretório de páginas. Seu valor muda com o processo atualmente em execução (novamente, espaço de endereço virtual diferente).

Então, como fica este Diretório de Páginas? Quando se trata de eficiência precisamos ser capazes de consultar esta “tabela” o mais rápido possível, também precisamos que seja o menor possível, pois esta tabela vai ser criada para cada processo. A solução para esse problema é nada menos que brilhante. A melhor imagem que encontrei para ilustrar o processo de tradução é esta (da wikipedia):

A MMU tem 2 entradas, o endereço virtual a traduzir e o CR3 (endereço para o directório de páginas actualmente relevante). A especificação x86 corta o endereço virtual em 3 partes:

  • 10 bit número – índice para o diretório de página.
  • 10 bit número – índice para a tabela de página.
  • 12 bit número – offset para o próprio endereço físico.

Então o processador pega o primeiro número de 10 bits e usa-o como índice para o diretório de páginas, para cada entrada no diretório de páginas temos a tabela de páginas, que então o processador usa o próximo número de 10 bits como índice. Cada ponto de entrada da tabela de diretório para a página de memória limite 4K, que então o último deslocamento de 12 bits do endereço virtual é usado para apontar a localização exata em físico. O brilho nessa solução é:

  • A flexibilidade que cada endereço virtual localiza para um completamente sem relação física.
  • A eficiência no espaço das estruturas envolvidas é surpreendente.
  • Nada toda entrada de cada tabela é usada, somente os endereços virtuais que realmente são usados e mapeados pelo processo existem nas tabelas.

Sinto muito por não explicar este processo em mais detalhes, este é um processo bem documentado que muitas pessoas trabalharam duro em explicar melhor do que eu poderia fazer – google it.

Kernel vs User

É aqui que se torna interessante (e mágico se eu puder).

Iniciámos este artigo afirmando que o SO está orquestrando tudo, ele faz isso usando o kernel. Como já foi dito o kernel está rodando em uma seção de memória que é mapeada como modo supervisionado apenas no GDT para todos os processos. Sim, eu sei que cada processo tem seu próprio espaço de endereços, mas o kernel está cortando esse espaço de endereços (geralmente a metade superior, depende do SO) para seu uso pessoal, não apenas para cortar o espaço de endereços, mas também no mesmo endereço para todos os processos. Isto é importante porque o código do kernel é fixo e cada referência a variáveis e estruturas precisa estar no mesmo local para todos os processos. Você pode olhar para o kernel como uma biblioteca especial carregada para cada processo na mesma localização.

Deeper into interrupts

Sabemos que o IDT contém endereços de funções, estas funções chamadas ISR (Interrupt Service Routine), algumas executam quando ocorre um evento de hardware (pressione a tecla no teclado) e outras quando o software inicia a interrupção para, por exemplo, mudar para o modo kernel.

Windows têm um conceito legal sobre interrupções e priorização das mesmas: Uma interrupção especialmente importante é o tiquetaque do relógio. Com cada tic tac do relógio há uma interrupção que é tratada pelo seu ISR. O programador do sistema operacional usa este evento do relógio para controlar quanto tempo cada processo está rodando e se é ou não a vez de outro. Como você pode entender que esta interrupção é super importante e precisa ser servida assim que acontece, nem todos os ISR’s têm a mesma importância e é aqui que as prioridades entre as interrupções entram em ação. Vamos pegar a tecla do teclado, por exemplo, e assumir que ela tem a prioridade 1, eu apenas pressionei uma tecla no teclado e é ISR está executando, enquanto executando o ISR do teclado todas as interrupções da mesma prioridade e inferiores são ignoradas. Enquanto executa o ISR, o ISR do relógio é acionado com prioridade 2 (razão pela qual ele não foi desativado), a mudança imediata ocorre para o ISR do relógio, uma vez que o relógio termina ele retorna o controle para o ISR do teclado de onde ele parou. estas interrupções de prioridades chamadas IRQLs (Interrupt ReQuest Level), como o IRQL de interrupção sobe sua prioridade é maior. As interrupções com a prioridade mais alta nunca são interrupções no meio, elas funcionam até o fim, sempre. Os IRQLs são específicos para Windows – o IRQL é um número entre 0-31, para Linux, por outro lado, ele não existe, o Linux trata cada interrupção com a mesma prioridade e simplesmente desabilita todas as interrupções quando realmente precisa daquela rotina específica para não ser perturbada. Como você pode ver é tudo uma questão de design e preferências.

Let’s conectar tudo ao nosso amado modo Usuário . O ISR desse evento do relógio vai executar independentemente da thread que está rodando atualmente e pode até mesmo interromper para outro ISR por tarefa não relacionada. este é um exemplo perfeito do porquê do kernel estar no mesmo endereço para todos os processos que não queremos alterar o GDT e o Page Directory (em C3) cada vez que executamos interrupção como acontece MUITAS vezes durante uma única função de qualquer processo em modo usuário. Muita coisa está acontecendo entre aquelas linhas de código que você escreve quando você desenvolve sua aplicação em modo usuário (;)).

Uma outra maneira de ver as interrupções é como entradas externas e independentes para o nosso SO, esta definição não é precisa (nem todas as interrupções são externas ou independentes), mas é bom fazer um ponto, grande parte do trabalho do kernel é fazer sentido dos eventos que ocorrem o tempo todo de cada local (dispositivos de entrada) e de um lado para servir a esses eventos e do outro para ter certeza de que tudo está correlacionado corretamente.

Então para dar sentido a tudo isso vamos começar com uma simples aplicação em modo usuário executando a seguinte instrução:

0x0000051d push ebp;

Para cada instrução que a CPU está executando primeiro examine o endereço dessa instrução (nesse caso ‘0x0000051d’) contra o GDT/LDT usando o registro de segmento de código (‘cs’ porque é uma instrução a executar) para saber o índice a ser procurado na tabela (lembre-se que o registro de segmento diz à CPU exatamente onde procurar). Uma vez que a CPU saiba que a instrução está em local executável e nós no anel direito (modo usuário/modo de kernel) ela agora continua a executar a instrução. Neste caso a instrução ‘push ebp’ não está afetando somente o registro, mas também a pilha do programa (ela empurra a pilha do conteúdo do ebp), então a CPU também verifica com o GDT/LDT o endereço dentro do registro esp (o endereço da localização atual na pilha, e porque é a localização da pilha que a CPU sabe usar o registro do segmento da pilha para fazer isso) para ter certeza de que ela pode ser escrita naquele anel específico. Note que se esta instrução também fosse lida a partir da memória, a CPU teria verificado o endereço relevante para acesso de leitura também.

Isso não é tudo, depois que a CPU verificou todos os aspectos de segurança, agora é necessário acessar e manipular a memória, já que você se lembra que os endereços estão em seu formato virtual. A MMU agora traduz cada memória virtual especificada pela instrução para um endereço de memória física usando o registro CR3 que aponta para o diretório da página (que aponta para a tabela de páginas) que nos permite eventualmente traduzir o endereço para um endereço físico. Note que o endereço pode não estar na memória no momento da necessidade, nesse caso o SO irá gerar uma falha de página (uma exceção que gera interrupção) e irá trazer os dados para a memória física para nós e então continuar a execução (isto é transparente para a aplicação em modo usuário).

De usuário para kernel

Cada troca entre modo usuário para modo kernel está acontecendo usando o IDT. Da aplicação em modo usuário, a instrução ‘int <num>’ é transferir a execução para a função no IDT no índice numérico. Quando a execução está em modo kernel muitas das regras estão mudando, cada thread tem pilhas diferentes para modo usuário e kernel, verificações de acesso à memória são muito mais complicadas e obrigatórias, em modo kernel há muito pouco que você não pode fazer e muito que você pode quebrar.

ASLR e KASLR

mais frequentemente é “apenas” a falta de conhecimento que nos impede de alcançar o impossível.

ASLR (Address Space Layout Randomization) é um conceito que foi implementado diferentemente dentro de cada sistema operacional, o conceito é randomizar os endereços virtuais dos processos e suas bibliotecas carregadas.

Antes de mergulharmos no assunto, eu queria notar que decidi incluir o ASLR neste post porque é uma boa maneira de ver como o modo protegido e suas estruturas permitem este tipo de capacidade, mesmo não sendo ele quem a implementa ou é responsável por ela.

Por que ASLR?

O porquê é fácil, para prevenir ataques. Quando alguém é capaz de injetar código em um processo em execução, não saber os endereços de algumas funções benéficas é o que pode causar a falha do ataque.

Já temos um espaço de endereços diferente para cada processo, isto significa que sem ASLR todos os processos teriam os mesmos endereços base, isto porque quando cada processo em seu próprio espaço de endereços virtual não temos que desconfiar de colisões entre processos. Quando ligamos o programa, o linker escolhe um endereço base fixo no qual ele liga o executável. No papel, todos os arquivos executáveis ligados pelo mesmo linker com os parâmetros padrão (o endereço base pode ser configurado se necessário) terão o mesmo endereço base. Para dar o exemplo escrevi duas aplicações, uma chamada “1.exe” e a segunda “2.exe”, ambos são projetos diferentes no Visual Studio e ainda assim ambos têm o mesmo endereço base (eu usei o exeinfo PE para viciar o endereço base no arquivo PE):

Não só estes dois executáveis têm o mesmo endereço base, ambos não suportam ASLR (Eu desabilitei):

>

>

Você também pode vê-lo incluído no formato PE em Características do Arquivo:

>

Agora vamos executar os dois executáveis ao mesmo tempo e os dois compartilham o mesmo endereço base (eu estarei usando o vmmap da Sysinternals para ver a imagem base):

>

>

>

Vemos que os dois processos não usam ASLR e têm o mesmo endereço base de 0x00400000. Se fôssemos atacantes e tivéssemos acesso a este executável, poderíamos saber exatamente quais endereços estarão disponíveis para este processo uma vez que nos encontramos fora para nos injetarmos em sua execução. vamos habilitar o ASLR em nosso executável 1.exe e ver a magia:

>

>

>

>

>

Mudou!

KASLR (Kernel ASLR) é o mesmo que o ASLR, apenas funciona ao nível do kernel, o que significa que uma vez que um atacante foi capaz de se injectar no contexto do kernel ele (esperançosamente) não será capaz de saber que endereços contêm que estruturas (por exemplo, onde o GDT se senta na memória). Uma coisa a mencionar aqui é que o ASLR trabalha sua mágica com cada desova de um novo processo (que suporta ASLR, claro) enquanto o KASLR o faz a cada reinício, pois é quando o kernel é “desovado”.

Como o ASLR?

Então como ele funciona e como ele está conectado ao modo protegido? O responsável por implementar o ASLR é o carregador. Quando um processo é iniciado, o carregador é aquele que precisa colocá-lo na memória, criar as estruturas relevantes e disparar a sua thread. O carregador primeiro verifica se o executável suporta ASLR e se sim, ele randomiza algum endereço base dentro do intervalo dos endereços disponíveis (o espaço do kernel, por exemplo, não está obviamente disponível). Baseado nesse endereço, o carregador agora inicializa o diretório de páginas para que esse processo aponte o espaço de endereços aleatórios para o físico. A flexibilidade do LDT também vem em nosso socorro, já que o carregador simplesmente cria o LDT o correspondente ao endereço aleatório com as permissões relevantes. A beleza aqui é que o modo protegido não está nem mesmo ciente que o ASLR está sendo usado, ele é flexível o suficiente para não se importar.

Um detalhe interessante da implementação é que no windows o endereço aleatório para um executável específico é corrigido por razões de eficiência. O que eu quero dizer com isso é que se nós aleatorizarmos o endereço para, digamos, calc.exe, a segunda vez que ele for executado o endereço base será o mesmo. Então se eu abrir 2 calculadoras ao mesmo tempo – elas terão o mesmo endereço base. Quando eu fechar ambas as calculadoras e abri-las novamente, ambas terão o mesmo endereço novamente, apenas este será diferente do endereço das calculadoras anteriores. Por que isso não é eficiente? Pense nas DLLs comumente usadas. Muitos processos as utilizam e se seus endereços base forem diferentes em cada instância do processo, seu código também será diferente (o código faz referência aos dados usando este endereço base) e se o código for diferente as DLLs precisarão ser carregadas na memória para cada processo. Na realidade, o SO carrega as imagens apenas uma vez por todas o processo que utiliza esta imagem. Ele economiza espaço – muito dele!

Conclusion

Por agora você deve ser capaz de imaginar o kernel no trabalho e entender como todas as estruturas chave da arquitetura x86 funcionam juntas para uma imagem maior e nos permitir executar aplicações possivelmente perigosas no modo usuário sem (ou pouco) medo.

Articles

Deixe uma resposta

O seu endereço de email não será publicado.