DevSecOps

2 jan, 2012

Kernell mode, user mode, privilégios e proteção

Publicidade

Você provavelmente sabe que os aplicativos têm poderes limitados em computadores Intel x86, e que somente os códigos do sistema operacional podem realizar certas tarefas. Mas você sabe como isso funciona? Este artigo examina os níveis de privilégio do x86, o mecanismo onde o OS e a CPU conspiram para restringir o que os programas em user-mode podem fazer. Há quatro níveis de privilégio, numerados de 0 (o de maior privilégio) a 3 (o de menor privilégio), e três recursos principais sendo protegidos: memória, portas I/O, e a possibilidade de executar certas instruções da máquina. Em um certo momento uma CPU x86 estará rodando em um nível de privilégio específico, o que determina o que a codificação pode ou não fazer. Esses níveis de privilégio são frequentemente descritos como anéis de proteção, com o mais interno deles correspondendo ao de maior privilégio. A maioria dos kernels x86 usam somente dois níveis de privilégio, 0 e 3:

x86 – anéis de proteção

Cerca de quinze instruções de máquina, em um universo de dúzias, são restringidas pela CPU ao nível zero. Muitas outras têm limitações em seus operandos. Essas instruções podem subverter o mecanismo de proteção, ou fomentar o caos se fossem admitidas no user-mode, de maneira que são restritas ao kernel. Uma tentativa de usá-las fora do anel zero causa uma exeção de proteção geral, como quando um programa usa endereços de memória inválidos. Da mesma forma, o acesso à memória ou a portas I/O é restringido com base no nível de privilégio. Porém, antes de olharmos os mecanismos de proteção, vamos ver exatamente como a CPU rastreia o nível de privilégio atual, o que envolve seletores de segmento.

Seletores de segmento – dados e código

Todos os conteúdos dos segmentos seletores de dados são carregados diretamente pelo código em vários registros de segmentos, tais como ss (registro de segmento stack). Isso inclui os conteúdos do campo Requested Privilege Level (RPL), cujo significado daremos uma olhada mais pra frente. O registro de segmento de código, contudo, é mágico. Primeiro, seus conteúdos não podem ser configurados diretamente por instruções de carregamento – tais como mov, apenas por instruções que alterem o fluxo de execução do programa, como call. Segundo, ao invés de um campo RPL, que pode ser configurado pelo código, o cs tem um campo Current Privilege Level (CPL) mantido pela própria CPU. Este campo CPL de 2-bit no registro de segmento de código é sempre igual ao nível atual de privilégio da CPU. A documentação da Intel titubeia um pouco quanto a isto, e algumas vezes a documentação online confunde o assunto, mas essa é a regra. A qualquer tempo, não importa o que esteja acontecendo na CPU, uma olhada no CPL do cs mostrará em qual nível de privilégio o código estará rodando.

Tenha em mente que o nível de privilégio da CPU não tem nada a ver com os usuários do sistema operacional. Não importa se você é raíz, administrador, visitante, ou usuário regular. Toda a codificação  do usuário roda no anel 3 e toda a codificação do kernel roda no anel 0, independentemente do usuário do OS. Algumas vezes certas tarefas podem ser encaminhadas para user-mode, como, por exemplo, os user-mode de drivers de dispositivos no Windows Vista – mas esses são processos especiais executando tarefas para o kernel, e podem ser desativados sem maiores conseqüências.

Devido a restrições de acesso à memória e às portas I/O, o user-mode não pode fazer quase nada no mundo exterior sem chamar o kernel. Não pode abrir arquivos, enviar pacotes pelas redes, imprimir na tela, ou alocar memória. Os processos do usuário rodam em um sandbox severamente limitado pelo anel zero.

É por isso que é impossível, pelo design, um processo liberar memória para além de sua existência, ou manter arquivos abertos depois de serem fechados. Todas as estruturas de dados que controlam essas coisas – memória, arquivos abertos, etc – não podem ser alcançadas diretamente pelo código do usuário; uma vez que um processo termine, o sandbox é destruído pelo kernel.

Essa é a razão pela qual nossos servidores podem ter 600 dias de uptime – uma vez que o kernel não pare de funcionar, as coisas podem rodar para sempre. Esta também é a razão porquê o Windows 95/ 98 tinha tantos crashs: não é porque a “Microsoft é uma droga”, mas porque estruturas de dados importantes eram acessíveis ao user-mode por razões de compatibilidade. Era uma boa opção para a época, ainda que a um alto custo.

A CPU protege a memória em dois pontos cruciais: quando um seletor de segmento está carregado e quando uma página de memória é acessada com um endereço linear. A proteção reflete, assim, a tradução de endereço de memória, em que tanto a segmentação e a paginação estão envolvidas. Quando um seletor de segmentos de dados está sendo carregado, acontece a verificação abaixo:

x86 – proteção do segmento

Como um número maior significa menos privilégio, o MAX() acima seleciona o menor privilégio do CPL e RPL, e os compara ao descriptor privilege (DPL). Se o DPL for superior, ou igual, então o acesso é permitido. A ideia por trás do RPL é permitir a codificação do kernel carregar um segmento usando um privilégio inferior. Por exemplo, você pode usar um RPL de 3 para garantir que uma dada operação use segmentos acessíveis ao user-mode. A exceção será para o registro do segmento stack ss, para o qual CPL, RPL, e DPL  precisam combinar exatamente.

Na verdade, a proteção do segmento tem pouca importância, porque os kernels modernos usam espaço de endereço horizontal, onde os segmentos do user-mode podem usar todo o espaço do endereço linear.

Cada página de memória é um bloco de bytes descritos em uma entrada de tabela em página, contendo dois campos relacionados com proteção: uma flag supervisor e uma flag read/write. A flag supervisor é o mecanismo de proteção primário do x86 usado pelos kernels. Quando ativado, a página não pode ser acessada do anel 3. Embora a flag read/write não seja importante na aplicação dos privilégios, ainda assim é útil.

Quando um processo é carregado, as páginas contendo imagens binárias (código) são marcadas como “somente leitura”, desta forma, flagrando alguns erros pontuais quando um programa tentar escrever nessas páginas.

Esta flag também é usada para implementar copy on write quando um processo é bifurcado em Unix. Na bifurcação, as páginas principais são assinaladas como “somente leitura” e compartilhadas com as que estão ligadas à elas. Caso qualquer processo tente escrever nas páginas, o processador dispara uma falha e o kernel duplica as páginas e as marca com leitura/ escrita para o processo de escrita.

Finalmente, precisamos de uma forma para a CPU mudar os níveis de privilégio. Se a codificação do anel 3 pudesse transferir o controle para locais arbitrários no kernel, seria fácil subverter o sistema operacional saltando para locais errados. Uma transferência controlada é necessária. Isso é obtido através dos gate descriptors e instruções do sysenter. Um gate descriptor é um segmento descritor do tipo de sistema, e vem em quatro sub-tipos: call-gate descriptor, interrupt-gate descriptor, trap-gate descriptor e task-gate descriptor.

As chamadas de gates fornecem uma entrada no kernel que pode ser usada como um chamado normal e instruções jmp, mas como não são muito usados, as ignorarei. Os task gates não são muito importantes (no Linux são usados somente em falhas duplas, causadas tanto por problemas no kernel, quanto no hardware).

Isso nos deixa com os dois melhores: interrupt e trap gates, que são usados para lidar com interrupções de hardware (por exemplo, teclado, timer, discos), e exceções (falhas de página, ou divisões por zero).  Farei referência aos dois como “interrupções”. Esses gate descriptors são gravados no Interrupt Descriptor Table (IDT). 

A cada interrupção é atribuído um número, chamado de vetor, entre 0 e 255, que o processador usa como um index para o IDT ao decidir qual gate descriptor usar ao lidar com a interrupção. Interrupt e trap gates são mais ou menos a mesma coisa. Seu formato é mostrado abaixo juntamente com as verificação de privilégio aplicados quando uma interrupção acontece. Preenchi alguns valores para o kernel do Linux para mostrar uma situação concreta.

Interrupt descriptor com verficação de privilégio

Tanto o DPL, quanto o seletor de segmento no gate, regulam o acesso, enquanto o seletor de segmento e o offset em conjunto fixam um ponto de entrada para o código handler de interrupção. Os kernels, normalmente, usam o seletor de segmento para o segmento de código do kernel nesses gate descriptors. Uma interrupção nunca pode transferir o controle de um anel mais privilegiado para um menos privilegiado.

O privilégio precisa tanto ser mantido (quando o próprio kernel sofre interrupção), ou ser elevado (quando o user-mode code é interrompido). Em qualquer caso, a CPL resultante pode ser igual ao DPL do destino do código do segmento; se o CPL mudar, uma stack switch também ocorre. Se uma interrupção é disparada pela codificação via uma instrução, como int n, uma checagem adicional acontece: o gate DPL deve estar no mesmo nível de privilégio ou menor do que o CPL.

Isso impede o código do usuário de disparar interrupções ao acaso. Se essas checagens falharem, acontece uma  exceção de interrupção geral. Todos handlers interrompidos do Linux terminam rodando no anel zero.

Durante a inicialização, o kernel do Linux primeiro configura um IDT no setup_idt(), que ignora todas interrupções. Ele usa funções no include/asm-x86/desc.h para detalhar entradas IDT comuns no arch/x86/kernel/traps_32.c. No Linux, um gate descriptor com “system” em seu nome é acessível do modo usuário, e sua função de configuração usa um DPL de 3. Um “system gate” é um trap gate Intel acessível pelo modo usuário. Caso contrário, a terminologia combina. Os interrupt gates de hardware não são configurados aqui entretanto, mas nos drivers apropriados.

Três gates são acessíveis ao modo usuário: vetores 3 e 4 são usados para debugação e checagem de overflows numéricos, respectivamente. Então um system gate é configurado para SYSCALL_VECTOR , que é 0X80 para a arquitetura x86. Esse era o mecanismo para um processo transferir controle para o kernel, para fazer um system call, e há um tempo atrás eu aploquei por uma  placa da licença vanity “int 0X80” .Começando com o Pentium Pro, a instrução  sysenter foi introduzida, como uma forma mais rápida de fazer chamados de sistemas. Isso se assenta em registros com objetivos especiais da CPU que armazenam os segmentos de códigos, pontos de entrada e outros tidbits para o sistema de chamadas do handler kernel. Quando o sysenter é executado, a CPU não executa checagem de privilégios, seguindo imediatamente para CPL 0 e carregando novos valores nos registros para o código e o stack (cs, eip, ss, e  esp). Somente o anel 0 pode carregar os registros do setup sysenter, o que é feito no enable_sep_cpu().

Finalmente, ao retornar ao anel 3, as questões do kernel emitem uma instrução iret ou sysexit para retornar das interrupções e chamadas do sistema respectivamente, deixando assim o nível 0 e reiniciando a execução do código do usuário com uma CPL de 3. O Vim me diz que estou me aproximando de 3.000 palavras, de maneira que a proteção das portas I/O fica para outro dia. Isto conclui nosso tour nos aneis e proteção do x86. Obrigado pela leitura! 

***

Texto original disponível em: http://duartes.org/gustavo/blog/post/cpu-rings-privilege-and-protection