Introdução
Em setembro e outubro desse ano ministraremos uma turma do nosso treinamento de Introdução à Exploração de Vulnerabilidades no Kernel Linux. Será uma turma na modalidade online e, para dar um gostinho sobre o que abordaremos no curso decidimos fazer uma série de blog posts realizando a exploração de uma vulnerabilidade da mesma classe e na mesma distribuição Linux que serão empregados no treinamento.
Para isso, será utilizada uma vulnerabilidade real do kernel do Linux. Passaremos por todo o processo de exploração da vulnerabilidade, desde montar o ambiente do laboratório, fazer a exploração da vulnerabilidade, manter o sistema estável e, por fim, realizar os bypasses de algumas mitigações presentes no sistema. Ao final da série, o leitor deverá ser capaz de escrever um exploit que obtém privilégios de root em um sistema Linux com as principais mitigações habilitadas (com exceção do SMAP devido à classe de vulnerabilidade, falaremos sobre isso no último post da série) como demonstrado no vídeo abaixo.
Dessa forma, essa série estará dividida em 4 partes:
- Vulnerabilidade e ambiente
- Acionamento da vulnerabilidade
- Exploração estável da vulnerabilidade
- Bypasses de mitigações modernas: SMEP e KASLR
Por se tratarem de blog posts, seremos objetivos em alguns pontos, explanando e contextualizando conforme for necessário para didática do conteúdo. Isso quer dizer que vamos buscar apenas uma primitiva para exploração dessa vulnerabilidade e abordaremos uma única forma de escalação de privilégios para obter sucesso na exploração. No treinamento, entretanto, tratamos diversas primitivas e técnicas que nos permitem alcançar nosso objetivo de escalar privilégios.
A vulnerabilidade
A vulnerabilidade que vamos explorar já foi corrigida no kernel do Linux. O commit que fez a correção é o 36eec020fab66 net: sched: fix NULL pointer dereference in mq_attach. Trata-se de um problema de NULL pointer dereference no subsistema de networking. O sistema operacional alvo utilizado será Debian 12 com o kernel 6.1.8-1.
NOTA: Vulnerabilidades de NULL pointer dereference não são mais exploráveis por padrão na maioria das distribuições Linux. Os sistemas Linux atuais contam com uma configuração habilitada por padrão que impede o mapeamento do endereço NULL pelo usuário. Além disso, as CPUs modernas também contam com recurso que impossibilita a exploração dessa classe de vulnerabilidade. Falaremos mais sobre isso na seção “Configurações para Laboratório” mais abaixo.
Escolhemos uma vulnerabilidade de NULL pointer dereference para essa série de posts e para o treinamento pelo motivo dessa classe de vulnerabilidade não precisar de muitos detalhes dos subsistemas do kernel do Linux e de alocação dinâmica em kernel para ser compreendida.
Assim, ela permite a prática de conceitos e fundamentos, como espaço de endereçamento entre usuário e kernel e paginação. Com esses tópicos bem compreendidos, o leitor alcançará um bom nível de entendimento sobre o funcionamento de um sistema operacional e como acontece a interação com uma CPU moderna, além de também permitir que outros assuntos mais complexos possam ser praticados no futuro.
Para o leitor que também irá participar do treinamento, essa série de blog posts servem como pré-requisitos para uma melhor absorção e compreensão do treinamento. Outros tipos de vulnerabilidades acarretaria uma complexidade maior a respeito da vulnerabilidade em si, o que poderia atrapalhar no entendimento do que queremos passar, principalmente para o público que visamos com esse treinamento.
A própria mensagem do commit que fez a correção da vulnerabilidade traz as informações de que se trata de um NULL pointer dereference. Inclusive apresenta um relatório de mensagem de erro do kernel (OOPS) mostrando detalhes sobre o que ocorreu, como estado dos registradores e stacktrace:
Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
Internal error: Oops: 0000000096000006 [#1] SMP
Modules linked in:
pstate: 20000005 (nzCv daif -PAN -UAO -TCO -DIT -SSBS BTYPE=--)
pc : mq_attach+0x44/0xa0
lr : qdisc_graft+0x20c/0x5cc
sp : ffff80000e2236a0
x29: ffff80000e2236a0 x28: ffff0000c0e59d80 x27: ffff0000c0be19c0
x26: ffff0000cae3e800 x25: 0000000000000010 x24: 00000000fffffff1
x23: 0000000000000000 x22: ffff0000cae3e800 x21: ffff0000c9df4000
x20: ffff0000c9df4000 x19: 0000000000000000 x18: ffff80000a934000
x17: ffff8000f5b56000 x16: ffff80000bb08000 x15: 0000000000000000
x14: 0000000000000000 x13: 6b6b6b6b6b6b6b6b x12: 6b6b6b6b00000001
x11: 0000000000000000 x10: 0000000000000000 x9 : 0000000000000000
x8 : ffff0000c0be0730 x7 : bbbbbbbbbbbbbbbb x6 : 0000000000000008
x5 : ffff0000cae3e864 x4 : 0000000000000000 x3 : 0000000000000001
x2 : 0000000000000001 x1 : ffff8000090bc23c x0 : 0000000000000000
Call trace:
mq_attach+0x44/0xa0
qdisc_graft+0x20c/0x5cc
tc_modify_qdisc+0x1c4/0x664
rtnetlink_rcv_msg+0x354/0x440
netlink_rcv_skb+0x64/0x144
rtnetlink_rcv+0x28/0x34
netlink_unicast+0x1e8/0x2a4
netlink_sendmsg+0x308/0x4a0
sock_sendmsg+0x64/0xac
____sys_sendmsg+0x29c/0x358
___sys_sendmsg+0x90/0xd0
__sys_sendmsg+0x7c/0xd0
__arm64_sys_sendmsg+0x2c/0x38
invoke_syscall+0x54/0x114
el0_svc_common.constprop.1+0x90/0x174
do_el0_svc+0x3c/0xb0
el0_svc+0x24/0xec
el0t_64_sync_handler+0x90/0xb4
el0t_64_sync+0x174/0x178Para chegar a esse ponto de reproduzirmos essa mensagem do kernel precisamos de um ambiente vulnerável para fazer os testes. Apresentaremos a forma como montar o ambiente ainda neste post, mas antes vamos entender o que é um problema de NULL pointer dereference e o que possibilita sua exploração.
NULL Pointer Dereference e Memory Layout
Quando lidamos com linguagens de baixo nível, como C, é bastante comum utilizarmos ponteiros. Ponteiros são tipos de variáveis cujo conteúdo é um endereço de memória. Esse endereço de memória, portanto, contém um conteúdo do mesmo tipo do ponteiro. Ou seja, um ponteiro do tipo inteiro é uma variável que seu conteúdo aponta para um endereço na memória, e esse endereço contém um valor do tipo inteiro.
Ponteiros são largamente utilizados. Por padrão, um ponteiro que aponte para NULL é inválido, por isso é comum encontrar validações, como a seguinte, durante alocações de memória:
void *pointer;
...
pointer = malloc(sizeof(typeA));
if (pointer == NULL) {
printf("[!] - Ponteiro inválido!n");
exit(EXIT_FAILURE);
}NOTA: O endereço NULL (0x0000000000000000) não é de fato um endereço inválido. Ele apenas não é mapeado por padrão e, no caso de não estar mapeado, ao ser acessado, uma exceção é gerada e o programa é interrompido pelo sistema operacional. Porém, em algumas circunstâncias, ele pode ser mapeado e acessado normalmente, assim como qualquer outro endereço do espaço de endereçamento de um processo.
Algum tempo atrás, não havia proibição e qualquer processo poderia mapear e tornar o endereço NULL um endereço válido. Hoje, porém, isso não é permitido por padrão devido ao fato de problemas de NULL pointer dereference no kernel permitir o comprometimento do sistema.
O exemplo acima é um caso de alocação de memória com validação do retorno em um processo em execução no modo usuário. O equivalente em modo kernel é mostrado abaixo:
void *pointer;
...
pointer = kmalloc(sizeof(struct typeA), GFP_KERNEL);
if (pointer == NULL) {
return NULL;
}Há dois pontos de diferença entre esses códigos:
- A API de alocação de memória dinâmica no kernel do Linux é diferente da API usada em modo usuário.
- O código em execução no kernel não pode simplesmente chamar ‘
exit()'quando deseja encerrar a execução. O kernel, quando iniciado, não é finalizado até que a máquina seja desligada, então, não faz sentido usar ‘exit()'. Os códigos no kernel normalmente precisam retornar para onde foi chamado.
No caso do kernel Linux é possível que aplicações user mode mapeiem o endereço NULL para seu uso, devido ao layout de memória do sistema. O espaço de possíveis endereços de memória de processos user mode vai desde o endereço 0x0000000000000000 (NULL) até 0x00007fffffffffff. É então seguido de endereços não canônicos e depois o espaço de endereçamento do kernel, indo do endereço 0xffff800000000000 até 0xffffffffffffffff:

NOTA: Você pode ver mais sobre o layout de memória no Linux acessando essa página: Complete virtual memory map with 4-level page tables.
Se um processo malicioso mapear o endereço NULL, inserir dados de seu interesse nesse endereço e acionar a vulnerabilidade em questão, os códigos executados em kernel mode no contexto do processo malicioso que desreferenciam o ponteiro apontado para o endereço NULL serão utilizados com dados do processo malicioso, podendo afetar a execução de código do kernel.
Isso acontece pois na arquitetura x86 (e diversas outras) o espaço de endereçamento do kernel e do processo são mesclados (como exibido o layout da memória anteriormente). Isso quer dizer que por mais que o processo em user mode não tenha acesso ao espaço de memória do kernel, o inverso não acontece, pois o kernel tem sim acesso ao espaço de endereçamento da aplicação em user mode e por causa do layout de memória, o endereço NULL pertence ao espaço de endereçamento do usuário.
AVISO: Por questão de simplificação que afirmamos que o kernel tem acesso ao espaço de endereçamento do processo em user mode. Na realidade, hoje em dia há mitigações que impedem que isso aconteça. É o caso do SMEP e SMAP. Mas trataremos melhor sobre elas no último post dessa série. Por enquanto apenas considere que, devido ao layout de memória do kernel Linux e da arquitetura x86, o kernel tem acesso ao espaço de endereçamento em user mode.
Na arquitetura x86 e no Linux, por padrão, o kernel é mapeado no espaço de endereçamento mencionado acima e isso é válido para todos os processos. Porém cada processo tem seu próprios mapeamentos. O espaço de endereçamento dos processos é o mesmo, mas os endereços mapeados em um processo A é valido para ele e para o kernel em execução no contexto do processo A, mas não é valido em um processo B e nem para o kernel em execução no contexto do processo B.
Isso acontece porque cada processo tem sua tabela de paginação (page table) e quando o kernel executa, ele usa a page table do processo. O kernel é mapeado na page table de todos os processos e uma modificação em endereços do kernel no contexto de um processo é visível em outro processo. Já modificações e mapeamento em processos são visíveis apenas para o próprio processo, suas threads e seus processos filhos (quando o mapeamento permite compartilhamento).
Em suma, se o processo A mapeia o endereço NULL, mas a vulnerabilidade de NULL pointer dereference é acionada no contexto do processo B, o endereço não é valido (mapeado, vez que são page tables diferentes). Assim, uma exceção será gerada, causando um OOPS e encerrando o processo. O acesso pelo kernel precisa ser feito no contexto do processo que fez o mapeamento.
Por outro lado, há outras arquiteturas e sistemas (inclusive o Linux 32 bits com a configuração 4G/4G habilitada) em que o layout de memória é diferente, havendo uma separação real entre o espaço de endereçamento do kernel e o do processo em user mode. No caso desses sistemas não há compartilhamento de espaço de endereçamento entre o kernel e processos.
NOTA: O conhecimento de temas como memória virtual, paginação e segmentação é importante para uma melhor compreensão dessa parte. Caso não consigam entender as noções de endereçamento, recomendamos que faça uma pausa e revise esses conteúdos.
Sendo assim, podemos chegar à conclusão de que esse tipo de vulnerabilidade pode ser explorada em cenários onde a memória do kernel e user mode são combinadas.
Como um NULL pointer dereference pode permitir o comprometimento de todo o sistema?
Como já mencionado, se não há funcionalidades em uso no sistema que proíba o usuário de mapear endereço NULL, ele pode ser mapeado e se torna um endereço válido. Então, se um erro de NULL pointer dereference acontece no kernel e o ponteiro não é validado corretamente, permitindo que o endereço NULL seja acessado enquanto o código está em execução em modo kernel, o kernel irá acessar um endereço no qual o usuário tem total controle.
O caso mais simples e direto que permite o comprometimento é quando o objeto utilizado pelo kernel contém um endereço de uma função a ser executada. Como esse endereço está armazenado na memória que um processo do usuário tem permissão para ler e escrever, o endereço da função pode ser alterado de acordo com o que o usuário desejar. Isso leva diretamente à execução de código arbitrário no kernel.
Ambiente do laboratório
Para conseguirmos trabalhar na exploração dessa vulnerabilidade, precisamos configurar um ambiente propício para fazermos os devidos testes e análises conforme a necessidade.
Como a vulnerabilidade que vamos abordar já foi corrigida, não basta instalar qualquer versão do kernel Linux para que já seja possível explorá-la, pois sendo uma versão recente, possivelmente ela já estará corrigida. Dessa forma utilizaremos a versão 6.1.8-1 do kernel no Debian 12.
Instalação do sistema base
Já definimos que vamos utilizar o Debian 12 com o kernel na versão 6.1.8-1. Portanto, precisamos instalar esse sistema. Para isso vamos criar uma máquina virtual.
AVISO: Para a criação da máquina virtual você pode utilizar o virtualizador que tiver maior familiaridade, seja ele QEMU, VMware, VirtualBox ou qualquer outro. Nesses posts utilizaremos o QEMU com LibVirt, mas não há mudanças significativas para o que iremos fazer de laboratório caso opte por utilizar um software diferente. Apenas se atente em realizar as ações da forma devida para a solução que você fizer uso.
Após baixar a imagem de instalação do Debian 12, disponibilizado nessa página, vamos criar a máquina virtual. Para essa máquina, disponibilize cerca de 2GB de memória RAM, 2 vCPUs, 50GB de disco rígido e coloque ela na rede de modo que tenha acesso à internet e ao seu host para acessá-la via SSH.
No nosso cenário utilizando QEMU + LibVirt criamos um disco e definimos nossa máquina com os seguintes comandos:
$ qemu-img create -f qcow2 debian12.qcow2 50G
$ virt-install --network default --network bridge=research0 --memory 2048 --vcpus 2 --arch x86_64 --graphics vnc --noautoconsole --disk debian12.qcow2 --name debian_12 --os-variant debian12 --cdrom debian-12.6.0-amd64-DVD-1.isoFaça a instalação do sistema de maneira padrão. Leve em consideração os seguintes pontos:
- Não é necessária a instalação de nenhum ambiente gráfico.
- Lembre de habilitar para instalar servidor SSH.
- Costumamos configurar ‘
debian12‘ como hostname e ‘user‘ como nome e senha do usuário do sistema.
Após finalizar a instalação e fazer o reboot da máquina podemos conectar via SSH. Com acesso ao shell da máquina virtual, vamos executar alguns comandos para termos o sistema atualizado e com alguns pacotes que serão úteis durante esse processo:
# apt update
# apt upgrade -y
# apt install -y sudo gcc make vim
# usermod -a user -G sudo
# rebootObtenção do kernel vulnerável
Já temos uma máquina virtual em execução para o nosso laboratório. Mas ela provavelmente não está com o kernel que contém a vulnerabilidade que vamos explorar. Precisamos agora instalar a versão do kernel que decidimos usar, 6.1.8-1.
O Debian disponibiliza um site bastante útil nesse caso: Debian Snapshots. É um snapshot do repositório que contém todas as versões dos pacotes disponíveis nos repositórios do Debian em uma data específica. Assim, é possível configurar os repositórios para uma determinada data e horário para instalar as versões de softwares disponíveis naquele instante de tempo.
Em nosso cenário vamos acessar a página do pacote Linux disponível nos snapshots. Buscando nessa página, podemos encontrar a versão correspondente que queremos utilizar, 6.1.8-1. Assim, vamos obter três artefatos para montarmos o ambiente do laboratório:
- Código fonte do kernel
- Binário com símbolos para debugging
- Binário para execução na máquina virtual
Código Fonte
Nessa etapa vamos coletar o código fonte exato que gerou o kernel vulnerável para ser instalado na máquina virtual do laboratório. Você pode estar se perguntando por qual motivo precisamos fazer isso, ao invés de simplesmente baixar o código de kernel.org, certo?
O código disponível em kernel.org é o que podemos chamar de kernel vanilla ou kernel upstream, sem nenhuma modificação além das edições dos próprios desenvolvedores do projeto. Entretanto, algumas distribuições Linux costumam fazer modificações no kernel upstream de modo a atender melhor seus usuários.
Esse é exatamente o caso do Debian. Você pode ver as modificações que são feitas pelo time de kernel do Debian nesse repositório git. Então precisamos ter em mãos exatamente o código que foi compilado para gerar o binário que vamos executar na nossa máquina virtual.
No topo da página na qual acessamos no Debian Snapshots, podemos ver três tipos de arquivos:

- *.debian.tar.gz: este arquivo é o conjunto das modificações que o time do Debian faz no kernel.
- *.dsc: é um arquivo de descrição do empacotamento do Debian que descreve um pacote de códigos.
- *.orig.tar.xz: este arquivo contém o kernel upstream utilizado por aquela versão de pacote do Debian.
Para obter o exato código do kernel utilizado para compilar o binário do Debian, baixamos esses três arquivos na máquina host:
$ wget http://snapshot.debian.org/archive/debian-debug/20230129T213302Z/pool/main/l/linux/linux_6.1.8-1.debian.tar.xz
$ wget http://snapshot.debian.org/archive/debian-debug/20230129T213302Z/pool/main/l/linux/linux_6.1.8-1.dsc
$ wget http://snapshot.debian.org/archive/debian-debug/20230129T213302Z/pool/main/l/linux/linux_6.1.8.orig.tar.xz
$NOTA: O programa que utilizaremos faz parte da suíte de empacotamento do Debian, mas você pode instalar em diversas distribuições, mesmo que seu host não seja Debian. Caso não consiga instalar, você pode fazer esse processo na máquina virtual e depois transferir os arquivos para o host.
Após obter os três arquivos necessários executamos o seguinte comando:
$ dpkg-source --no-check -x *.dsc
$ ls -1 linux-6.1.8
arch
block
certs
COPYING
CREDITS
crypto
debian
Documentation
drivers
fs
include
init
io_uring
ipc
Kbuild
Kconfig
kernel
lib
LICENSES
MAINTAINERS
Makefile
mm
net
README
rust
samples
scripts
security
sound
tools
usr
virtCom esse comando é gerado um diretório chamado linux-6.1.8 que contém o código fonte do kernel, inclusive os patches do Debian já aplicados.
Binário com símbolos
Ainda no host, obteremos o binário do kernel compilado com símbolos. Ele será bastante útil para qualquer necessidade de debugging que surgir durante o laboratório.
Ainda na página de snapshots do Debian, encontramos os pacotes com os binários compilados, inclusive com símbolos. Busque por ‘amd64-dbg‘ para encontrar o arquivo necessário:

Faça o download desse arquivo:
$ wget http://snapshot.debian.org/archive/debian/20230131T034648Z/pool/main/l/linux/linux-image-6.1.0-3-amd64-dbg_6.1.8-1_amd64.deb
$Esse é um arquivo do empacotamento do Debian:
$ file linux-image-6.1.0-3-amd64-dbg_6.1.8-1_amd64.deb
linux-image-6.1.0-3-amd64-dbg_6.1.8-1_amd64.deb: Debian binary package (format 2.0), with control.tar.xz , data compression xz
$Podemos utilizar o comando 'ar‘ para extrair os arquivos desse pacote:
$ ar x linux-image-6.1.0-3-amd64-dbg_6.1.8-1_amd64.deb
$ ls
control.tar.xz data.tar.xz debian-binary linux-image-6.1.0-3-amd64-dbg_6.1.8-1_amd64.deb
$Três arquivos são extraídos do pacote .deb. Deles, o que importa para nosso projeto é o data.tar.xz, pois contém o binário com símbolos que vamos utilizar. Sendo assim, extraímos o conteúdo desse arquivo:
$ tar xvf data.tar.xz
$ ls usr/lib/debug/
boot lib vmlinux-6.1.0-3-amd64
$É o binário ‘vmlinux-6.1.0-3-amd64‘ que faremos uso. Copie-o para dentro do diretório do código fonte, salvando-o com o nome ‘vmlinux‘:
$ cp usr/lib/debug/vmlinux-6.1.0-3-amd64 linux-6.1.8/vmlinux
$ cd linux-6.1.8Entramos nesse diretório para melhor acesso ao código fonte e já temos o binário com símbolos também dentro nele. De agora em diante vamos utilizá-lo como espaço de trabalho.
Binário para execução
Do lado do nosso host já temos o ambiente pronto, depois de obtermos o código fonte do kernel e o vmlinux com símbolos. Agora precisamos colocar a versão correta do kernel para executar na máquina virtual.
Mais uma vez na página de snapshot do Debian você consegue esse binário. Fica bem próximo do arquivo que contém os símbolos. Pesquise por ‘amd64-unsigned‘ e irá encontrar o arquivo .deb para baixar:

Acesse a máquina virtual pelo SSH e faça o download desse arquivo:
user@debian12:~$ wget http://snapshot.debian.org/archive/debian/20230131T034648Z/pool/main/l/linux/linux-image-6.1.0-3-amd64-unsigned_6.1.8-1_amd64.deb
user@debian12:~$Após baixar utilize o dpkg para instalar:
user@debian12:~$ sudo dpkg -i linux-image-6.1.0-3-amd64-unsigned_6.1.8-1_amd64.deb
[sudo] password for user:
Selecting previously unselected package linux-image-6.1.0-3-amd64-unsigned.
(Reading database ... 41494 files and directories currently installed.)
Preparing to unpack linux-image-6.1.0-3-amd64-unsigned_6.1.8-1_amd64.deb ...
Unpacking linux-image-6.1.0-3-amd64-unsigned (6.1.8-1) ...
Setting up linux-image-6.1.0-3-amd64-unsigned (6.1.8-1) ...
I: /vmlinuz.old is now a symlink to boot/vmlinuz-6.1.0-23-amd64
I: /initrd.img.old is now a symlink to boot/initrd.img-6.1.0-23-amd64
I: /vmlinuz is now a symlink to boot/vmlinuz-6.1.0-3-amd64
I: /initrd.img is now a symlink to boot/initrd.img-6.1.0-3-amd64
/etc/kernel/postinst.d/initramfs-tools:
update-initramfs: Generating /boot/initrd.img-6.1.0-3-amd64
/etc/kernel/postinst.d/zz-update-grub:
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.1.0-23-amd64
Found initrd image: /boot/initrd.img-6.1.0-23-amd64
Found linux image: /boot/vmlinuz-6.1.0-22-amd64
Found initrd image: /boot/initrd.img-6.1.0-22-amd64
Found linux image: /boot/vmlinuz-6.1.0-3-amd64
Found initrd image: /boot/initrd.img-6.1.0-3-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
user@debian12:~$Cheque no diretório /boot e lá estará o kernel com a versão que precisamos:
user@debian12:~$ ls -1 /boot/vmlinuz*
/boot/vmlinuz-6.1.0-22-amd64
/boot/vmlinuz-6.1.0-23-amd64
/boot/vmlinuz-6.1.0-3-amd64
user@debian12:~$Configuração do GRUB
Depois de instalada a versão correta do kernel, basta reiniciar a máquina virtual e escolher a versão específica durante o boot para que o sistema execute com a versão em questão.
Porém, essa maneira é pouco prática e propensa a erros. Como esse laboratório tem o foco em explorar a vulnerabilidade nessa versão, vamos configurar para o GRUB selecionar essa versão por padrão.
Para fazer tal configuração, vamos buscar saber em que ordem das opções de boot a versão desejada aparece e então configurá-la. Como visto anteriormente, na instalação da máquina virtual há três versões do kernel instaladas (na sua máquina virtual pode variar um pouco a depender de quando reproduza esse laboratório). Então buscaremos saber a ordem na qual a versão do kernel desejada aparece pelo próprio arquivo de configuração do GRUB:
user@debian12:~$ sudo grep 'Debian' /boot/grub/grub.cfg
menuentry 'Debian GNU/Linux' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
submenu 'Advanced options for Debian GNU/Linux' $menuentry_id_option 'gnulinux-advanced-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
menuentry 'Debian GNU/Linux, with Linux 6.1.0-23-amd64' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.1.0-23-amd64-advanced-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
menuentry 'Debian GNU/Linux, with Linux 6.1.0-23-amd64 (recovery mode)' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.1.0-23-amd64-recovery-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
menuentry 'Debian GNU/Linux, with Linux 6.1.0-22-amd64' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.1.0-22-amd64-advanced-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
menuentry 'Debian GNU/Linux, with Linux 6.1.0-22-amd64 (recovery mode)' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.1.0-22-amd64-recovery-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
menuentry 'Debian GNU/Linux, with Linux 6.1.0-3-amd64' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.1.0-3-amd64-advanced-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {
menuentry 'Debian GNU/Linux, with Linux 6.1.0-3-amd64 (recovery mode)' --class debian --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.1.0-3-amd64-recovery-91cfce81-5ed9-46ab-a77e-3b464dbddff2' {Nesse caso, vemos que é o quinto item dentro do submenu. Levando isso em consideração, vamos configurar ‘1>4‘ como default no GRUB. Abra o arquivo ‘/etc/default/grub‘ e modifique a configuração ‘GRUB_DEFAULT‘ para o valor '1>4', veja como deve ficar:
user@debian12:~$ grep GRUB_DEFAULT /etc/default/grub
GRUB_DEFAULT="1>4"
user@debian12:~$Depois disso, gere a configuração do GRUB com o seguinte comando:
user@debian12:~$ sudo grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.1.0-23-amd64
Found initrd image: /boot/initrd.img-6.1.0-23-amd64
Found linux image: /boot/vmlinuz-6.1.0-22-amd64
Found initrd image: /boot/initrd.img-6.1.0-22-amd64
Found linux image: /boot/vmlinuz-6.1.0-3-amd64
Found initrd image: /boot/initrd.img-6.1.0-3-amd64
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
user@debian12:~$Então reinicie a máquina virtual. Ao acessá-la novamente, verifique se o kernel desejado está em execução:
user@debian12:~$ uname -r
6.1.0-3-amd64
user@debian12:~$Nesse momento, já temos quase tudo que precisamos para o laboratório. Vamos finalizar configurando a máquina virtual para conexão com o debugger e mais alguns pontos que serão de grande apoio no decorrer das análises.
Configurações para debugging
A máquina virtual já está configurada para executar o kernel que precisamos para o laboratório. Também temos em mãos o código fonte e o binário com símbolos na máquina host. Vamos para o próximo passo que é interligar esses dois pontos: máquina virtual e máquina host debugger.
Conexão com GDB
Para fazer o debugging do kernel é importante termos o poder de pausar a execução da máquina virtual, analisar o que está sendo executado, o estado dos registradores e da memória e assim por diante. Para quem já tem certa familiaridade com exploração de binários ou em desenvolver programas de baixo nível ou compilados, já deve saber que esse tipo de coisa é papel de um debugger.
O debugger que utilizaremos é o GDB. Ele é o padrão de facto da indústria para esse tipo de atividade em ambiente Linux. Então certifique de que ele está instalado em sua máquina.
Quando se utiliza o GDB para fazer debugging de uma aplicação user mode, basicamente, precisamos informar o binário para o GDB executar ou um processo para o GDB capturar a execução.
O cenário do kernel é semelhante, mas com algumas particularidades. Também devemos informar um binário para o GDB analisar (o vmlinux com símbolos que já obtemos), mas além disso precisamos informar uma máquina em execução que o GDB consiga interagir, pausar, etc.
Esse tipo de funcionalidade fica a cargo do hypervisor que você está utilizando cuja função é prover suporte. Como estamos fazendo esses posts com o QEMU + LibVirt, abordaremos como é feito com ele e em seguida como é no VMware. Outros virtualizadores como VirtualBox também permitem a execução, mas nesse caso você precisará pesquisar como deve realizar essa configuração.
QEMU + LibVirt
No caso do QEMU com LibVirt você precisa executar um comando:
$ virt-xml debian_12 --edit --confirm
--qemu-commandline '-gdb tcp:localhost:1234'Isso vai fazer com que o LibVirt configure acesso de debugging à sua máquina virtual. Para essa configuração surtir efeito a máquina virtual deve ser totalmente desligada, então faça o seguinte:
$ virsh destroy debian_12
$ virsh start debian_12Após isso, você deverá conseguir executar o GDB e se conectar nessa máquina. Faça o teste:
$ gdb vmlinux -q
Reading symbols from vmlinux...
(gdb) target remote :1234Dessa maneira você já pode perceber que está com a máquina pausada. Abra o console ou tente conectar via SSH e você notará que a máquina não respondeu. Então volte ao GDB e execute o comando ‘continue‘, logo em seguida a máquina voltará a responder e o SSH se conectará:
(gdb) continue
Continuing.Depois de realizado esses testes, pressione Control+C no GDB, digite ‘detach‘ para desconectar o GDB remoto e depois ‘quit‘ para finalizar o GDB, pois não precisaremos dele nesse exato momento.
VMware
Para o virtualizador VMware, o seguinte comando precisará ser adicionado ao arquivo VMX da máquina virtual. Isso precisa ser feito com a máquina virtual desligada. Quando iniciar a máquina virtual novamente, a porta 8864 estará aberta e o GDB poderá se conectar.
debugStub.listen.guest64 = "TRUE"$ gdb vmlinux -q
Reading symbols from vmlinux...
(gdb) target remote :8864Agora faremos duas configurações que nos ajudarão durante as análises:
- Criação dos scripts auxiliares para
GDBdo kernel - Remoção de algumas mitigações para o laboratório
Scripts GDB do kernel
No código fonte do kernel Linux há alguns scripts auxiliares para agilizar certas atividades relacionadas a debugging do kernel. Alguns deles são referentes ao GDB, são scripts que disponibilizam comandos no GDB para tornar mais prático nosso processo de debugging.
Para fazer a configuração desses scripts iremos realizar alguns passos na sua máquina host. Estando no diretório que contém o código fonte que você baixou, guiado pelas seções anteriores, execute os seguintes passos:
- Copiar o arquivo de configuração que o Debian utilizou (lembre-se do que falamos sobre ter o ambiente coerente entre código e o que deve ser executado):
$ scp debian12:/boot/config-6.1.0-3-amd64 .config- Habilitar a opção de gerar os scripts do
GDB:
$ echo CONFIG_GDB_SCRIPTS=y >> .config- Fazer a leitura do arquivo de configuração do kernel que modificamos no passo anterior:
$ make olddefconfig- Gerar os scripts de auxílio
GDB:
$ make scripts_gdbDepois de realizar esses passos você deve ver o arquivo ‘vmlinux-gdb.py‘ gerado na raiz do código fonte que você tem:
$ ls vmlinux-*
vmlinux-gdb.pyPara validar que realmente está funcionando, abra o GDB com o vmlinux que você tem nesse diretório e execute o comando ‘apropos lx‘. Caso você veja diversos comando como o resultado abaixo, a geração dos scripts foi feita corretamente. Esses comandos do GDB são implementados pelo script.
$ gdb vmlinux -q
Reading symbols from vmlinux...
(gdb) apropos lx
function lx_clk_core_lookup -- Find struct clk_core by name
function lx_current -- Return current task.
function lx_device_find_by_bus_name -- Find struct device by bus and name (both strings)
function lx_device_find_by_class_name -- Find struct device by class and name (both strings)
function lx_module -- Find module by name and return the module variable.
function lx_per_cpu -- Return per-cpu variable.
function lx_rb_first -- Lookup and return a node from an RBTree
function lx_rb_last -- Lookup and return a node from an RBTree.
function lx_rb_next -- Lookup and return a node from an RBTree.
function lx_rb_prev -- Lookup and return a node from an RBTree.
function lx_task_by_pid -- Find Linux task by PID and return the task_struct variable.
function lx_thread_info -- Calculate Linux thread_info from task variable.
function lx_thread_info_by_pid -- Calculate Linux thread_info from task variable found by pid
lx-clk-summary -- Print clk tree summary
lx-cmdline -- Report the Linux Commandline used in the current kernel.
lx-configdump -- Output kernel config to the filename specified as the command
lx-cpus -- List CPU status arrays
lx-device-list-bus -- Print devices on a bus (or all buses if not specified)
lx-device-list-class -- Print devices in a class (or all classes if not specified)
lx-device-list-tree -- Print a device and its children recursively
lx-dmesg -- Print Linux kernel log buffer.
lx-fdtdump -- Output Flattened Device Tree header and dump FDT blob to the filename
lx-genpd-summary -- Print genpd summary
lx-iomem -- Identify the IO memory resource locations defined by the kernel
lx-ioports -- Identify the IO port resource locations defined by the kernel
lx-list-check -- Verify a list consistency
lx-lsmod -- List currently loaded modules.
lx-mounts -- Report the VFS mounts of the current process namespace.
lx-ps -- Dump Linux tasks.
lx-symbols -- (Re-)load symbols of Linux kernel and currently loaded modules.
lx-timerlist -- Print /proc/timer_list
lx-version -- Report the Linux Version of the current kernel.Após essa etapa, finalizamos as configurações do lado do host, mas ainda não está tudo pronto para começarmos os testes. Ainda precisamos ajustar alguns pontos na máquina virtual.
Desativação das mitigações
Por padrão, o kernel Linux já habilita diversas mitigações modernas que buscam dificultar a exploração de vulnerabilidades, caso a CPU em execução as suportem. No nosso cenário de laboratório algumas interferem no nosso estudo, são elas:
- KASLR
- SMEP
- SMAP
- KPTI
Para início das análises vamos desabilitar essas mitigações.
NOTA: Como falamos no início, nessa série de posts também vamos fazer os bypasses de algumas dessas mitigações. No último post da série, vamos reabilitá-las, com exceção do SMAP, e trabalhar em manter o exploit funcional ainda com elas em funcionamento.
A forma mais prática de desabilitar essas configurações é alterando o GRUB para que, no processo de boot, ele informe ao kernel Linux para não habilitar as mitigações. A maneira que vamos fazer isso é, novamente, modificando o arquivo de configuração do GRUB, ‘/etc/default/grub‘.
Nesse arquivo, vamos editar a opção ‘GRUB_CMDLINE_LINUX‘ adicionando ‘clearcpuid=smep,smap nokaslr nopti‘. Você pode executar o seguinte comando abaixo que realizará essa configuração:
user@debian12:~$ sudo sed -i -E 's/GRUB_CMDLINE_LINUX="(.*)"/GRUB_CMDLINE_LINUX="1 clearcpuid=smep,smap nokaslr nopti"/' /etc/default/grub
user@debian12:~$Para confirmar que a alteração ocorreu corretamente, execute o seguinte comando:
user@debian12:~$ grep -w GRUB_CMDLINE_LINUX /etc/default/grub
GRUB_CMDLINE_LINUX=" clearcpuid=smep,smap nokaslr nopti"
user@debian12:~$Depois disso, devemos gerar a configuração do GRUB e reiniciar a máquina virtual:
user@debian12:~$ sudo grub-mkconfig -o /boot/grub/grub.cfg
user@debian12:~$ sudo rebootUma forma de confirmar que está funcionando é olhar o conteúdo do arquivo ‘/proc/cmdline‘ após o boot:
user@debian12:~$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-6.1.0-3-amd64 root=UUID=91cfce81-5ed9-46ab-a77e-3b464dbddff2 ro clearcpuid=smep,smap nokaslr nopti quiet
user@debian12:~$Note a configuração que adicionamos sendo exibida como conteúdo do arquivo.
Agora com a máquina configurada de uma forma melhor para uma seção de debugging, vamos conectar com o GDB:
$ gdb vmlinux -q
Reading symbols from vmlinux...
(gdb) target remote :1234
Remote debugging using :1234
0xffffffff81a2972b in native_safe_halt () at arch/x86/include/asm/irqflags.h:52
52 }
=> 0xffffffff81a2972b <native_safe_halt+11>: c3 ret
0xffffffff81a2972c <native_safe_halt+12>: cc int3
0xffffffff81a2972d <native_safe_halt+13>: cc int3
0xffffffff81a2972e <native_safe_halt+14>: cc int3
0xffffffff81a2972f <native_safe_halt+15>: cc int3Como agora não há randomização dos endereços, os símbolos que o GDB tem acesso (que estão no vmlinux) casam com os endereços que estão na memória da máquina em execução. Assim, perceba que o GDB consegue exibir corretamente que a função que parou a máquina é a ‘native_safe_halt()‘.
Configurações para laboratório
Na seção que falamos sobre a vulnerabilidade que exploraremos nessa série comentamos sobre a classe de vulnerabilidades NULL pointer dereference não ser mais explorável por padrão na maioria das distribuições Linux.
Também explicamos na seção sobre o tipo de vulnerabilidade NULL pointer dereference que a exploração dessa vulnerabilidade é possível pois processos em user mode podem mapear o endereço de memória NULL. Explicamos ainda que esse mapeamento será visível para o kernel, executando no contexto desse processo devido ao compartilhamento do espaço de endereçamento entre processos de usuário e o kernel. Então, com uma vulnerabilidade desse tipo, o kernel vai acessar esse endereço e fazer uso de conteúdo que processos em user mode podem manipular.
Uma das mitigações que, hoje em dia, faz com que falhas do tipo NULL pointer dereference não possam mais ser exploradas é impedir o mapeamento do endereço NULL por parte de aplicações user mode. Essa configuração é feita pela sysctl ‘vm.mmap_min_addr‘ que define qual o endereço mínimo que pode ser mapeado pelo usuário. A sysctl ‘vm.mmap_min_addr‘ proíbe o mapeamento do endereço NULL especificamente, mas permite mapear algumas páginas acima.
Em alguns casos, Linux Security Modules (LSMs), como SELinux, podem proibir o mapeamento, mesmo se a sysctl permitir. Mas, ainda assim, os LSMs não podem proibir todos os casos de NULL + offset. O foco deste artigo está no endereço NULL, mas o mesmo se aplica a endereços NULL + offset ou quando o kernel acessa qualquer endereço do espaço de endereçamento do usuário de forma não esperada, caracterizando uma vulnerabilidade.
Como as configurações da sysctl e dos LSMs não são robustas o suficiente, mitigações do processador como SMEP e SMAP bloqueiam a execução e o acesso a páginas do usuário quando em modo kernel (com exceção em casos específicos). O kernel nunca precisará executar código do usuário em um cenário normal, mas sempre precisará ler e escrever em endereços do usuário para o correto funcionamento do sistema. Essas mitigações tornam vulnerabilidades de NULL pointer dereference completamente desinteressante para o comprometimento do sistema.
Removeremos a limitação imposta pela sysctl ‘vm.mmap_min_addr‘ a fim de tornar possível a vulnerabilidade ser explorada. Fazemos isso adicionando a seguinte configuração no arquivo ‘/etc/sysctl.conf‘ e reiniciando a máquina virtual para que a configuração seja aplicada.
user@debian12:~$ echo vm.mmap_min_addr=0 | sudo tee -a /etc/sysctl.conf
user@debian12:~$ tail -n1 /etc/sysctl.conf
vm.mmap_min_addr=0
user@debian12:~$
user@debian12:~$ sudo sysctl vm.mmap_min_addr
vm.mmap_min_addr = 0
user@debian12:~$NOTA: Escolhemos utilizar uma vulnerabilidade de NULL pointer dereference para explicar alguns conceitos de exploração de kernel, pois vemos que ela é um classe de vulnerabilidade mais direta. Ela permite que possamos direcionar nossos esforços nos pontos relacionados à exploração de kernel, ao invés de complicações relacionada à vulnerabilidade em si. Por isso precisamos fazer esse tipo de configuração.
Próximos passos
Com isso chegamos ao final do primeiro post da série. Nesse ponto você já deve ter uma noção sobre a vulnerabilidade que vamos explorar e o porquê ela acontece e pode ser explorável. Além disso, configuramos o ambiente do laboratório, o que nos permitirá fazer todas as análises necessárias para explorar essa vulnerabilidade.
Nos próximos posts vamos avançar na exploração dessa vulnerabilidade. No post a seguir vamos acionar a vulnerabilidade com nosso próprio código, ocasionando um crash no sistema.
Depois disso, no post seguinte, faremos de fato a exploração da vulnerabilidade buscando uma shell com privilégios de root. Também nos preocuparemos em manter o sistema estável após da exploração.
Por fim, no último post da série, vamos reabilitar as mitigações que desabilitamos neste post e trataremos de fazer os seus respectivos bypasses, para que o laboratório seja mais próximo de um ambiente real e mesmo assim o exploit funcione perfeitamente.
Referências
Caso deseje mais materiais sobre os assuntos que tratamos neste post, recomendamos os seguintes:
- A Guide to Kernel Exploitation – Enrico Perla e Massimiliano Oldani
- Understanding the Linux Kernel – Daniel P. Bovet e Marco Cesati
- Understanding the Linux Virtual Memory Manager – Mel Gorman
- Manuais da Intel e/ou AMD
- Attacking the Core : Kernel Exploiting Notes
- Issue 1792: Linux: virtual address 0 is mappable via privileged write() to /proc/*/mem
