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; } 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 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 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 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 Saída típica:
00000000004010f0 t _start 0000000000401120 T main 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' 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); 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}") Execute assim:
./resolve_addr.py use_after_free 0x401168 Saída:
/path/para/use_after_free.c:16 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); 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)