DEV Community

Cover image for Depurando um crash de use-after-free no meniOS
Plínio Balduino
Plínio Balduino

Posted on

Depurando um crash de use-after-free no meniOS

Há uns dois anos, escrevi aqui que estava escrevendo um sistema operacional chamado meniOS, quase do zero. (Leia aqui).

Novamente fiquei um bom tempo parado, com a vida e a correria engolindo nossos momentos de lazer, mas voltei a brincar com ele.

A coisa avançou bastante nos últimos tempos, e atualmente é possível executar pequenos binários fora do kernel, na área chamada user space, onde rodam quase todos os softwares que usamos no dia a dia.

Conforme o sistema vai crescendo, cada vez mais partes vão trabalhando juntas e dependendo umas das outras, e vai ficando cada vez mais difícil entender porque um erro acontece.

Para ajudar a navegar entre vários e vários logs cheios de números hexadecimais que eu mesmo mandei imprimir mas não entendia muito bem o que significavam, criei esse pequeno guia, que mostra, passo a passo, como diagnosticar um crash provocado por acesso a memória já liberada (use-after-free). O objetivo é destacar o processo e as ferramentas que uso no meniOS, como nm, objdump e um script em Python com pyelftools, para transformar um endereço de falha em uma linha de código. Ao final, teremos um roteiro reaproveitável para incidentes parecidos.

1. Prepare o cenário

O binário de exemplo é intencionalmente falho: ele libera um ponteiro e logo em seguida tenta escrever naquele endereço. Não por coincidência, essa é uma das principais causas de tela azul no Windows, ou seja lá que cor estejam usando hoje em dia.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    char *ptr = malloc(128);
    if (ptr == NULL) {
        perror("malloc");
        return EXIT_FAILURE;
    }

    free(ptr);
    printf("Dereferencing freed pointer...\n");

    *ptr = 'A';  // Use-after-free: provoca crash
    printf("Value: %c\n", *ptr);

    return EXIT_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

Compilar com símbolos de depuração deixa o código-fonte acessível na desmontagem e nas tabelas DWARF; esse formato armazena metadados de depuração no ELF, incluindo o mapeamento entre endereços de instruções, arquivos e números de linha, o que é essencial para relacionar endereços a trechos de código.

gcc -g -O0 -Wall -Wextra use_after_free.c -o use_after_free
Enter fullscreen mode Exit fullscreen mode

O meniOS executa binários no formato ELF, sigla para Executable and Linkable Format. Esse contêiner descreve cabeçalhos, seções e tabelas necessárias para carregar o programa na memória, incluindo as tabelas de símbolos que nm lê e as seções de código que objdump desmonta. Como o ELF também embute as extensões de depuração DWARF geradas pelo compilador, conseguimos cruzar endereços de instruções com arquivos de origem e, a partir disso, chegar rapidamente à linha com defeito.

Enquanto não há suporte a bibliotecas compartilhadas (.so), cada aplicativo do meniOS é entregue como um executável ELF independente. Compilo esses binários na máquina host e copio o resultado para a mesma imagem de disco utilizada no boot do sistema, mantendo o fluxo de build simples e previsível.

2. Reproduza o crash e capture o endereço

Executando o programa no meniOS, ou no ambiente host caso esteja testando localmente, o kernel interrompe o processo assim que detecta o acesso inválido:

Dereferencing freed pointer...
  User page fault at 0x0000555555555000 (present=yes write=yes), terminating pid=17
proc_exit: Process init/use_after_free exited with status 11
Enter fullscreen mode Exit fullscreen mode

Quando o crash é coletado via console serial do meniOS, o log inclui ainda o contexto de sistema de chamadas. Logo antes da falha, a saída típica de write(1, …) registra o ponto para o qual a execução retornaria:

syscall_dispatch: pid=17 number=1 rip=40112f cs=3b rsp=bfdfd0
syscall_dispatch: post-handler rip=401168 cs=3b rsp=bfdf10 rax=32
mosh: process terminated by signal 11
Enter fullscreen mode Exit fullscreen mode

Anote dois dados essenciais: rip=0x401168, que aponta para a instrução que falhou, e o nome do binário (use_after_free), porque é nele que vou procurar a origem.

3. Descubra o símbolo com nm

Comece localizando qual função cobre o endereço problemático. O nm lista a tabela de símbolos do executável, indicando o intervalo de cada função.

nm -an use_after_free | grep -i main
Enter fullscreen mode Exit fullscreen mode

Saída típica:

00000000004010f0 t _start
0000000000401120 T main
Enter fullscreen mode Exit fullscreen mode

main começa em 0x401120. Como 0x401168 ainda está próximo, é razoável assumir que a falha ocorreu dentro dessa função, mas é prudente confirmar.

4. Navegue no assembly com objdump

O objdump permite correlacionar instruções com o código-fonte. Use as opções --source e --line-numbers para misturar C e assembly; limite a saída a um trecho curto com sed.

objdump -d --no-show-raw-insn --source --line-numbers use_after_free \
  | sed -n '35,60p'
Enter fullscreen mode Exit fullscreen mode

Trecho relevante:

use_after_free.c:16
    *ptr = 'A';  // Use-after-free: provoca crash
  401164:  mov    -0x8(%rbp),%rax
  401168:  movb   $0x41,(%rax)          ; 0x41 == 'A'

use_after_free.c:17
    printf("Value: %c\n", *ptr);
Enter fullscreen mode Exit fullscreen mode

Isso confirma visualmente: a instrução em 0x401168 é exatamente a escrita com 'A'. Se o log apontar um endereço alguns bytes adiante, use o mesmo procedimento para encontrar a linha correspondente.

5. Resolver linha e arquivo com Python + pyelftools

As tabelas DWARF do executável guardam, para cada unidade de compilação, a sequência de endereços e as respectivas linhas do código-fonte. Para automatizar o mapeamento de endereços, um script curto em Python consulta esse material. Esse procedimento economiza tempo quando coleto crashes em lote ou quando quero integrar a análise em pipelines de CI.

#!/usr/bin/env python3

import os
import sys
from elftools.elf.elffile import ELFFile

def lookup(addr, path):
    with open(path, "rb") as f:
        elf = ELFFile(f)
        dwarf = elf.get_dwarf_info()
        for cu in dwarf.iter_CUs():
            lineprog = dwarf.line_program_for_CU(cu)
            prev = None
            for entry in lineprog.get_entries():
                if entry.state is None:
                    continue
                state = entry.state
                if state.end_sequence:
                    prev = None
                    continue
                if prev and prev.address <= addr < state.address:
                    file_entry = lineprog["file_entry"][prev.file - 1]
                    comp_dir_attr = cu.get_top_DIE().attributes.get("DW_AT_comp_dir")
                    comp_dir = ""
                    if comp_dir_attr:
                        comp_dir = comp_dir_attr.value.decode()
                    directory = comp_dir
                    if file_entry.dir_index not in (0, None):
                        include_dirs = lineprog["include_directory"]
                        directory = os.path.join(
                            comp_dir,
                            include_dirs[file_entry.dir_index - 1].decode(),
                        )
                    filename = file_entry.name.decode()
                    full_path = os.path.join(directory, filename) if directory else filename
                    return full_path, prev.line
                prev = state
            if prev and prev.address <= addr:
                file_entry = lineprog["file_entry"][prev.file - 1]
                comp_dir_attr = cu.get_top_DIE().attributes.get("DW_AT_comp_dir")
                comp_dir = comp_dir_attr.value.decode() if comp_dir_attr else ""
                directory = comp_dir
                if file_entry.dir_index not in (0, None):
                    include_dirs = lineprog["include_directory"]
                    directory = os.path.join(
                        comp_dir,
                        include_dirs[file_entry.dir_index - 1].decode(),
                    )
                filename = file_entry.name.decode()
                full_path = os.path.join(directory, filename) if directory else filename
                return full_path, prev.line
    return None, None

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print(f"Uso: {sys.argv[0]} <ELF> <endereco_hex>", file=sys.stderr)
        sys.exit(1)
    binary, raw_addr = sys.argv[1], sys.argv[2]
    address = int(raw_addr, 16)
    src, line = lookup(address, binary)
    if src is None:
        print("Endereço não encontrado.")
        sys.exit(2)
    print(f"{src}:{line}")
Enter fullscreen mode Exit fullscreen mode

Execute assim:

./resolve_addr.py use_after_free 0x401168
Enter fullscreen mode Exit fullscreen mode

Saída:

/path/para/use_after_free.c:16
Enter fullscreen mode Exit fullscreen mode

Integre o script ao seu fluxo de crash dumps no meniOS: basta salvar o endereço de rip e, depois, usar a ferramenta para apontar diretamente para a linha culpada.

6. Confirme a correção

Depois de identificar a origem, ajuste o código para evitar o uso após free. Um fix simples é atrasar a chamada de free ou zerar o ponteiro antes de qualquer acesso:

printf("Dereferencing freed pointer...\n");
ptr[0] = 'A';
printf("Value: %c\n", ptr[0]);
free(ptr);
Enter fullscreen mode Exit fullscreen mode

Recompile, execute e confirme que o crash desapareceu. Aproveite para rodar o binário antigo novamente e validar que seu processo de depuração continua funcionando.

Dicas extras

Garanta que o console serial do meniOS esteja ligado durante os testes. Como o sistema roda hoje em emuladores QEMU ou Bochs, redireciono a porta serial primária para com1.log (ou com1bochs.log, no caso do Bochs); esse console funciona como uma janela de logs do kernel, exibindo tudo o que o sistema imprime por kprintf, incluindo o endereço de instrução, o PID e a syscall ativa no momento do crash; como cada serviço roda em um executável ELF independente, repita o procedimento com o binário correspondente na imagem do meniOS; se quiser detectar problemas dessa classe automaticamente em paralelo no host, compile versões instrumentadas com AddressSanitizer e execute-as no Linux ou no macOS, onde o runtime já está disponível.

Seguindo esses passos você transforma um crash misterioso em uma linha específica do código-fonte.

Achei importante documentar isso aqui para, caso eu pare novamente de brincar com isso, ao voltar eu consiga me virar com certa rapidez. E também para que você, leitor(a) se sinta convidado(a) a participar desse desenvolvimento.

Top comments (0)