Linux Internals: Como /proc/self/mem grava em memória não gravável

PUBLICIDADE

Linux Internals: Como /proc/self/mem grava em memória não gravável

Introdução

Uma peculiaridade obscura do pseudoarquivo /proc/*/mem é sua semântica de “punch through”. As gravações realizadas por meio deste arquivo serão bem-sucedidas mesmo se a memória virtual de destino estiver marcada como não gravável. Na verdade, esse comportamento é intencional e usado ativamente por projetos como o compilador Julia JIT e o depurador rr.

Esse comportamento levanta algumas questões: O código privilegiado está sujeito a permissões de memória virtual? Em geral, até que ponto o hardware pode inibir o acesso à memória do kernel?

Ao explorar essas questões, este artigo lançará luz sobre a relação sutil entre um sistema operacional e o hardware em que ele é executado. Examinaremos as restrições que a CPU pode impor ao kernel e como o kernel pode contornar essas restrições.

Corrigindo libc com /proc/self/mem

Então, como é essa semântica punch-through?

Considere este código:

#include 
#include 
#include 

/* Write @len bytes at @ptr to @addr in this address space using
 * /proc/self/mem.
 */
void memwrite(void *addr, char *ptr, size_t len) {
  std::ofstream ff("/proc/self/mem");
  ff.seekp(reinterpret_cast(addr));
  ff.write(ptr, len);
  ff.flush();
}

int main(int argc, char **argv) {
  // Map an unwritable page. (read-only)
  auto mymap =
      (int *)mmap(NULL, 0x9000,
                  PROT_READ, // <<<<<<<<<<<<<<<<<<<<< READ ONLY <<<<<<<<
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

  if (mymap == MAP_FAILED) {
    std::cout << "FAILED\n";
    return 1;
  }

  std::cout << "Allocated PROT_READ only memory: " << mymap << "\n";
  getchar();

  // Try to write to the unwritable page.
  memwrite(mymap, "\x40\x41\x41\x41", 4);
  std::cout << "did mymap[0] = 0x41414140 via proc self mem..";
  getchar();
  std::cout << "mymap[0] = 0x" << std::hex << mymap[0] << "\n";
  getchar();

  // Try to write to the text segment (executable code) of libc.
  auto getchar_ptr = (char *)getchar;
  memwrite(getchar_ptr, "\xcc", 1);

  // Run the libc function whose code we modified. If the write worked,
  // we will get a SIGTRAP when the 0xcc executes.
  getchar();
}

Isso usa /proc/self/mem para gravar em duas páginas de memória não graváveis. A primeira é uma página somente leitura que o próprio código mapeia. A segunda é uma página de código pertencente à própria libc (a getchar função).

O último é o teste mais interessante – ele grava um byte 0xcc (a instrução de ponto de interrupção do software x86-64) que fará com que o kernel entregue um SIGTRAP ao nosso processo, se executado. Isso está literalmente mudando o código executável da libc. Então, da próxima vez que ligarmos getcharse obtivermos um SIGTRAP, sabemos que a gravação foi bem-sucedida.

Aqui está o que parece quando executo o programa:

Funcionou! As instruções de impressão intermediárias provam que o valor 0x41414140 foi gravado e lido com sucesso na memória. A última impressão mostra que um SIGTRAP foi entregue ao nosso processo quando ligamos getchar depois de corrigi-lo.

Aqui está uma demonstração em vídeo:

Agora que vimos como esse recurso funciona da perspectiva do espaço do usuário, vamos nos aprofundar um pouco mais. Para entender completamente como isso funciona, devemos observar como o hardware impõe permissões de memória.

Para o hardware

No x86-64, existem duas configurações de CPU que controlam a capacidade do kernel de acessar a memória. Eles são impostos pela unidade de gerenciamento de memória (MMU).

A primeira configuração é o bit de proteção contra gravação (CR0.WP). Do Volume 3, Seção 2.5 do manual da Intel:

Write Protect (bit 16 de CR0) — Quando definido, inibe procedimentos de nível de supervisor de gravar em páginas somente leitura; quando desmarcado, permite que procedimentos de nível supervisor escrevam em páginas somente leitura (independentemente da configuração do bit U/S; consulte a Seção 4.1.3 e a Seção 4.6).

Isso inibe a capacidade do kernel de gravar em páginas somente leitura, que é notavelmente permitido por padrão.

A segunda configuração é Prevenção de Acesso ao Modo Supervisor (SMAP) (CR4.SMAP). Sua descrição completa no Volume 3, Seção 4.6 é detalhada, mas o resumo executivo é que o SMAP desativa totalmente a capacidade do kernel de ler ou escrever na memória do espaço do usuário. Isso impede explorações de segurança que preenchem o espaço do usuário com dados maliciosos para serem lidos pelo kernel durante a exploração.

Se o código do kernel em questão usa apenas canais aprovados para acessar o espaço do usuário (copy_to_user, etc), o SMAP pode ser ignorado com segurança – essas funções alternam automaticamente o SMAP antes e depois de acessar a memória. Mas e quanto ao Write Protect?

Com CR0.WP limpo, a implementação do kernel de /proc/*/mem será de fato capaz de gravar sem cerimônia na memória do espaço do usuário não gravável.

No entanto, CR0.WP é habilitado na inicialização e geralmente permanece definido durante a vida útil do sistema. Neste caso, uma falha de página será acionada em resposta à gravação. Sendo mais uma ferramenta para facilitar a cópia na gravação do que um limite de segurança, isso não apresenta nenhuma restrição real ao kernel. Dito isto, exige a inconveniência do tratamento de falhas, que de outra forma não seria necessário.

Com isso em mente, vamos examinar a implementação.

Como funciona /proc/*/mem

/proc/*/mem é implementado em fs/proc/base.c.

Uma estrutura file_operações é preenchida com funções de manipulador, e a função mem_rw() em última análise, apóia o manipulador de gravação. mem_rw() usa access_remote_vm() para realizar as gravações reais. access_remote_vm() faz o seguinte:

  • Chama get_user_pages_remote() para consultar o quadro físico correspondente ao endereço virtual de destino.
  • Chama kmap() para mapear esse quadro no espaço de endereço virtual do kernel como gravável.
  • Chama copy_to_user_page() para finalmente realizar as gravações.

A implementação evita toda a questão da capacidade do kernel de gravar na memória do espaço do usuário não gravável! Ele exerce o controle do kernel sobre o subsistema de memória virtual para ignorar totalmente o MMU, permitindo que o kernel simplesmente escreva em seu próprio espaço de endereço gravável. Isso torna a discussão CR0.WP discutível.

Para elaborar cada uma dessas etapas:

get_user_pages_remote()
Para contornar a MMU, o kernel precisa executar manualmente no software o que a MMU realiza no hardware. A primeira etapa é traduzir o endereço virtual de destino em um endereço físico.

Isso é exatamente o que a família de funções get_user_pages() fornece. Essas funções pesquisam quadros de memória física que respaldam um determinado intervalo de endereços virtuais percorrendo as tabelas de páginas. Eles também lidam com validação de acesso e páginas não presentes.
O chamador fornece contexto e modifica o comportamento de get_user_pages() por meio de sinalizadores. De particular interesse é o sinalizador FOLL_FORCE, que mem_rw() passa. Este sinalizador faz com que check_vma_flags (a lógica de validação de acesso em get_user_pages()) ignore gravações em páginas não graváveis ​​e permita que a pesquisa continue. A semântica “punch through” é atribuída inteiramente a FOLL_FORCE. (comentários meus)


static int check_vma_flags(struct vm_area_struct *vma, unsigned long gup_flags)
{
        [...]
        if (write) { // If performing a write..
                if (!(vm_flags & VM_WRITE)) { // And the page is unwritable..
                        if (!(gup_flags & FOLL_FORCE)) // *Unless* FOLL_FORCE..
                                return -EFAULT; // Return an error
        [...]
        return 0; // Otherwise, proceed with lookup
}

get_user_pages() também respeita a semântica copy-on-write (CoW). Se uma gravação for detectada em uma entrada de tabela de páginas não gravável, uma “falha de página” será emulada chamando handle_mm_fault, o manipulador principal de falhas de página. Isso aciona a rotina apropriada de manipulação de CoW via do_wp_page, que copia a página se necessário. Isso garante que as gravações via /proc/*/mem sejam visíveis apenas dentro do processo se ocorrerem em um mapeamento compartilhado de forma privada, como libc.

kmap()
Depois que o quadro físico for consultado, a próxima etapa é mapeá-lo no espaço de endereço virtual do kernel com permissões graváveis. Isso é feito através do kmap().

No x86 de 64 bits, toda a memória física é mapeada por meio da região de mapeamento linear do espaço de endereço virtual do kernel. kmap() é trivial neste caso – basta adicionar o endereço inicial do mapeamento linear ao endereço físico do quadro para calcular o endereço virtual no qual o quadro está mapeado.

No x86 de 32 bits, o mapeamento linear contém um subconjunto de memória física, então kmap() pode precisar mapear o quadro alocando memória highmem e manipulando tabelas de páginas.

Em ambos os casos, o mapeamento linear e os mapeamentos highmem são alocados com proteção PAGE_KERNEL que é RW.

copy_to_user_page()
A última etapa é executar as gravações. Isto é conseguido através de copy_to_user_page(), que é essencialmente um memcpy. Como o destino é o mapeamento gravável de kmap(), isso simplesmente funciona.

Discussão

Para resumir, o kernel primeiro traduz o endereço virtual do espaço do usuário de destino para seu quadro físico de apoio por meio de uma caminhada na tabela de páginas do software. Em seguida, ele mapeia esse quadro em seu próprio espaço de endereço virtual como RW. Por fim, ele realiza as gravações usando um memcpy simples.

O que chama a atenção nesta implementação é que ela não envolve CR0.WP. A implementação evita isso elegantemente, explorando o fato de que não tem obrigação de acessar a memória através do ponteiro que recebe do espaço do usuário. Como o kernel tem controle total da memória virtual, ele pode simplesmente remapear o quadro físico em seu próprio espaço de endereço virtual, com permissões arbitrárias, e operar nele como desejar.

Isso chega a um ponto importante: as permissões que protegem uma página de memória estão associadas ao endereço virtual usado para acessar essa página, não ao quadro físico que sustenta a página. Na verdade, a noção de permissões de memória é puramente uma consideração da memória virtual e não se refere à memória física.

Conclusão

Tendo investigado minuciosamente os detalhes de implementação da semântica “punch through” de /proc/*/mem, podemos refletir sobre o relacionamento entre o kernel e a CPU.

À primeira vista, a capacidade do kernel de gravar em memória não gravável do espaço do usuário levanta a questão: até que ponto a CPU pode inibir o acesso à memória do kernel? O manual de fato documenta mecanismos de controle que parecem limitar o kernel.

No entanto, sob inspeção, estas revelam-se, na melhor das hipóteses, como restrições superficiais – meros obstáculos que podem ser contornados ou totalmente evitados.


Esta postagem foi citada:


Aprender algo novo? Avise!

Você aprendeu algo com este post? Eu adoraria saber o que foi – me mande um tweet @offlinemark!

Também tenho uma lista de discussão de baixo tráfego, se você quiser saber mais sobre novos textos. Inscreva-se aqui:


Obrigado a Jann Horn por me explicar muito disso. Obrigado também a Peter Goodman e Ben Marks pela revisão dos rascunhos anteriores deste post.



Fonte: theverge

Mais recentes

PUBLICIDADE

WP Twitter Auto Publish Powered By : XYZScripts.com