A gente sempre ouve falar que o sistema operacional impede que um processo veja a memória do outro ou que o programa fale diretamente com o hardware, mas normalmente não explicam o “como”.
Eu sempre achei isso meio mágico até que eu resolvi ir atrás da resposta, e é bem interessante.
Vou me basear na arquitetura x86, mas é provável que outras arquiteturas sejam parecidas.
O problema: a CPU
Pra CPU não existe processo, kernel, sistema operacional. Existe só endereços de memória de onde ela lê a próxima instrução e executa.
Se a CPU pode falar direto com a RAM, SSD, teclado, mouse, tela… O que me impede de escrever um programa pra ler suas senhas e tokens direto da RAM?
Ou de ler arquivos e alterar arquivos sensíveis direto no SSD?
Por outro lado, se o kernel fiscalizasse cada instrução que da CPU antes dela executar, isso seria extremamente lento…
Outro problema: os interrupts
Se a CPU só executasse sequencialmente, seu sistema poderia executar várias coisas e esquecer de checar se uma tecla foi apertada, se o mouse mexeu, etc…
Então certos eventos interrompem o que quer que a CPU esteja fazendo para serem tratados assim que possível.
Alguns exemplos de interrupt são:
Teclas do teclado pressionadas ou soltas
Botões e movimento do mouse
Timers
Operações de disco assíncronas
Pacotes de rede recebidos/transmitidos
Uma solução: rings
Os processadores da arquitetura x86 tem o esquema de rings. Pense em rings como grau de limitação.
Ring 0 significa limitação zero, ou seja, acesso a todas as instruções da CPU e consequentemente acesso total ao hardware e memória. O kernel roda em ring 0, ou kernel mode.
O kernel assim que é carregado configura todos os interrupts handlers da CPU para executar o handler apropriado do kernel, em kernel mode, claro.
Em ring 3 a CPU fica limitada e não pode fazer instruções consideradas privilegiadas. E obviamente em ring 3 a CPU não consegue se colocar em ring 0 sozinha, pois dessa forma qualquer programa conseguiria se pôr em ring 0.
O kernel executa os processos de usuário em ring 3, ou user mode.
Mas se os processos estão executando em ring 3, e processos são uma abstração do SO e não da CPU, como que faz pro kernel voltar a executar em ring 0?
Basicamente todo interrupt handler roda em ring 0, então sempre que o controle volta pro kernel, ele já está em kernel mode.
Exemplo de instrução privilegiada
Vamos escrever um código em assembly que tenta executar a instrução hlt, que para a execução da CPU até que ela receba um interrupt.
Se conseguíssemos rodar essa instrução em user mode, qualquer usuário poderia congelar a máquina, o que não seria legal.
O assembly fica assim:
;; aqui a gente fala pro assembler que o código a seguir fica na seção .text, que é onde a parte executável do binário Linux fica
section .text
;; aqui estamos "exportando a função" _start
global _start
;; O Linux vai dizer pra CPU começar a rodar as instruções do endereço que chamamos de _start
_start:
hlt
Enter fullscreen mode Exit fullscreen mode
E para testar vamos gerar o binário e executar com o debugger gdb:
$ nasm -felf64 halt.asm && ld halt.o -o halt && gdb ./halt
Enter fullscreen mode Exit fullscreen mode
Vamos adicionar um breakpoint no _start:
(gdb) break _start
Enter fullscreen mode Exit fullscreen mode
Rodar o programa e cair no breakpoint:
(gdb) run
Starting program: /home/igor/Git/asm/halt
Breakpoint 1, 0x0000000000401000 in _start ()
Enter fullscreen mode Exit fullscreen mode
Printar a instrução atual pra ter certeza que estamos no hlt:
(gdb) x/i $rip
=> 0x401000 <_start>: hlt
Enter fullscreen mode Exit fullscreen mode
Obs.: RIP é o instruction pointer, endereço da instrução atual
Agora vamos executar somente essa instrução com si e ver o resultado:
(gdb) si
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401000 in _start ()
Enter fullscreen mode Exit fullscreen mode
E recebemos o esperado segmentation fault.
A ponte: system calls
Se a CPU executando código em user mode não consegue acesso a nada do hardware, surgem os problemas:
Como que faz pra escrever e ler do disco?
Como consulta a data e hora atual, se isso vem do hardware?
Como toca som e produz imagens se isso também é hardware?
Quando um programa rodando precisa ler um arquivo, ele não pode (nem consegue) ler diretamente do SSD. Ele tem que pedir ao kernel com uma instrução especial chamada syscall (system call).
Essa instrução coloca a CPU em ring 0 de novo e executa o handler previamente configurado pelo kernel. Em essência é similar a um interrupt de software, porém em x86 moderno é uma instrução própria mesmo.
O kernel avalia o processo que fez a system call, se ele tem permissão para ler aquele arquivo, e se for o caso, ele lê e copia os dados lidos para uma região da memória do processo do usuário.
Ok, isso impede que o processo acesse o hardware diretamente, mas e a memória de outros processos?
Por padrão, a CPU tem acesso total a toda memória RAM, memória de vídeo, dispositivos, etc.
Se o kernel verificasse cada acesso de memória do processo pra garantir que ele se comportasse, também seria extremamente lento e vulnerável.
Pra resolver o problema o kernel usa outra feature dos processadores modernos: memória virtual.
A memória virtual
A memória virtual permite que o processador execute instruções usando endereços “fake” e por baixo dos panos ele traduza esses endereços para endereços reais.
Por exemplo, o processador pode executar uma instrução de ler o endereço 0x0050, mas por baixo dos panos ele está lendo o enderço real 0x1234.
Obs.: geralmente os sistemas não mapeiam o endereço 0x0000, então ele é o famoso null pointer
Obs.: da mesma forma que em ring 3 você não consegue sair de ring 3, você também não consegue alterar a memória virtual, porque isso também furaria a proposta de segurança
O kernel configura cada processo pra enxergar só as partes da memória que interessam àquele processo.
Quando um processo tenta ler um endereço não mapeado pela memória virtual, a CPU estoura um page fault, o kernel processa e dependendo do caso encerra o processo com o aterrorizante segmentation fault ou equivalente.
Há casos também onde um page fault não necessariamente é um problema, e sim o kernel sendo “lazy”.
Por exemplo você pode alocar memória, mas enquanto você não tentar ler nem escrever, o kernel pode nem ter alocado a memória física ainda.
Outro caso é a stack do programa, que começa pequena, e conforme ela cresce pode causar um page fault e o kernel entender que é só questão de mapear mais memória pra stack…
Há duas formas principais de fazer um processo ler uma memória que não está mapeada.
Lendo um endereço não mapeado
Nesse exemplo vamos ler 1 byte no endereço 0 e colocar no registrador al, que é um registrador de 1 byte de tamanho.
section .text
global _start
_start:
;; lê o endereço 0 e carrega no registrador al
mov al, [0x0]
Enter fullscreen mode Exit fullscreen mode
Quando executamos com gdb:
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401000 in _start ()
1: x/i $rip
=> 0x401000 <_start>: mov 0x0,%rax
Enter fullscreen mode Exit fullscreen mode
Não encerrando o processo com a syscall exit
Como eu devo ter falado acima, a CPU só executa instruções como se não houvesse amanhã. Ela não sabe que seu código terminou.
Quanto chamamos a syscall de exit, o sistema operacional fica em cargo de encerrar o processo, liberar os recursos usado por ele e botar a CPU para executar outra coisa.
Quando não chamamos a syscall de exit e não fazemos nenhum tipo de jump, a CPU vai ler o próximo endereço de memória e tentar executá-lo, o que pode causar todo tipo de comportamento.
Exemplo de código que demonstra isso:
section .text
global _start
_start:
;; printa Hello world com a syscall de write
mov rax, 1 ;; código da syscall write
mov rdi, 1 ;; file descriptor 1 = stdout
lea rsi, [rel hello] ;; endereço de memória da mensagem
mov rdx, 12 ;; quantidade de dados a ser escrita = tamanho da string
syscall
;; a CPU vai executar abaixo os bytes de hello world como se fossem instruções
hello:
db "Hello world", 10
Enter fullscreen mode Exit fullscreen mode
E rodando pelo gdb, instrução por instrução vemos:
Breakpoint 1, 0x0000000000401000 in _start ()
1: x/i $rip
=> 0x401000 <_start>: mov $0x1,%eax
=> 0x401005 <_start+5>: mov $0x1,%edi
=> 0x40100a <_start+10>: lea 0x7(%rip),%rsi # 0x401018 <hello>
=> 0x401011 <_start+17>: mov $0xc,%edx
=> 0x401016 <_start+22>: syscall
Hello world
Enter fullscreen mode Exit fullscreen mode
Obs.: limpei a saída do gdb para ficar mais fácil de ver
Até aí tudo dentro do esperado.
0x0000000000401018 in hello ()
1: x/i $rip
=> 0x401018 <hello>: rex.W
(gdb) si
Program received signal SIGSEGV, Segmentation fault.
Enter fullscreen mode Exit fullscreen mode
Aqui ele tenta executar a instrução correspondente ao byte que representa o ‘H’ de hello world e falha.
E os loops infinitos?
Se a CPU só executa instrução a instrução, como que o sistema operacional impede que um loop infinito seja… infinito?
Um programa que fizesse um loop infinito em cada core da CPU bloquearia todos os cores e o sistema operacional nem conseguiria interrompê-lo, exceto se um interrupt de hardware acontecesse.
E na prática o que salva o kernel mesmo é o um interrupt de hardware!
O kernel configura um hardware timer para causar interrupts na CPU de tempos em tempos.
Esse interrupt, assim como os outros, interrompe o código do usuário e passa o controle pro kernel.
Se o kernel vir que um processo está executando a muito tempo sem interrupções, ele vai pausar aquele processo e dar oportunidade para outros processos rodarem.
Conclusão
Bom a princípio foi isso que entendi sobre os dois mecanismos fundamentais de segurança dos sistemas operacionais.
Mais pra frente vou compartilhar novas coisas que eu for brincando com, como por exemplo meu projeto de interceptação de system calls no Linux.
답글 남기기