Como um simples bug de corrupção de memória do kernel do Linux pode levar ao comprometimento total do sistema

Esta postagem do blog descreve um bug direto de bloqueio do kernel do Linux e como eu o explorei no kernel do Debian Buster 4.19.0-13-amd64.

Introdução

Com base nisso, ele explora opções para atenuações de segurança que podem impedir ou dificultar a exploração de problemas semelhantes a este.

Espero que passar por essa exploração e compartilhar esse conhecimento compilado com a comunidade de segurança mais ampla possa ajudar a raciocinar sobre a utilidade relativa de várias abordagens de mitigação.

Muitas das técnicas de exploração individual e opções de mitigação que estou descrevendo aqui não são novas. No entanto, acredito que há valor em escrevê-los juntos para mostrar como várias atenuações interagem com um exploit de uso após livre bastante normal.

Nossa entrada bugtracker para este bug, junto com a prova de conceito, está em https://bugs.chromium.org/p/project-zero/issues/detail?id=2125 .

Os trechos de código nesta postagem do blog que são relevantes para o exploit foram retirados do upstream versão 4.19.160, uma vez que é nisso que o kernel do Debian se baseia; alguns outros trechos de código são do Linux principal.

(Caso você esteja se perguntando por que o bug e o kernel do Debian direcionado são do final do ano passado: eu já escrevi a maior parte desta postagem por volta de abril, mas só recentemente a terminei)

Gostaria de agradecer a Ryan Hileman por uma discussão que tivemos há algum tempo sobre como a análise estática pode se encaixar na prevenção estática de bugs de segurança (mas observe que Ryan não revisou esta postagem e não concorda necessariamente com nenhuma de minhas opiniões) . Também quero agradecer a Kees Cook por fornecer feedback sobre uma versão anterior deste post (novamente, sem implicar que ele necessariamente concorda com tudo), e meus colegas do Project Zero por revisar este post e discussões frequentes sobre mitigações de exploits.

Histórico do bug

No Linux, os dispositivos de terminal (como um console serial ou um console virtual ) são representados por um struct tty_struct. Entre outras coisas, essa estrutura contém campos usados ​​para os recursos de controle de trabalho dos terminais, que geralmente são modificados usando um conjunto de ioctls :

struct tty_struct {
[...]
        spinlock_t ctrl_lock;
[...]
        struct pid *pgrp;               /* Protected by ctrl lock */
        struct pid *session;
[...]
        struct tty_struct *link;
[...]
}[...];

pgrpcampo aponta para o grupo de processos em primeiro plano do terminal (normalmente modificado no espaço do usuário por meio do TIOCSPGRPioctl); o sessioncampo aponta para a sessão associada ao terminal. Ambos os campos não apontam diretamente para um processo / tarefa, mas sim para a struct pidstruct pidvincula uma encarnação específica de um ID numérico a um conjunto de processos que usam esse ID como seu PID (também conhecido no espaço do usuário como TID), TGID (também conhecido no espaço do usuário como PID), PGID ou SID. Você pode pensar nisso como uma referência fraca a um processo, embora isso não seja totalmente preciso. (Há algumas nuances extras em torno de struct pidquando execve()é chamado por um segmento não líder, mas isso é irrelevante aqui.)

Todos os processos que estão sendo executados dentro de um terminal e estão sujeitos ao seu controle de trabalho referem-se a esse terminal como seu “terminal de controle” (armazenado no ->signal->ttyprocesso).

Um tipo especial de dispositivo de terminal são os pseudo- terminais , que são usados ​​quando você, por exemplo, abre um aplicativo de terminal em um ambiente gráfico ou se conecta a uma máquina remota via SSH. Enquanto outros dispositivos terminais estão conectados a algum tipo de hardware, ambas as extremidades de um pseudoterminal são controladas pelo espaço do usuário, e os pseudoterminais podem ser criados livremente pelo espaço do usuário (sem privilégios). Cada vez que /dev/ptmx(abreviação de “pseudoterminal multiplexor”) é aberto, o descritor de arquivo resultante representa o lado do dispositivo (referido na documentação e fontes do kernel como ” o pseudoterminal mestre“) de um novo pseudoterminal. Você pode ler dele para obter os dados que devem ser impressos na tela emulada e escrever nele para emular as entradas do teclado. O dispositivo de terminal correspondente (ao qual você normalmente conectaria um shell) é criado automaticamente pelo kernel em /dev/pts/<number>.

Uma coisa que torna os pseudoterminais particularmente estranhos é que ambas as extremidades do pseudoterminal têm suas próprias extremidades struct tty_struct, que apontam uma para a outra usando o linkmembro, mesmo que o lado do dispositivo do pseudo-terminal não tenha recursos terminais como controle de trabalho – muitos de seus membros são não utilizado.

Muitos dos ioctls para gerenciamento de terminal podem ser usados ​​em ambas as extremidades do pseudoterminal; mas não importa de que lado você os chame, eles afetam o mesmo estado, às vezes com pequenas diferenças de comportamento. Por exemplo, no manipulador ioctl para TIOCGPGRP:

/**
 *      tiocgpgrp               -       get process group
 *      @tty: tty passed by user
 *      @real_tty: tty side of the tty passed by the user if a pty else the tty
 *      @p: returned pid
 *
 *      Obtain the process group of the tty. If there is no process group
 *      return an error.
 *
 *      Locking: none. Reference to current->signal->tty is safe.
 */
static int tiocgpgrp(struct tty_struct *tty, struct tty_struct *real_tty, pid_t __user *p)
{
        struct pid *pid;
        int ret;
        /*
         * (tty == real_tty) is a cheap way of
         * testing if the tty is NOT a master pty.
         */
        if (tty == real_tty && current->signal->tty != real_tty)
                return -ENOTTY;
        pid = tty_get_pgrp(real_tty);
        ret =  put_user(pid_vnr(pid), p);
        put_pid(pid);
        return ret;
}

Conforme documentado no comentário acima, esses manipuladores recebem um ponteiro real_ttyque aponta para o dispositivo de terminal normal; um ponteiro adicional ttyé passado e pode ser usado para descobrir em qual extremidade do terminal o ioctl foi originalmente chamado. Como este exemplo ilustra, o ttyponteiro é normalmente usado apenas para coisas como comparações de ponteiro. Neste caso, é utilizado para evitar que TIOCGPGRPfuncione quando chamado do lado do terminal por um processo que não tem este terminal como terminal de controle.

Nota: Se você quiser saber mais sobre como os terminais e o controle de tarefas devem funcionar, o livro “The Linux Programming Interface” fornece uma boa introdução de como essas partes antigas da API do espaço do usuário deveriam funcionar. No entanto, ele não descreve nenhuma parte interna do kernel, já que foi escrito como uma referência para a programação do espaço do usuário. E é de 2010, então não contém nada sobre as novas APIs que surgiram na última década.

O inseto

O bug estava no manipulador ioctl tiocspgrp:

/**
 *      tiocspgrp               -       attempt to set process group
 *      @tty: tty passed by user
 *      @real_tty: tty side device matching tty passed by user
 *      @p: pid pointer
 *
 *      Set the process group of the tty to the session passed. Only
 *      permitted where the tty session is our session.
 *
 *      Locking: RCU, ctrl lock
 */
static int tiocspgrp(struct tty_struct *tty, struct tty_struct *real_tty, pid_t __user *p)
{
        struct pid *pgrp;
        pid_t pgrp_nr;
[...]
        if (get_user(pgrp_nr, p))
                return -EFAULT;
[...]
        pgrp = find_vpid(pgrp_nr);
[...]
        spin_lock_irq(&tty->ctrl_lock);
        put_pid(real_tty->pgrp);
        real_tty->pgrp = get_pid(pgrp);
        spin_unlock_irq(&tty->ctrl_lock);
[...]
}

pgrpmembro do lado do terminal ( real_tty) está sendo modificado e as contagens de referência do antigo e do novo grupo de processos são ajustadas de acordo com put_pidget_pid; mas o bloqueio é ativado tty, o que pode ser qualquer uma das extremidades do par pseudoterminal, dependendo do descritor de arquivo para o qual passarmos ioctl(). Portanto, ao chamar simultaneamente o TIOCSPGRPioctl em ambos os lados do pseudoterminal, podemos causar disputas de dados entre acessos simultâneos ao pgrpmembro. Isso pode fazer com que as contagens de referência sejam distorcidas nas seguintes corridas:

  ioctl(fd1, TIOCSPGRP, pid_A)        ioctl(fd2, TIOCSPGRP, pid_B)
    spin_lock_irq(...)                  spin_lock_irq(...)
    put_pid(old_pid)
                                        put_pid(old_pid)
    real_tty->pgrp = get_pid(A)
                                        real_tty->pgrp = get_pid(B)
    spin_unlock_irq(...)                spin_unlock_irq(...)
  ioctl(fd1, TIOCSPGRP, pid_A)        ioctl(fd2, TIOCSPGRP, pid_B)
    spin_lock_irq(...)                  spin_lock_irq(...)
    put_pid(old_pid)
                                        put_pid(old_pid)
                                        real_tty->pgrp = get_pid(B)
    real_tty->pgrp = get_pid(A)
    spin_unlock_irq(...)                spin_unlock_irq(...)

Em ambos os casos, o refcount do antigo struct pidé diminuído em 1 demais, e A’s ou B’s é incrementado em 1 demais.

Depois de entender o problema, a correção parece relativamente óbvia:

    if (session_of_pgrp(pgrp) != task_session(current))
        goto out_unlock;
    retval = 0;
-   spin_lock_irq(&tty->ctrl_lock);
+   spin_lock_irq(&real_tty->ctrl_lock);
    put_pid(real_tty->pgrp);
    real_tty->pgrp = get_pid(pgrp);
-   spin_unlock_irq(&tty->ctrl_lock);
+   spin_unlock_irq(&real_tty->ctrl_lock);
 out_unlock:
    rcu_read_unlock();
    return retval;

Estágios de ataque

Nesta seção, examinarei primeiro como meu exploit funciona; depois, discutirei as diferentes técnicas defensivas que visam essas fases do ataque.

Estágio de ataque: liberando o objeto com várias referências pendentes

Esse bug nos permite distorcer probabilisticamente o refcount de um struct piddown, dependendo de como a corrida acontece: Podemos executar TIOCSPGRPchamadas em conflito de dois threads repetidamente, e de vez em quando isso bagunçará o refcount. Mas não sabemos imediatamente quantas vezes a distorção do refcount realmente aconteceu.

O que realmente queremos como um invasor é uma maneira de distorcer o refcount de forma determinística. Teremos que compensar de alguma forma nossa falta de informação sobre se o refcount foi distorcido com sucesso. Poderíamos tentar de alguma forma tornar a corrida determinística (parece difícil), ou depois de cada tentativa de distorcer o refcount assumir que a corrida funcionou e executar o resto do exploit (já que se não distorcermos o refcount, a corrupção de memória inicial é desapareceu e nada de ruim acontecerá), ou podemos tentar encontrar um vazamento de informações que nos permita descobrir o estado da contagem de referência.

Em distribuições típicas de desktop / servidor, a seguinte abordagem funciona (de forma não confiável, dependendo do tamanho da RAM) para configurar uma versão liberada struct pidcom várias referências pendentes:

  1. Aloque um novo struct pid(criando uma nova tarefa).
  2. Crie um grande número de referências a ele (enviando mensagens com SCM_CREDENTIALSpara sockets de domínio unix e deixando essas mensagens enfileiradas).
  3. Acione repetidamente a TIOCSPGRPcorrida para inclinar a contagem de referência para baixo, com o número de tentativas escolhidas de tal forma que esperamos que a distorção de refcount resultante seja maior do que o número de referências que precisamos para o resto do nosso ataque, mas menor do que o número de referências extras Nós criamos.
  4. Deixe a tarefa que possui a pidsaída e morra, e espere o RCU (ler-copiar-atualizar, um mecanismo que envolve atrasar a liberação de alguns objetos) para resolver de forma que a referência da tarefa para o piddesaparece. (Esperar por um período de carência RCU do espaço do usuário não é um primitivo que é intencionalmente exposto por meio do UAPI, mas existem várias maneiras de o espaço do usuário fazer isso – por exemplo, testando quando a memória de um programa BPF liberado é subtraída da contabilidade da memória, ou abusando do membarrier(MEMBARRIER_CMD_GLOBAL, ...)syscall após a versão do kernel em que os sabores RCU foram unificados.)
  5. Crie um novo encadeamento e deixe esse encadeamento tentar eliminar todas as referências que criamos.

Como o refcount é menor no início da etapa 5 do que o número de referências que estamos prestes a descartar, o pidserá liberado em algum ponto durante a etapa 5; a próxima tentativa de descartar uma referência causará um uso após livre:

struct upid {
        int nr;
        struct pid_namespace *ns;
};

struct pid
{
        atomic_t count;
        unsigned int level;
        /* lists of tasks that use this pid */
        struct hlist_head tasks[PIDTYPE_MAX];
        struct rcu_head rcu;
        struct upid numbers[1];
};
[...]
void put_pid(struct pid *pid)
{
        struct pid_namespace *ns;

        if (!pid)
                return;

        ns = pid->numbers[pid->level].ns;
        if ((atomic_read(&pid->count) == 1) ||
             atomic_dec_and_test(&pid->count)) {
                kmem_cache_free(ns->pid_cachep, pid);
                put_pid_ns(ns);
        }
}

Quando o objeto é liberado, o alocador SLUB normalmente substitui os primeiros 8 bytes (nota: uma posição diferente é escolhida começando em 5.7, consulte o blog de Kees ) do objeto liberado com um ponteiro freelist ofuscado por XOR; portanto, os campos countlevelagora são efetivamente lixo aleatório. Isso significa que a carga de pid->numbers[pid->level]agora estará em algum deslocamento aleatório de pid, na faixa de zero a 64 GiB. Contanto que a máquina não tenha toneladas de RAM, isso provavelmente causará uma falha de segmentação do kernel. (Sim, eu sei, essa é uma maneira absolutamente grosseira e não confiável de explorar isso. Porém, na maioria das vezes funciona, e eu só percebi esse problema quando já tinha tudo escrito, então eu realmente não queria voltar e alterá-lo … além disso, eu mencionei que geralmente funciona?)

O Linux em sua configuração padrão, e a configuração fornecida pela maioria das distribuições de propósito geral, tenta consertar falhas inesperadas da página do kernel e outros tipos de “opses”, eliminando apenas o thread que está travando. Portanto, essa falha de página do kernel é realmente útil para nós como um sinal: uma vez que o encadeamento morre, sabemos que o objeto foi liberado e podemos continuar com o resto do exploit.

Se esse código fosse um pouco diferente e estivéssemos realmente chegando a um double-free, o alocador SLUB também detectaria isso e dispararia um kernel oops (consulte set_freepointer()para o CONFIG_SLAB_FREELIST_HARDENEDcaso).

Ideia de ataque descartada: explorar diretamente o UAF no nível SLUB

No kernel do Debian que eu estava olhando, um struct pidno namespace inicial é alocado do mesmo kmem_cacheque struct seq_filestruct epitem– essas três placas foram fundidas em uma find_mergeable()para reduzir a fragmentação da memória, uma vez que seus tamanhos de objeto, requisitos de alinhamento e sinalizadores correspondem:

root@deb10:/sys/kernel/slab# ls -l pid
lrwxrwxrwx 1 root root 0 Feb  6 00:09 pid -> :A-0000128
root@deb10:/sys/kernel/slab# ls -l | grep :A-0000128
drwxr-xr-x 2 root root 0 Feb  6 00:09 :A-0000128
lrwxrwxrwx 1 root root 0 Feb  6 00:09 eventpoll_epi -> :A-0000128
lrwxrwxrwx 1 root root 0 Feb  6 00:09 pid -> :A-0000128
lrwxrwxrwx 1 root root 0 Feb  6 00:09 seq_file -> :A-0000128
root@deb10:/sys/kernel/slab# 

Uma maneira direta de explorar uma referência pendente a um objeto SLUB é realocar o objeto por meio do mesmo de kmem_cacheonde veio, sem nunca permitir que a página alcance o alocador de páginas. Para descobrir se é fácil explorar esse bug dessa maneira, fiz uma tabela listando quais campos aparecem em cada deslocamento nessas três estruturas de dados (usando pahole -E --hex -C <typename> <path to vmlinux debug info>):

Deslocamentopideventpoll_epi / epitem (RCU-liberado)seq_file
0x00contador de contagem (4) (CONTROLE)rbn .__ rb_parent_color (8) (ALVO?)buf (8) (ALVO?)
0x04nível (4)
0x08tarefas [PIDTYPE_PID] (8)rbn.rb_right (8) / rcu.func (8)tamanho (8)
0x10tarefas [PIDTYPE_TGID] (8)rbn.rb_left (8)de (8)
0x18tarefas [PIDTYPE_PGID] (8)rdllink.next (8)contar (8)
0x20tarefas [PIDTYPE_SID] (8)rdllink.prev (8)pad_until (8)
0x28rcu.next (8)próximo (8)índice (8)
0x30rcu.func (8)ffd.file (8)read_pos (8)
0x38números [0] .nr (4)ffd.fd (4)versão (8)
0x3c[buraco] (4)nwait (4)
0x40números [0] .ns (8)pwqlist.next (8)bloqueio (0x20): contador (8)
0x48pwqlist.prev (8)
0x50ep (8)
0x58fllink.next (8)
0x60fllink.prev (8)em (8)
0x68ws (8)poll_event (4)
0x6c[buraco] (4)
0x70event.events (4)arquivo (8)
0x74event.data (8) (CONTROL)
0x78privado (8) (ALVO?)
0x7c
0x80

Nesse caso, realocar o objeto como um desses três tipos não me pareceu um bom caminho a seguir (embora deva ser possível explorar isso de alguma forma com algum esforço, por exemplo, usando count.counterpara corromper o bufcampo de seq_file). Além disso, alguns sistemas podem estar usando o slab_nomergesinalizador de linha de comando do kernel, que desativa esse comportamento de mesclagem.

Outra abordagem que não examinei aqui seria tentar corromper o ponteiro freelist SLUB ofuscado (a ofuscação é implementada em freelist_ptr()); mas, uma vez que isso armazena o ponteiro em big-endian, count.counterapenas nos permitiria corromper a metade mais significativa do ponteiro, o que provavelmente seria difícil de explorar.

Estágio de ataque: Liberando a página do objeto para o alocador de página

Esta seção fará referência a alguns aspectos internos do alocador SLUB; se você não estiver familiarizado com eles, pode querer pelo menos dar uma olhada nos slides 2-4 e 13-14 da palestra de visão geral do alocador de placas de Christoph Lameter de 2014 . (Observe que essa conversa cobre três alocadores diferentes; o alocador SLUB é o que a maioria dos sistemas usa atualmente.)

A alternativa para explorar o UAF no nível do alocador SLUB é liberar a página para o alocador de página (também chamado de alocador de amigo), que é o último nível de alocação de memória dinâmica no Linux (uma vez que o sistema está suficientemente longe na inicialização processo que o alocador memblock não é mais usado). A partir daí, a página pode, teoricamente, terminar em praticamente qualquer contexto. Podemos liberar a página para o alocador de páginas com as seguintes etapas:

  1. Instrua o kernel para fixar nossa tarefa em uma única CPU. SLUB e o alocador de página usam estruturas por cpu; portanto, se o kernel nos migrar para uma CPU diferente no meio, falharemos.
  2. Antes de alocar a vítima struct pidcujo refcount será corrompido, aloque um grande número de objetos para drenar páginas de laje parcialmente livres de todos os seus objetos não alocados. Se o objeto vítima (que será alocado na etapa 5 abaixo) parar em uma página que já está parcialmente usada neste ponto, não poderemos liberar essa página.
  3. Alocar em torno de objs_per_slab * (1+cpu_partial)objetos – em outras palavras, um conjunto de objetos que preenche completamente pelo menos cpu_partialpáginas, onde cpu_partialé o comprimento máximo da “lista parcial percpu”. Essas páginas recém-alocadas que estão completamente preenchidas com objetos não são referenciadas pelos freelists do SLUB neste momento porque o SLUB rastreia apenas as páginas com objetos livres em seus freelists.
  4. Preencha objs_per_slab-1mais objetos, de forma que, no final desta etapa, a “placa da CPU” (a página da qual as alocações serão servidas primeiro) não contenha nada além de espaço livre e novas alocações (criadas nesta etapa).
  5. Aloque o objeto vítima (a struct pid). A página da vítima (a página de onde o objeto vítima veio) geralmente será a placa da CPU da etapa 4, mas se a etapa 4 preencher completamente a placa da CPU, a página da vítima também pode ser uma placa da CPU nova, recém-alocada.
  6. Acione o bug no objeto vítima para criar uma referência não contada e libere o objeto.
  7. Aloque objs_per_slab+1mais objetos. Depois disso, a página da vítima será completamente preenchida com as alocações dos passos 4 e 7, e não será mais a placa da CPU (porque a última alocação não pode caber na página da vítima).
  8. Libere todas as alocações das etapas 4 e 7. Isso faz com que a página da vítima fique vazia, mas não libera a página; a página da vítima é colocada na lista parcial percpu assim que um único objeto dessa página for liberado e, em seguida, permanece nessa lista.
  9. Libere um objeto por página das alocações da etapa 3. Isso adiciona todas essas páginas à lista parcial percpu até atingir o limite cpu_partial, momento em que será liberado: As páginas contendo alguns objetos em uso são colocadas no perNUMA do SLUB -node lista parcial e as páginas que estão completamente vazias são liberadas de volta para o alocador de páginas. (Não liberamos todas as alocações da etapa 3 porque queremos apenas que a página vítima seja liberada para o alocador de página.) Observe que esta etapa requer que cada objs_per_slabobjeto que o alocador nos forneceu na etapa 3 esteja em uma página diferente.

Quando a página é fornecida ao alocador de página, nos beneficiamos da página ser ordem 0 (4 KiB, tamanho de página nativo): Para páginas ordem 0, o alocador de página tem freelists especiais, um por CPU + zona + combinação de tipo de migração. As páginas desses freelists não são normalmente acessadas de outras CPUs e não são combinadas imediatamente com as páginas livres adjacentes para formar páginas gratuitas de ordem superior.

Neste ponto, podemos realizar acessos de uso após livre a algum deslocamento dentro da página da vítima gratuita, usando codepaths que interpretam parte da página da vítima como um struct pid. Observe que, neste ponto, ainda não sabemos exatamente em qual deslocamento dentro da página da vítima o objeto vítima está localizado.

Estágio de ataque: realocando a página da vítima como um pagetable

No ponto em que a página vítima atingiu o freelist do alocador de páginas, é essencialmente o fim do jogo – neste ponto, a página pode ser reutilizada como qualquer coisa no sistema, dando-nos uma ampla gama de opções para exploração. Em minha opinião, a maioria das defesas que atuam depois de atingirmos esse ponto não são confiáveis.

Um tipo de alocação que é servido diretamente do alocador de página e tem boas propriedades para exploração são as tabelas de página (que também têm sido usadas para explorar o Rowhammer ). Uma maneira de abusar da capacidade de modificar uma tabela de página seria habilitar o bit de leitura / gravação em uma entrada de tabela de página (PTE) que mapeia uma página de arquivo para a qual devemos apenas ter acesso de leitura – por exemplo, isso poderia ser usado para obter acesso de gravação a parte de um .textsegmento binário setuid e sobrescrevê-lo com código malicioso.

Não sabemos em que deslocamento dentro da página da vítima o objeto vítima está localizado; mas uma vez que uma tabela de página é efetivamente uma matriz de elementos alinhados de 8 bytes de tamanho 8 e o alinhamento do objeto vítima é um múltiplo disso, contanto que pulverizemos todos os elementos da matriz de vítima, não precisamos saber o deslocamento do objeto vítima.

Para alocar uma tabela de páginas cheia de PTEs mapeando a mesma página de arquivo, temos que:

  • preparar configurando uma região de memória alinhada a 2 MiB (porque cada tabela de página de último nível descreve 2 MiB de memória virtual) contendo mmap()mapeamentos de página única da mesma página de arquivo (o que significa que cada mapeamento corresponde a um PTE); então
  • acionar a alocação da tabela de páginas e preenchê-la com PTEs lendo cada mapeamento

struct pidtem o mesmo alinhamento de um PTE e começa com um refcount de 32 bits, de modo que refcount tem a garantia de se sobrepor à primeira metade de um PTE, que é de 64 bits. Como as CPUs X86 são little-endian, incrementar o campo refcount nos struct pidincrementos liberados a metade menos significativa do PTE – portanto, incrementa efetivamente o PTE. (Exceto para o caso extremo em que está a metade menos significativa 0xffffffff, mas esse não é o caso aqui.)

struct pid: count | level |   tasks[0]  |   tasks[1]  |   tasks[2]  | ... 
pagetable:       PTE      |     PTE     |     PTE     |     PTE     | ...

Portanto, podemos incrementar um dos PTEs disparando repetidamente get_pid(), que tenta incrementar o refcount do objeto liberado. Isso pode ser transformado na capacidade de gravar na página do arquivo da seguinte maneira:

  • Aumente o PTE em 0x42 para definir o bit de leitura / gravação e o bit sujo. (Se não definíssemos o bit Dirty, a CPU faria isso sozinha quando escrevermos no endereço virtual correspondente, portanto, também poderíamos apenas incrementar em 0x2 aqui.)
  • Para cada mapeamento, tente sobrescrever seu conteúdo com dados maliciosos e ignore as falhas de página.
    • Isso pode gerar erros espúrios por causa de entradas TLB desatualizadas, mas obter uma falha de página removerá automaticamente essas entradas TLB, portanto, se apenas tentarmos gravar duas vezes, isso não pode acontecer na segunda gravação (migração de módulo de CPU, como mencionado acima) .
    • Uma maneira fácil de ignorar falhas de página é permitir que o kernel execute a gravação de memória usando pread(), que retornará -EFAULTem caso de falha.

Se o kernel perceber o bit Dirty mais tarde, isso pode acionar o write-back, que pode travar o kernel se o mapeamento não estiver configurado para gravação. Portanto, temos que redefinir o bit sujo. Não podemos decrementar de forma confiável o PTE porque put_pid()acessa de maneira ineficiente pid->numbers[pid->level]mesmo quando o refcount não está caindo para zero, mas podemos incrementá-lo por um adicional de 0x80-0x42 = 0x3e, o que significa o valor final do PTE, em comparação com o valor inicial , terá apenas o bit adicional 0x80 definido, que o kernel ignora.

Depois, lançamos o executável setuid (que, na versão no pagecache, agora contém o código que injetamos) e ganhamos privilégios de root:

user@deb10:~/tiocspgrp$ make
as -o rootshell.o rootshell.S
ld -o rootshell rootshell.o --nmagic
gcc -Wall -o poc poc.c
user@deb10:~/tiocspgrp$ ./poc
starting up...
executing in first level child process, setting up session and PTY pair...
setting up unix sockets for ucreds spam...
draining pcpu and node partial pages
preparing for flushing pcpu partial pages
launching child process
child is 1448
ucreds spam done, struct pid refcount should be lifted. starting to skew refcount...
refcount should now be skewed, child exiting
child exited cleanly
waiting for RCU call...
bpf load with rlim 0x0: -1 (Operation not permitted)
bpf load with rlim 0x1000: 452 (Success)
bpf load success with rlim 0x1000: got fd 452
....................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
RCU callbacks executed
gonna try to free the pid...
double-free child died with signal 9 after dropping 9990 references (99%)
hopefully reallocated as an L1 pagetable now
PTE forcibly marked WRITE | DIRTY (hopefully)
clobber via corrupted PTE succeeded in page 0, 128-byte-allocation index 3, returned 856
clobber via corrupted PTE succeeded in page 0, 128-byte-allocation index 3, returned 856
bash: cannot set terminal process group (1447): Inappropriate ioctl for device
bash: no job control in this shell
root@deb10:/home/user/tiocspgrp# id
uid=0(root) gid=1000(user) groups=1000(user),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),108(netdev),112(lpadmin),113(scanner),120(wireshark)
root@deb10:/home/user/tiocspgrp# 

Observe que nada em toda essa exploração exige que vazemos qualquer endereço virtual ou físico do kernel, em parte porque temos uma primitiva de incremento em vez de uma gravação simples; e também não envolve influenciar diretamente o ponteiro de instrução.

Defesa

Esta seção descreve as diferentes maneiras pelas quais esse exploit poderia ter sido impedido de funcionar. Para ajudar o leitor, os títulos de algumas das subseções referem-se a estágios de exploração específicos da seção acima.

Contra bugs acessíveis: redução da superfície de ataque

Uma possível primeira linha de defesa contra muitos problemas de segurança do kernel é apenas disponibilizar subsistemas do kernel para o código que precisa de acesso a eles. Se um invasor não tiver acesso direto a um subsistema vulnerável e não tiver influência suficiente sobre um componente do sistema com acesso para fazê-lo disparar o problema, o problema é efetivamente inexplorável no contexto de segurança do invasor.

Os pseudoterminais são (mais ou menos) necessários apenas para atender interativamente os usuários que têm acesso ao shell (ou algo semelhante a isso), incluindo:

  • emuladores de terminal dentro de sessões gráficas de usuário
  • Servidores SSH
  • screen sessões iniciadas em vários tipos de terminais

Coisas como servidores da web ou aplicativos de telefone normalmente não precisam de acesso a tais dispositivos; mas existem exceções. Por exemplo:

  • um servidor web é usado para fornecer um shell raiz remoto para administração do sistema
  • o objetivo de um aplicativo de telefone é disponibilizar um shell para o usuário
  • um script de shell usa expectpara interagir com um binário que requer um terminal para entrada / saída

Na minha opinião, os maiores limites na redução da superfície de ataque como estratégia defensiva são:

  1. Ele expõe uma solução alternativa para uma preocupação de implementaçãodo kernel (possíveis problemas de segurança de memória) na API voltada para o usuário, o que pode levar a problemas de compatibilidade e sobrecarga de manutenção – por exemplo, do ponto de vista de segurança, acho que pode ser uma boa ideia exigir que aplicativos de telefone e serviços systemd declarem sua intenção de usar o subsistema PTY no momento da instalação, mas isso seria uma mudança de API exigindo algum tipo de ação dos autores do aplicativo, criando atrito que não seria necessário se estivéssemos confiantes de que o kernel está funcionando corretamente. Isso pode ficar especialmente confuso no caso de software que invoca binários externos dependendo da configuração, por exemplo, um servidor web que precisa de acesso PTY quando é usado para administração de servidor. (Isso é um pouco menos complicado quando um aplicativo benigno, mas potencialmente explorável, aplica restrições a si mesmo ativamente;pode haver problemas de compatibilidade causados ​​por bibliotecas fora do controle do autor do aplicativo .)
  2. Ele não pode proteger um subsistema de um contexto que fundamentalmente precisa de acesso a ele. (Por exemplo, o Android pode /dev/binderser acessado diretamente pelos renderizadores do Chrome no Android porque eles têm código Android em execução dentro deles.)
  3. Isso significa que as decisões que não devem influenciar a segurança de um sistema (disponibilizando uma API que não concede privilégios extras a algum contexto potencialmente não confiável) envolvem essencialmente uma troca de segurança.

Ainda assim, na prática, acredito que os mecanismos de redução da superfície de ataque (especialmente o seccomp) são atualmente alguns dos mecanismos de defesa mais importantes no Linux.

Contra bugs no código-fonte: validação de bloqueio em tempo de compilação

O bug no TIOCSPGRPfoi uma violação bastante direta de uma regra de bloqueio direta: enquanto um tty_structestiver ativo, acessar seu pgrpmembro é proibido, a menos que ctrl_locko mesmo tty_structseja mantido. Esta regra é suficientemente simples para que não seja totalmente irracional esperar que o compilador seja capaz de verificá-la – contanto que você de alguma forma informe o compilador sobre esta regra, porque descobrir as regras de bloqueio pretendidas apenas olhando para um pedaço de o código muitas vezes pode ser difícil até para humanos (especialmente quando parte do código está incorreto).

Quando você está iniciando um novo projeto do zero, a melhor maneira geral de abordar isso é usar uma linguagem segura para a memória – em outras palavras, uma linguagem que foi explicitamente projetada de forma que o programador deve fornecer ao compilador informações suficientes sobre semântica de segurança de memória pretendida que o compilador pode verificá-los automaticamente. Mas, para as bases de código existentes, pode valer a pena examinar o quanto disso pode ser adaptado.

O recurso Thread Safety Analysis do Clang faz algo vagamente parecido com o que precisaríamos para verificar o bloqueio nesta situação:

$ nl -ba -s' ' thread-safety-test.cpp | sed 's|^   ||'
  1 struct __attribute__((capability("mutex"))) mutex {
  2 };
  3 
  4 void lock_mutex(struct mutex *p) __attribute__((acquire_capability(*p)));
  5 void unlock_mutex(struct mutex *p) __attribute__((release_capability(*p)));
  6 
  7 struct foo {
  8     int a __attribute__((guarded_by(mutex)));
  9     struct mutex mutex;
 10 };
 11 
 12 int good(struct foo *p1, struct foo *p2) {
 13     lock_mutex(&p1->mutex);
 14     int result = p1->a;
 15     unlock_mutex(&p1->mutex);
 16     return result;
 17 }
 18 
 19 int bogus(struct foo *p1, struct foo *p2) {
 20     lock_mutex(&p1->mutex);
 21     int result = p2->a;
 22     unlock_mutex(&p1->mutex);
 23     return result;
 24 }
$ clang++ -c -o thread-safety-test.o thread-safety-test.cpp -Wall -Wthread-safety
thread-safety-test.cpp:21:22: warning: reading variable 'a' requires holding mutex 'p2->mutex' [-Wthread-safety-precise]
    int result = p2->a;
                     ^
thread-safety-test.cpp:21:22: note: found near match 'p1->mutex'
1 warning generated.
$ 

No entanto, isso não funciona atualmente ao compilar como código C porque o guarded_byatributo não pode localizar o outro membro da estrutura; parece ter sido projetado principalmente para uso em código C ++. Um problema mais fundamental é que ele também não parece ter suporte embutido para distinguir as diferentes regras para acessar um membro de estrutura dependendo do estado de vida do objeto. Por exemplo, quase todos os objetos com membros bloqueados terão funções de inicialização / destruição que têm acesso exclusivo a todo o objeto e podem acessar membros sem bloqueio. (O bloqueio pode nem mesmo ser inicializado nesses estados.)

Alguns objetos também têm mais estados de vida; em particular, para muitos objetos com tempo de vida gerenciado por RCU, apenas um subconjunto dos membros pode ser acessado por meio de uma referência de RCU sem ter atualizado a referência para uma recontada de antemão. Talvez isso pudesse ser resolvido introduzindo um novo atributo de tipo que pode ser usado para marcar ponteiros para estruturas em estados de vida útil especiais? (Para código C ++, a análise de segurança de thread do Clang simplesmente desativa todas as verificações em todas as funções do construtor / destruidor.)

Tenho esperança de que, com algumas extensões, algo vagamente parecido com o Thread Safety Analysis do Clang possa ser usado para melhorar algum nível de segurança em tempo de compilação contra disputas de dados não intencionais. Isso exigirá a adição de muitas anotações, em particular nos cabeçalhos, para documentar a semântica de bloqueio pretendida; mas essas anotações são provavelmente necessárias para permitir um trabalho produtivo em uma base de código complexa. Na minha experiência, quando não há comentários / anotações detalhadas sobre regras de bloqueio, cada tentativa de alterar um trecho de código com o qual você não está intimamente familiarizado (sem introduzir horríveis bugs de segurança de memória) se transforma em uma incursão no matagal da chamada circundante gráficos, tentando desvendar as intenções por trás do código.

A única grande desvantagem é que isso exige que a comunidade de desenvolvimento da base de código aceite a ideia de preencher e manter essas anotações. E alguém tem que escrever o conjunto de ferramentas de análise que pode verificar as anotações.

No momento, o kernel do Linux tem uma validação de bloqueio muito grosseira via sparse; mas essa infraestrutura não é capaz de detectar situações em que o bloqueio errado é usado ou de validar se um membro de estrutura está protegido por um bloqueio. Ele também não pode lidar adequadamente com coisas como bloqueio condicional, o que o torna difícil de usar para qualquer coisa que não seja spinlocks / RCU. A validação de bloqueio de tempo de execução do kernel viaLOCKDEPé mais avançado, mas principalmente com foco em correção de bloqueio de ponteiros RCU, bem como detecção de deadlock (o foco principal); novamente, não há nenhum mecanismo para, por exemplo, validar automaticamente que um determinado membro de estrutura é acessado apenas sob um bloqueio específico (o que provavelmente também seria muito caro para implementar com validação de tempo de execução). Além disso, como um mecanismo de validação de tempo de execução, ele não pode descobrir erros no código que não é executado durante o teste (embora possa combinar o comportamento observado separadamente em cenários de corrida sem nunca realmente observar a corrida).

Contra bugs no código-fonte: análise de bloqueio estático global

Uma abordagem alternativa para verificar as regras de segurança da memória em tempo de compilação é fazer isso depois que toda a base de código foi compilada ou com uma ferramenta externa que analisa toda a base de código. Isso permite que o conjunto de ferramentas de análise execute análises em unidades de compilação, reduzindo a quantidade de informações que precisam ser explicitadas nos cabeçalhos. Essa pode ser uma abordagem mais viável se adicionar anotações em todos os cabeçalhos não for viável; mas também reduz a utilidade para leitores humanos do código, a menos que a semântica inferida seja tornada visível para eles por meio de algum visualizador de código especial. Também pode ser menos ergonômico a longo prazo se as alterações em uma parte do kernel puderem fazer a verificação de outras partes falhar – especialmente se essas falhas aparecerem apenas em algumas configurações.

Acho que a análise estática global é provavelmente uma boa ferramenta para encontrar alguns subconjuntos de bugs e também pode ajudar a encontrar a profundidade do pior caso de pilhas de kernel ou provar a ausência de deadlocks, mas provavelmente é menos adequada para provar a correção da segurança da memória.

Contra as primitivas de exploração: redução da primitiva de ataque por meio de restrições syscall

(Sim, inventei esse nome porque pensei que capturar isso em “Redução da superfície de ataque” é muito confuso.)

Como os atalhos do alocador (tanto no SLUB quanto no alocador de página) são implementados usando estruturas de dados por CPU, a facilidade e a confiabilidade das explorações que desejam persuadir os alocadores de memória do kernel a realocar a memória de maneiras específicas podem ser melhoradas se o invasor estiver bem – controle restrito sobre a atribuição de threads de exploração aos núcleos da CPU. Estou chamando esse recurso, que fornece uma maneira de facilitar a exploração ao influenciar o estado / comportamento do sistema relevante, um “ataque primitivo” aqui. Felizmente para nós, o Linux permite que as tarefas se fixem em núcleos de CPU específicos sem exigir nenhum privilégio usando o sched_setaffinity()syscall.

(Como um exemplo diferente, um primitivo que pode fornecer a um invasor recursos bastante poderosos é ser capaz de paralisar indefinidamente as falhas do kernel nos endereços do espaço do usuário por meio de FUSE ou userfaultfd.)

Assim como na seção “Redução da superfície de ataque” acima, a capacidade de um invasor de usar essas primitivas pode ser reduzida filtrando as syscalls; mas embora o mecanismo e as questões de compatibilidade sejam semelhantes, o resto é bastante diferente:

A redução da primitiva de ataque normalmente não impede de forma confiável que um bug seja explorado; e um invasor às vezes será capaz de obter indiretamente um primitivo semelhante, mas de menor qualidade (mais complicado, menos confiável, menos genérico, …), por exemplo:

A redução da superfície de ataque trata de limitar o acesso ao código suspeito de conter bugs exploráveis; em uma base de código escrita em uma linguagem não segura para memória, que tende a se aplicar a praticamente toda a base de código. A redução da superfície de ataque costuma ser bastante oportunista: você permite as coisas de que precisa e nega o resto por padrão.

A redução da primitiva de ataque limita o acesso ao código suspeito ou conhecido por fornecer primitivas de exploração (às vezes muito específicas). Por exemplo, alguém pode decidir proibir especificamente o acesso ao FUSE e userfaultfd para a maioria dos códigos por causa de sua utilidade para a exploração do kernel e, se uma dessas interfaces for realmente necessária, projetar uma solução alternativa que evite expor o ataque primitivo ao espaço do usuário. Isso é diferente da redução da superfície de ataque, onde geralmente faz sentido permitir o acesso a qualquer recurso que uma carga de trabalho legítima deseja usar.

Um bom exemplo de uma redução primitiva de ataque é o sysctl vm.unprivileged_userfaultfdque foi introduzido pela primeira vez para que userfaultfd pudesse se tornar completamente inacessível para usuários normais e foi posteriormente ajustado para que os usuários possam ter acesso a parte de sua funcionalidade sem obter o primitivo de ataque perigoso . (Mas se você puder criar namespaces de usuário sem privilégios, ainda poderá usar o FUSE para obter um efeito equivalente.)

Ao manter listas de syscalls permitidas para um componente do sistema em sandbox, ou algo nesse sentido, pode ser uma boa ideia rastrear explicitamente quais syscalls são explicitamente proibidas por razões de redução primitiva de ataque ou por razões igualmente fortes – caso contrário, pode-se acidentalmente acabar permitindo eles no futuro. (Eu acho que isso é semelhante a problemas que alguém pode encontrar ao manter ACLs …)

Mas, como na seção anterior, a redução da primitiva de ataque também tende a tornar algumas funcionalidades indisponíveis e, portanto, pode não ser viável em todas as situações. Por exemplo, as versões mais recentes do Android, de forma deliberada e indireta, dão aos aplicativos acesso ao FUSE por meio do mecanismo AppFuse . (Essa API não fornece acesso direto a um aplicativo /dev/fuse, mas encaminha solicitações de leitura / gravação para o aplicativo.)

Contra oráculos baseados em oops: bloqueio ou pânico na falha

A capacidade de se recuperar de oopses do kernel em uma exploração pode ajudar um invasor a compensar a falta de informações sobre o estado do sistema. Em algumas circunstâncias, ele pode até servir como um oráculo binário que pode ser usado para executar mais ou menos uma busca binária por um valor, ou algo parecido.

Costumava ser ainda pior em algumas distribuições , onde dmesgera acessível para usuários sem privilégios; então, se você conseguisse acionar um oops ou WARN, então, poderia pegar os estados de registro em todos os quadros IRET na pilha do kernel, que poderiam ser usados ​​para vazar coisas como ponteiros do kernel . Felizmente, hoje em dia a maioria das distribuições, incluindo Ubuntu 20.10 , restringe o dmesgacesso.)

Hoje em dia, o Android e o Chrome OS definem o panic_on_oopssinalizador do kernel , o que significa que a máquina irá reiniciar imediatamente quando um kernel oops acontecer. Isso torna difícil usar oopsing como parte de um exploit e, sem dúvida, também faz mais sentido do ponto de vista da confiabilidade – o sistema ficará inativo por um tempo e perderá seu estado existente, mas também será redefinido para um estado conhecido bom estado em vez de continuar em um estado potencialmente quebrado pela metade, especialmente se o encadeamento com falha estava segurando mutexes que nunca mais poderão ser liberados, ou coisas assim. Por outro lado, se algum serviço travar em um sistema de desktop, talvez isso não deva fazer com que todo o sistema pare imediatamente e faça você perder o estado não salvo – então panic_on_oopspode ser muito drástico nisso.

Uma boa solução para isso pode exigir uma abordagem mais refinada. (Por exemplo, grsecurity tem por muito tempo a capacidade de bloquear UIDs específicos que causaram travamentos.) Talvez faça sentido permitir que o initdaemon use políticas diferentes para travamentos em serviços / sessões / UIDs diferentes?

Contra o acesso de UAF: mitigação de UAF determinística

Uma defesa que interromperia de forma confiável uma exploração para esse problema seria uma mitigação de uso após livre determinística. Tal mitigação protegeria de forma confiável a memória anteriormente ocupada pelo objeto de acessos por meio de ponteiros pendentes para o objeto, pelo menos depois que a memória foi reutilizada para uma finalidade diferente (incluindo reutilização para armazenar metadados de heap). Para operações de gravação, isso provavelmente requer atomicidade da verificação de acesso e da gravação real ou um mecanismo de liberação retardada semelhante a RCU. Para operações de leitura simples, ele também pode ser implementado ordenando a verificação de acesso após a leitura, mas antes que o valor lido seja usado.

Uma grande desvantagem desta abordagem por si só é que verificações extras em cada acesso à memória provavelmente virão com uma penalidade de eficiência extremamente alta, especialmente se a atenuação não puder fazer suposições sobre quais tipos de acessos paralelos podem estar acontecendo a um objeto, ou quais ponteiros de semântica têm. (A implementação de prova de conceito que apresentei no LSSNA 2020 ( slides , gravação ) tinha sobrecarga de CPU aproximadamente na faixa de 60% -159% em benchmarks com kernel pesado e ~ 8% para um benchmark com muito espaço do usuário.)

Infelizmente, mesmo uma mitigação de uso após livre determinística muitas vezes não será suficiente para limitar deterministicamente o raio de explosão de algo como um erro de recontagem do objeto em que ocorreu. Considere um caso em que dois codepaths operam simultaneamente no mesmo objeto: Codepath A assume que o objeto está ativo e sujeito às regras normais de bloqueio. Codepath B sabe que a contagem de referência atingiu zero, assume que, portanto, tem acesso exclusivo ao objeto (o que significa que todos os membros são mutáveis ​​sem nenhum requisito de bloqueio) e está tentando destruir o objeto. O codepath B pode então começar a eliminar as referências que o objeto estava mantendo em outros objetos, enquanto o codepath A segue as mesmas referências. Isso poderia levar a liberações posteriores de uso em objetos apontados. Se todas as estruturas de dados estiverem sujeitas à mesma mitigação, isso pode não ser um grande problema; mas se algumas estruturas de dados (comostruct page) não estão protegidos, pode permitir um desvio de mitigação.

Problemas semelhantes se aplicam a estruturas de dados com unionmembros que são usados ​​em diferentes estados de objeto; por exemplo, aqui está uma estrutura de dados de kernel aleatória com um rcu_headem a union(apenas um exemplo aleatório, não há nada de errado com este código até onde eu sei):

struct allowedips_node {
    struct wg_peer __rcu *peer;
    struct allowedips_node __rcu *bit[2];
    /* While it may seem scandalous that we waste space for v4,
     * we're alloc'ing to the nearest power of 2 anyway, so this
     * doesn't actually make a difference.
     */
    u8 bits[16] __aligned(__alignof(u64));
    u8 cidr, bit_at_a, bit_at_b, bitlen;

    /* Keep rarely used list at bottom to be beyond cache line. */
    union {
        struct list_head peer_list;
        struct rcu_head rcu;
    };
};

Contanto que tudo esteja funcionando corretamente, o peer_listmembro é usado apenas enquanto o objeto está ativo, e o rcumembro é usado apenas após o objeto ter sido programado para liberação retardada; portanto, este código está totalmente correto. Mas se um bug de alguma forma fez com que o peer_listfosse lido após o rcumembro ter sido inicializado, resultaria em confusão de tipo.

Em minha opinião, isso demonstra que embora as atenuações de UAF tenham muito valor (e teriam evitado com segurança a exploração desse bug específico), um uso após a liberação é apenas uma consequência possível da classe de sintoma “confusão de estado de objeto”(que pode ou não ser igual à classe de bug da causa raiz). Seria ainda melhor aplicar regras sobre os estados do objeto e garantir que um objeto, por exemplo, não possa ser acessado por meio de uma referência “refcount” depois que o refcount atingiu zero e fez a transição lógica para um estado como “membros não RCU são propriedade exclusiva de thread executando desmontagem “ou” RCU callback pendente, membros não-RCU não foram inicializados “ou” acesso exclusivo a membros protegidos por RCU concedido ao thread executando desmontagem, outros membros não inicializados “. Claro, fazer isso como uma mitigação de tempo de execução seria ainda mais caro e confuso do que uma mitigação UAF confiável; este nível de proteção provavelmente só é realista com pelo menos algum nível de anotações e validação estática.

Contra o acesso de UAF: Mitigação de UAF probabilística; vazamentos de ponteiro

Resumo: Alguns tipos de atenuação de UAF probabilística são interrompidos se o invasor puder vazar informações sobre os valores do ponteiro; e informações sobre valores de ponteiro vazam facilmente para o espaço do usuário, por exemplo, por meio de comparações de ponteiro em estruturas semelhantes a mapa / conjunto.

Se uma mitigação de UAF determinística for muito cara, uma alternativa é fazê-lo de forma probabilística; por exemplo, marcando ponteiros com um pequeno número de bits que são verificados em relação aos metadados do objeto no acesso e, em seguida, alterando os metadados do objeto quando os objetos são liberados.

A desvantagem dessa abordagem é que vazamentos de informações podem ser usados ​​para interromper a proteção. Um exemplo de tipo de vazamento de informações que gostaria de destacar (sem qualquer julgamento sobre a importância relativa disso em comparação com outros tipos de vazamentos de informações) são as comparações de indicadores intencionais, que têm várias facetas.

Um exemplo relativamente simples em que isso pode ser um problema é o kcmp()syscall. Este syscall compara dois objetos kernel usando uma comparação aritmética de seus ponteiros permutados (usando uma permutação aleatória por inicialização, consulte kptr_obfuscate()) e retorna o resultado da comparação (menor, igual ou maior). Isso dá ao espaço do usuário uma maneira de ordenar os manipuladores para objetos do kernel (por exemplo, descritores de arquivo) com base nas identidades desses objetos do kernel (por struct fileexemplo, instâncias), o que por sua vez permite que o espaço do usuário agrupe um conjunto de tais identificadores apoiando o objeto do kernel no O(n*log(n))tempo usando um padrão algoritmo de classificação.

Este syscall pode ser usado para melhorar a confiabilidade de exploits use-after-free contra alguns tipos de estrutura porque verifica se dois ponteiros para objetos do kernel são iguais sem acessar esses objetos: Um invasor pode alocar um objeto, de alguma forma criar uma referência para o objeto que não é contado corretamente, libere o objeto, realoque-o e, em seguida, verifique se a realocação realmente reutilizou o mesmo endereço, comparando a referência pendente e uma referência ao novo objeto com kcmp(). Se kcmp()incluir os bits de tag do ponteiro na comparação, isso provavelmente também permitiria quebrar as atenuações de UAF probabilísticas.

Essencialmente, a mesma preocupação se aplica quando um ponteiro do kernel é criptografado e, em seguida, fornecido ao espaço do usuário em fuse_lock_owner_id(), que criptografa o ponteiro para a files_structcom uma versão de código aberto do XTEA antes de passá-lo para um daemon FUSE.

Em ambos os casos, remover explicitamente os bits de tag seria uma solução alternativa aceitável porque um ponteiro sem bits de tag ainda identifica exclusivamente um local de memória; e como essas são interfaces muito especiais que expõem intencionalmente algum grau de informações sobre os ponteiros do kernel para o espaço do usuário, seria razoável ajustar esse código manualmente.

Um exemplo um pouco mais interessante é o comportamento desta parte do código do espaço do usuário:

#define _GNU_SOURCE
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/resource.h>
#include <err.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>

#define SYSCHK(x) ({          \
  typeof(x) __res = (x);      \
  if (__res == (typeof(x))-1) \
    err(1, "SYSCHK(" #x ")"); \
  __res;                      \
})

int main(void) {
  struct rlimit rlim;
  SYSCHK(getrlimit(RLIMIT_NOFILE, &rlim));
  rlim.rlim_cur = rlim.rlim_max;
  SYSCHK(setrlimit(RLIMIT_NOFILE, &rlim));

  cpu_set_t cpuset;
  CPU_ZERO(&cpuset);
  CPU_SET(0, &cpuset);
  SYSCHK(sched_setaffinity(0, sizeof(cpuset), &cpuset));

  int epfd = SYSCHK(epoll_create1(0));
  for (int i=0; i<1000; i++)
    SYSCHK(eventfd(0, 0));
  for (int i=0; i<192; i++) {
    int fd = SYSCHK(eventfd(0, 0));
    struct epoll_event event = {
      .events = EPOLLIN,
      .data = { .u64 = i }
    };
    SYSCHK(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event));
  }

  char cmd[100];
  sprintf(cmd, "cat /proc/%d/fdinfo/%d", getpid(), epfd);
  system(cmd);
}

Ele primeiro cria uma tonelada de eventfds que não são usados. Em seguida, ele cria um monte de mais eventfds e cria relógios epoll para eles, na ordem de criação, com um contador de incremento monotônico no campo “dados”. Posteriormente, ele pede ao kernel para imprimir o estado atual da instância epoll, que vem com uma lista de todos os relógios epoll registrados, incluindo o valor do datamembro (em hex). Mas como essa lista é classificada? Este é o resultado da execução desse código em uma VM Ubuntu 20.10 (truncado, porque é um pouco longo):

user@ubuntuvm:~/epoll_fdinfo$ ./epoll_fdinfo 
pos:    0
flags:  02
mnt_id: 14
tfd:     1040 events:       19 data:               24  pos:0 ino:2f9a sdev:d
tfd:     1050 events:       19 data:               2e  pos:0 ino:2f9a sdev:d
tfd:     1024 events:       19 data:               14  pos:0 ino:2f9a sdev:d
tfd:     1029 events:       19 data:               19  pos:0 ino:2f9a sdev:d
tfd:     1048 events:       19 data:               2c  pos:0 ino:2f9a sdev:d
tfd:     1042 events:       19 data:               26  pos:0 ino:2f9a sdev:d
tfd:     1026 events:       19 data:               16  pos:0 ino:2f9a sdev:d
tfd:     1033 events:       19 data:               1d  pos:0 ino:2f9a sdev:d
[...]

data:campo aqui é o índice de loop que armazenamos no .datamembro, formatado como hexadecimal. Aqui está a lista completa dos datavalores em decimal:

36, 46, 20, 25, 44, 38, 22, 29, 30, 45, 33, 28, 41, 31, 23, 37, 24, 50, 32, 26, 21, 43, 35, 48, 27, 39, 40, 47, 42, 34, 49, 19, 95, 105, 111, 84, 103, 97, 113, 88, 89, 104, 92, 87, 100, 90, 114, 96, 83, 109, 91, 85, 112, 102, 94, 107, 86, 98, 99, 106, 101, 93, 108, 110, 12, 1, 14, 5, 6, 9, 4, 17, 7, 13, 0, 8, 2, 11, 3, 15, 16, 18, 10, 135, 145, 119, 124, 143, 137, 121, 128, 129, 144, 132, 127, 140, 130, 122, 136, 123, 117, 131, 125, 120, 142, 134, 115, 126, 138, 139, 146, 141, 133, 116, 118, 66, 76, 82, 55, 74, 68, 52, 59, 60, 75, 63, 58, 71, 61, 53, 67, 54, 80, 62, 56, 51, 73, 65, 78, 57, 69, 70, 77, 72, 64, 79, 81, 177, 155, 161, 166, 153, 147, 163, 170, 171, 154, 174, 169, 150, 172, 164, 178, 165, 159, 173, 167, 162, 152, 176, 157, 168, 148, 149, 156, 151, 175, 158, 160, 186, 188, 179, 180, 183, 191, 181, 187, 182, 185, 189, 190, 184

Embora pareçam um pouco aleatórios, você pode ver que a lista pode ser dividida em blocos de comprimento 32 que consistem em sequências contíguas embaralhadas de números:

Block 1 (32 values in range 19-50):
36, 46, 20, 25, 44, 38, 22, 29, 30, 45, 33, 28, 41, 31, 23, 37, 24, 50, 32, 26, 21, 43, 35, 48, 27, 39, 40, 47, 42, 34, 49, 19

Block 2 (32 values in range 83-114):
95, 105, 111, 84, 103, 97, 113, 88, 89, 104, 92, 87, 100, 90, 114, 96, 83, 109, 91, 85, 112, 102, 94, 107, 86, 98, 99, 106, 101, 93, 108, 110

Block 3 (19 values in range 0-18):
12, 1, 14, 5, 6, 9, 4, 17, 7, 13, 0, 8, 2, 11, 3, 15, 16, 18, 10

Block 4 (32 values in range 115-146):
135, 145, 119, 124, 143, 137, 121, 128, 129, 144, 132, 127, 140, 130, 122, 136, 123, 117, 131, 125, 120, 142, 134, 115, 126, 138, 139, 146, 141, 133, 116, 118

Block 5 (32 values in range 51-82):
66, 76, 82, 55, 74, 68, 52, 59, 60, 75, 63, 58, 71, 61, 53, 67, 54, 80, 62, 56, 51, 73, 65, 78, 57, 69, 70, 77, 72, 64, 79, 81

Block 6 (32 values in range 147-178):
177, 155, 161, 166, 153, 147, 163, 170, 171, 154, 174, 169, 150, 172, 164, 178, 165, 159, 173, 167, 162, 152, 176, 157, 168, 148, 149, 156, 151, 175, 158, 160

Block 7 (13 values in range 179-191):
186, 188, 179, 180, 183, 191, 181, 187, 182, 185, 189, 190, 184

O que está acontecendo aqui fica claro quando você olha para as estruturas de dados epollusadas internamente. ep_insertchamadas ep_rbtree_insertpara inserir um struct epitemem uma árvore vermelho-preto (um tipo de árvore binária classificada); e esta árvore vermelha e preta é classificada usando uma tupla de a struct file *e um número descritor de arquivo:

/* Compare RB tree keys */
static inline int ep_cmp_ffd(struct epoll_filefd *p1,
                             struct epoll_filefd *p2)
{
        return (p1->file > p2->file ? +1:
                (p1->file < p2->file ? -1 : p1->fd - p2->fd));
}

Portanto, os valores que estamos vendo foram ordenados com base no endereço virtual do correspondente struct file; e SLUB aloca a struct filepartir de páginas de pedido 1 (ou seja, páginas de tamanho 8 KiB), que podem conter 32 objetos cada:

root@ubuntuvm:/sys/kernel/slab/filp# cat order 
1
root@ubuntuvm:/sys/kernel/slab/filp# cat objs_per_slab 
32
root@ubuntuvm:/sys/kernel/slab/filp# 

Isso explica o agrupamento dos números que vimos: Cada bloco de 32 valores contíguos corresponde a uma página de ordem 1 que estava anteriormente vazia e é usada pelo SLUB para alocar objetos até ficar cheia.

Com esse conhecimento, podemos transformar esses números um pouco, para mostrar a ordem em que os objetos foram alocados dentro de cada página (excluindo páginas para as quais não vimos todas as alocações):

$ cat slub_demo.py 
#!/usr/bin/env python3
blocks = [
  [ 36, 46, 20, 25, 44, 38, 22, 29, 30, 45, 33, 28, 41, 31, 23, 37, 24, 50, 32, 26, 21, 43, 35, 48, 27, 39, 40, 47, 42, 34, 49, 19 ],
  [ 95, 105, 111, 84, 103, 97, 113, 88, 89, 104, 92, 87, 100, 90, 114, 96, 83, 109, 91, 85, 112, 102, 94, 107, 86, 98, 99, 106, 101, 93, 108, 110 ],
  [ 12, 1, 14, 5, 6, 9, 4, 17, 7, 13, 0, 8, 2, 11, 3, 15, 16, 18, 10 ],
  [ 135, 145, 119, 124, 143, 137, 121, 128, 129, 144, 132, 127, 140, 130, 122, 136, 123, 117, 131, 125, 120, 142, 134, 115, 126, 138, 139, 146, 141, 133, 116, 118 ],
  [ 66, 76, 82, 55, 74, 68, 52, 59, 60, 75, 63, 58, 71, 61, 53, 67, 54, 80, 62, 56, 51, 73, 65, 78, 57, 69, 70, 77, 72, 64, 79, 81 ],
  [ 177, 155, 161, 166, 153, 147, 163, 170, 171, 154, 174, 169, 150, 172, 164, 178, 165, 159, 173, 167, 162, 152, 176, 157, 168, 148, 149, 156, 151, 175, 158, 160 ],
  [ 186, 188, 179, 180, 183, 191, 181, 187, 182, 185, 189, 190, 184 ]
]

for alloc_indices in blocks:
  if len(alloc_indices) != 32:
    continue
  # indices of allocations ('data'), sorted by memory location, shifted to be relative to the block
  alloc_indices_relative = [position - min(alloc_indices) for position in alloc_indices]
  # reverse mapping: memory locations of allocations,
  # sorted by index of allocation ('data').
  # if we've observed all allocations in a page,
  # these will really be indices into the page.
  memory_location_by_index = [alloc_indices_relative.index(idx) for idx in range(0, len(alloc_indices))]
  print(memory_location_by_index)
$ ./slub_demo.py 
[31, 2, 20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17]
[16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17, 31, 2, 20, 6, 14]
[23, 30, 17, 31, 2, 20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27]
[20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17, 31, 2]
[5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17, 31, 2, 20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15]

E essas sequências são quase as mesmas, exceto que foram giradas em diferentes quantidades. Este é exatamente o esquema de randomização freelist do SLUB , conforme apresentado no commit 210e7a43fa905 !

Quando um SLUB kmem_cacheé criado (uma instância do alocador SLUB para uma classe de tamanho específica e potencialmente outros atributos específicos, geralmente inicializados no momento da inicialização), init_cache_random_seqcache_random_seq_createpreencha uma matriz ->random_seqcom índices de objetos ordenados aleatoriamente via embaralhamento de Fisher-Yates, com o comprimento da matriz igual ao número de objetos que cabem em uma página. Então, sempre que o SLUB pega uma nova página do alocador de página de nível inferior, ele inicializa a página freelist usando os índices de ->random_seq, começando em um índice aleatório na matriz (e envolvendo quando o final é alcançado). (Estou ignorando o fallback de alocação de baixa ordem aqui.)

Portanto, em resumo, podemos ignorar a randomização SLUB para a laje a partir da qual struct fileé alocada porque alguém a usou como uma chave de pesquisa em um tipo específico de estrutura de dados. Isso já é bastante indesejável se a randomização SLUB deve fornecer proteção contra alguns tipos de ataques locais para todos os blocos.

O efeito de enfraquecimento da randomização do heap de tais estruturas de dados não é necessariamente limitado a casos em que os elementos da estrutura de dados podem ser listados em ordem por espaço do usuário: Se houvesse um caminho de código que iterasse na árvore em ordem e liberasse todos os nós da árvore , isso poderia ter um efeito semelhante, porque os objetos seriam colocados na lista independente do alocador classificados por endereço, cancelando a randomização. Além disso, você pode vazar informações sobre a ordem de iteração por meio de canais do lado do cache ou algo parecido.

Se introduzirmos uma mitigação de uso após livre probabilística que depende de os invasores não serem capazes de descobrir se os bits superiores do endereço de um objeto mudaram depois que ele foi realocado, essa estrutura de dados também pode quebrar isso. Este caso é mais confuso do que coisas como, kcmp()porque aqui o vazamento de ordem de endereço origina-se de uma estrutura de dados padrão.

Você deve ter notado que alguns dos exemplos que estou usando aqui seriam mais ou menos limitados aos casos em que um invasor está realocando memória com o mesmo tipo da alocação antiga , enquanto um ataque típico de uso após livre acaba substituindo um objeto com um tipo diferente para causar confusão de tipo. Como um exemplo de bug que pode ser explorado para escalonamento de privilégios sem confusão de tipo no nível da estrutura C, veja a entrada 808 em nosso bugtracker . Meu exploit para aquele bug primeiro inicia uma writev()operação em um arquivo gravável, permite que o kernel valide se o arquivo é realmente gravável, em seguida, substitui o struct filepor um fileapontando somente leitura para /etc/crontab, e permitewritev()Prosseguir. Isso permite obter privilégios de root por meio de um bug use-after-free sem ter que mexer com os ponteiros do kernel, layouts de estrutura de dados, ROP ou qualquer coisa assim. É claro que essa abordagem não funciona com todos os use-after-free embora.

(A propósito: para um exemplo de vazamento de ponteiro através de estruturas de dados de contêiner em um mecanismo de JavaScript, veja este bug que relatei ao Firefox em 2016, quando eu não era um funcionário do Google , que vaza os 32 bits de um ponteiro de operações de temporização em tabelas de hash pessimais – basicamente transformando o ataque HashDoS em um vazamento de informações. É claro que, hoje em dia, um vazamento de ponteiro baseado em canal lateral em um mecanismo JS provavelmente não valeria mais a pena tratar como um bug de segurança, uma vez que você provavelmente pode obter o mesmo resultado com Spectre …)

Contra a liberação de páginas SLUB: Prevenção da reutilização de endereços virtuais além da placa

(Também discutido um pouco sobre a lista de fortalecimento do kernel neste tópico .)

Uma alternativa mais fraca, mas com menos uso de CPU, para tentar fornecer proteção completa de uso após livre para objetos individuais seria garantir que os endereços virtuais que foram usados ​​para a memória da placa nunca sejam reutilizados fora da placa, mas que as páginas físicas ainda podem ser reutilizado. Essa seria a mesma abordagem básica usada por PartitionAlloc e outros. Em termos de kernel, isso significaria essencialmente servir alocações SLUB do espaço vmalloc.

Alguns desafios que posso imaginar com essa abordagem são:

  • As alocações SLUB são servidas atualmente a partir do mapeamento linear, que normalmente usa enormes páginas; se mapeamentos de vmalloc com PTEs de 4 K foram usados, a pressão TLB pode aumentar, o que pode levar a alguma degradação de desempenho.
  • Para poder usar alocações SLUB em contextos que operam diretamente na memória física, às vezes é necessário que as páginas SLUB sejam fisicamente contíguas. Isso não é realmente um problema, mas é diferente do comportamento padrão do vmalloc. (Nota secundária: os buffers de DMA nem sempre precisam ser fisicamente contíguos – se você tiver um IOMMU, pode usá-lo para mapear páginas descontíguas para um intervalo de endereços DMA contíguo, assim como as tabelas de página normais criam memória virtualmente contígua. Veja isto a API interna do kernel para um exemplo que faz uso disso, e a documentação do Fuchsia para uma visão geral de alto nível de como tudo isso funciona em geral.)
  • Algumas partes do kernel são convertidas entre endereços virtuais, struct pageponteiros e (para interação com hardware) endereços físicos. Este é um mapeamento relativamente simples para endereços no mapeamento linear, mas se tornaria um pouco mais complicado para endereços vmalloc. Em particular, page_to_virt()phys_to_virt()teria que ser ajustado.
    • Isso provavelmente também será um problema para coisas como Tag de memória, uma vez que as tags de ponteiro terão que ser reconstruídas ao converter de volta para um endereço virtual. Talvez faça sentido proibir esses auxiliares fora do gerenciamento de memória de baixo nível e mudar os usuários existentes para, em vez disso, manter um indicador normal para a alocação ao redor? Ou talvez você pudesse permitir que os ponteiros struct pagetransportassem os bits de tag para o endereço virtual correspondente em bits de endereço não utilizados / ignorados?

A probabilidade de que essa defesa possa impedir que os UAFs levem a uma confusão de tipos exploráveis ​​depende um pouco da granularidade das placas; se tipos de estrutura específicos têm suas próprias lajes, isso fornece mais proteção do que se os objetos fossem agrupados apenas por tamanho. Portanto, para melhorar a utilidade da memória de lajes com suporte virtual, seria necessário substituir as lajes kmalloc genéricas (que contêm vários objetos, agrupados apenas por tamanho) por outras segregadas por tipo e / ou local de alocação. (O pessoal do grsecurity / PaX vagamente aludiu a fazer algo mais ou menos nesse sentido, usando instrumentação de compilador.)

Após a realocação como pagetable: randomização do layout da estrutura

Os problemas de segurança de memória são frequentemente explorados de uma forma que envolve a criação de uma confusão de tipo; por exemplo, explorar um use-after-free substituindo o objeto liberado por um novo objeto de um tipo diferente.

Uma defesa que apareceu pela primeira vez em grsecurity / PaX é embaralhar a ordem dos membros da estrutura no tempo de construção para tornar mais difícil explorar confusões de tipo envolvendo estruturas; a versão upstream do Linux está em scripts / gcc-plugins / randomize_layout_plugin.c .

A eficácia disso depende em parte se o invasor é forçado a explorar o problema como uma confusão entre dois structs ou se o invasor pode explorá-lo como uma confusão entre um struct e um array (por exemplo, contendo caracteres, ponteiros ou PTEs). Especialmente se apenas um único membro de estrutura for acessado, uma confusão de estrutura de matriz ainda pode ser viável pulverizando toda a matriz com elementos idênticos. Contra a confusão de tipo descrita nesta postagem do blog (entrestruct pide entradas de tabela de página), a randomização do layout da estrutura ainda pode ser um tanto eficaz, uma vez que a contagem de referência tem metade do tamanho de um PTE e, portanto, pode ser colocada aleatoriamente para se sobrepor à metade inferior ou superior de um PTE. (Exceto que a versão upstream do Linux de randstruct apenas randomiza structs explicitamente marcados ou structs contendo apenas ponteiros de função e struct pidnão tem tal marcação.)

É claro que traçar uma distinção clara entre structs e arrays simplifica um pouco as coisas; por exemplo, pode haver tipos de estrutura que têm um grande número de ponteiros do mesmo tipo ou valores controlados pelo invasor, não muito diferente de uma matriz.

Se o invasor não puder contornar completamente a randomização do layout da estrutura espalhando toda a estrutura, o nível de proteção depende de como as compilações do kernel são distribuídas:

  • Se as compilações forem criadas centralmente por um fornecedor e distribuídas para um grande número de usuários, um invasor que deseja comprometer os usuários desse fornecedor terá que retrabalhar sua exploração para usar um tipo de confusão diferente para cada versão, o que pode forçar o invasor reescrever partes significativas da exploração.
  • Se o kernel for construído individualmente por máquina (ou similar), e a imagem do kernel for mantida em segredo, um invasor que deseja explorar de forma confiável um sistema alvo pode ser forçado a vazar informações sobre alguns layouts de estrutura e preparar exploits para vários possíveis estruture layouts com antecedência ou escreva partes da exploração interativamente após vazar informações do sistema de destino.

Para maximizar o benefício da randomização do layout da estrutura em um ambiente onde os kernels são construídos centralmente por uma distribuição / fornecedor, seria necessário fazer da randomização um processo de inicialização, tornando os deslocamentos da estrutura relocáveis. (Ou no momento da instalação, mas isso interromperia a assinatura do código.) Fazer isso de forma limpa (por exemplo, de forma que deslocamentos imediatos de 8 e 16 bits ainda possam ser usados ​​para acesso de membro de estrutura onde possível) provavelmente exigiria muito trabalho com compiladores internos, desde o frontend C até a emissão de relocações. Uma versão um tanto hacky desta abordagem já existe para a compilação C-> BPF como BPF CO-RE , usando o clang embutido __builtin_preserve_access_index, mas que depende de debuginfo, que provavelmente não é uma abordagem muito limpa.

Os problemas potenciais com a randomização do layout da estrutura são:

  • Se as estruturas forem feitas à mão para serem particularmente eficientes em relação ao cache, o layout da estrutura totalmente aleatório pode piorar o comportamento do cache. A implementação existente do randstruct opcionalmente evita isso tentando randomizar apenas dentro de uma linha de cache.
  • A menos que a randomização seja aplicada de uma forma que seja refletida nas informações de depuração DWARF e outras (o que não está na implementação existente baseada no GCC ), ela pode tornar a depuração e a introspecção mais difíceis.
  • Ele pode quebrar o código que faz suposições sobre o layout da estrutura; mas esse código é grosseiro e deve ser limpo de qualquer maneira (e Gustavo Silva tem trabalhado para consertar alguns desses problemas).

Embora a aleatorização do layout da estrutura por si só seja limitada em sua eficácia por confusões de estrutura-array, pode ser mais confiável em combinação com o particionamento de heap limitado: se o heap for particionado de forma que apenas a confusão de estrutura-estrutura seja possível, e a aleatorização do layout de estrutura torna a estrutura -struct confusão difícil de explorar, e nenhuma estrutura na mesma partição heap tem propriedades semelhantes a array, então provavelmente seria muito mais difícil explorar diretamente um UAF como confusão de tipo. Por outro lado, se o heap já estiver particionado dessa forma, pode fazer mais sentido ir até o fim com o particionamento do heap e criar uma partição por tipo em vez de lidar com todo o incômodo da aleatoriedade do layout da estrutura.

(A propósito, se os layouts de estrutura forem randomizados, o preenchimento provavelmente também deve ser randomizado explicitamente, em vez de estar sempre do mesmo lado para randomizar ao máximo os membros da estrutura com baixo alinhamento; consulte minha postagem da lista neste tópico para obter detalhes.)

Integridade do Fluxo de Controle

Quero salientar explicitamente que a integridade do fluxo de controle do kernel não teria nenhum impacto sobre essa estratégia de exploração . Usando uma estratégia somente de dados, evitamos ter que vazar endereços, evitamos ter que encontrar dispositivos ROP para uma compilação de kernel específica e não somos afetados por nenhuma defesa que tente proteger o código do kernel ou o fluxo de controle do kernel. Coisas como obter acesso a arquivos arbitrários, aumentar os privilégios de um processo e assim por diante não requerem controle de ponteiro de instrução do kernel.

Como em minha última postagem de blog sobre a exploração do kernel do Linux (que era sobre um subsistema com bugs que um fornecedor do Android adicionou ao kernel downstream), para mim, uma abordagem somente de dados para a exploração parece muito natural e parece menos confusa do que tentar sequestrar o fluxo de controle qualquer forma.

Talvez as coisas sejam diferentes para o código do espaço do usuário; mas para ataques pelo espaço do usuário contra o kernel, atualmente não vejo muitos utilitários no CFI porque normalmente afeta apenas um dos muitos métodos possíveis para explorar um bug. (Embora, é claro, possa haver casos específicos em que um bug só pode ser explorado sequestrando o fluxo de controle, por exemplo, se uma confusão de tipo só permite a substituição de um ponteiro de função e nenhum dos callees permitidos faz suposições sobre os tipos de entrada ou privilégios que poderiam ser quebrados por alterando o ponteiro de função.)

Tornando dados importantes somente leitura

Uma ideia de defesa que apareceu em vários lugares (incluindo kernels de telefone Samsung e kernels XNU para iOS) é tornar os dados cruciais para a segurança do kernel somente leitura, exceto quando estão sendo intencionalmente gravados – a ideia é que até se um invasor tem uma gravação de memória arbitrária, ele não deve ser capaz de sobrescrever diretamente partes específicas de dados que são de excepcional importância para a segurança do sistema, como estruturas de credenciais, tabelas de página ou (no iOS, usando PPL) páginas de código do espaço do usuário .

O problema que vejo com essa abordagem é que uma grande parte das coisas que um kernel faz são, de alguma forma, críticas para o funcionamento correto do sistema e a segurança do sistema. Gerenciamento de estado de MMU, agendamento de tarefas, alocação de memória, sistemas de arquivos , cache de página, IPC, … – se qualquer uma dessas partes do kernel estiver suficientemente corrompida, um invasor provavelmente será capaz de obter acesso a todos os dados do usuário no sistema, ou usar essa corrupção para alimentar entradas falsas em um dos subsistemas cujas próprias estruturas de dados são somente leitura.

Na minha opinião, em vez de tentar separar as partes mais críticas do kernel e executá-las em um contexto com privilégios mais altos, pode ser mais produtivo ir na direção oposta e tentar aproximar algo como um microkernel adequado: Dividir drivers que não precisam estar estritamente no kernel e executá-los em um contexto de privilégios mais baixos que interage com o kernel central por meio de APIs adequadas. Claro que é mais fácil falar do que fazer! Mas o Linux já tem APIs para acessar com segurança dispositivos PCI (VFIO) e dispositivos USB do espaço do usuário, embora os drivers do espaço do usuário não sejam exatamente seu principal caso de uso.

(Também se pode considerar tornar as tabelas de página somente para leitura, não por causa de sua importância para a integridade do sistema, mas porque a estrutura das entradas da tabela de página as torna mais agradáveis ​​de trabalhar em exploits que são restritos nas modificações que podem fazer na memória. Eu não gosto esta abordagem porque eu acho que não tem uma conclusão clara e é altamente invasiva em relação a como as estruturas de dados podem ser dispostas.)

Conclusão

Este era essencialmente um bug de bloqueio chato em algum subsistema de kernel aleatório que, se não fosse pela insegurança da memória, não deveria ter muita relevância para a segurança do sistema. Eu escrevi um exploit bastante direto e sem graça (e reconhecidamente não confiável) contra esse bug; e provavelmente o maior desafio que encontrei ao tentar explorá-lo no Debian foi entender corretamente como o alocador SLUB funciona.

Minha intenção ao descrever os estágios de exploração, e como diferentes mitigações podem afetá-los, é destacar que quanto mais uma exploração de corrupção de memória avança, mais opções um invasor ganha; e, portanto, como regra geral, quanto mais cedo uma exploração for interrompida, mais confiável será a defesa. Portanto, mesmo que as defesas que interrompem uma exploração em um ponto anterior tenham uma sobrecarga maior, elas ainda podem ser mais úteis .

Eu acho que a situação atual da segurança do software poderia ser dramaticamente melhorada – em um mundo onde um pequeno bug em algum subsistema de kernel aleatório pode levar a um comprometimento total do sistema, o kernel não pode fornecer isolamento de segurança confiável. Os engenheiros de segurança devem ser capazes de se concentrar em coisas como verificações de erros de permissão e correção de gerenciamento de memória central, e não ter que gastar seu tempo lidando com problemas no código que não deveriam ter qualquer relevância para a segurança do sistema.

No curto prazo, existem algumas atenuações de band-aid que poderiam ser usadas para melhorar a situação – como particionamento de heap ou atenuação de UAF de baixa granularidade. Isso pode vir com algum custo de desempenho e pode fazer com que pareçam pouco atraentes; mas ainda acho que eles são um lugar melhor para investir tempo de desenvolvimento do que coisas como CFI, que tenta proteger contra estágios muito posteriores de exploração.

A longo prazo, acho que algo precisa mudar na linguagem de programação – o C simples é simplesmente muito sujeito a erros. Talvez a resposta seja Rust; ou talvez a resposta seja introduzir anotações suficientes para C (ao longo das linhas do projeto Checked C da Microsoft , embora até onde eu possa ver eles se concentrem principalmente em coisas como limites de array em vez de questões temporais) para permitir a verificação em tempo de construção equivalente a Rust de regras de bloqueio, estados de objeto, refcounting, void pointer casts e assim por diante. Ou talvez outra linguagem segura para a memória completamente diferente se torne popular no final, nem C nem Rust?

Minha esperança é que talvez no futuro de médio prazo, possamos ter um núcleo de código kernel de alto desempenho estaticamente verificado trabalhando junto com código legado instrumentado, verificado em tempo de execução e sem desempenho crítico, de modo que os desenvolvedores possam fazer uma troca entre investir tempo em anotações corretas de preenchimento e desaceleração da instrumentação de tempo de execução sem comprometer a segurança de qualquer maneira.

TL; DR

a corrupção da memória é um grande problema porque pequenos bugs, mesmo fora do código relacionado à segurança, podem comprometer totalmente o sistema; e para resolver isso, é importante que nós:

  • no curto a médio prazo:
    • projetar novas mitigações de segurança de memória :
      • idealmente, isso pode interromper os ataques em um ponto inicial, onde os invasores ainda não têm muitas opções alternativas
        • talvez no nível do alocador de memória (ou seja, SLUB)
      • que não pode ser quebrado usando vazamentos de tag de endereço (ou tentamos evitar vazamentos de tag, mas isso é muito difícil)
    • continue usando a redução da superfície de ataque
      • em particular seccomp
    • impedir explicitamente que código não confiável obtenha primitivas de ataque importantes
      • como o FUSE, e potencialmente considerar o controle de agendamento de baixa granularidade
  • a longo prazo:
    • verificar estaticamente a exatidão da maioria dos códigos de desempenho crítico
      • isso exigirá a determinação de como fazer o retrofit de anotações para o estado do objeto e o bloqueio no código C legado
      • considere projetar verificação de tempo de execução apenas para lacunas na verificação estática

Fonte: https://googleprojectzero.blogspot.com/