Ir para conteúdo
  • Construindo seu debugger - Parte 4 (final): Implementação

       (1 análise)

    anderson_leite

    Já faz um bom tempo (quase 1 ano!) desde o último artigo da série de desenvolvimento de debuggers. Este é o último artigo da série e iremos finalmente criar nosso primeiro prototipo de debugger.

    A ideia aqui, é compilar tudo que foi ensinado nos artigos anteriores sobre Sinais, Forks e ptrace . Com isso, criaremos um simples tracer em C que irá receber um endereço como argumento e colocar um breakpoint no mesmo.

    Diagrama

    Antes vamos definir um pouco o escopo do nosso software:

     

    image.png.dfff5b52f31853b9445fff07335e5284.png

    O nosso tracer irá criar um fork e nesse fork será feita a chamada para a execv, que por sua vez irá trocar a imagem do atual processo (seu conteúdo) pela de outro processo, fazendo com que de fato vire outro processo. Já o nosso debugger, dentro de um loop, irá se comunicar via sinais com o processo filho.

    Breakpoints

    Existem dois tipos de breakpoints: software breakpoints e hardware breakpoints. Ambos servem para interromper a execução do nosso software em determinada instrução. Para que isso seja possível é necessário que a execução do processo seja interrompida na nossa CPU.

    Interrupções

    Quando ocorre algum evento no computador que precisa de um tratamento imediato, a CPU invoca uma interrupção. Cada evento desse contém uma ação especifica que nosso kernel irá lidar de alguma maneira e a estrutura responsável por salvar os valores e significados das mesmas é a Interrupt Descriptor Table.

     

    image.thumb.png.e98d3bb41f9a61d7ce737625572a41a1.png

    A imagem acima representa visualmente uma implementação desse vetor, onde cada posição (offset) contém uma struct associada e nela os valores necessários para lidar com isso. Você pode ter uma explicação mais detalhada aqui.

    Mas por que eu estou falando de tudo isso? Porque breakpoints nada mais são do que uma interrupção em um dado endereço que faz com que o processador pare a execução do seu programa.

    O valor que interrompe a CPU para um breakpoint é o 0x03. Vamos testar isto nesse pequeno bloco de código:

    main() {
        int x = 4; // Iniciando qualquer coisa
        __asm__(
            "int $0x03"
        );
    }

    A macro __asm__ permite que seja colocado o código direto em assembly, nesse caso, foi colocado o mnémonico INT, que cuida das interrupções com o valor 3 (offset comentado acima na IDT). Se você compilar e executar esse programa:

    ~ ./code                                                                  
    zsh: trace trap (core dumped)  ./code

    Nesse momento o trabalho de fazer o handle dessa interrupção é do nosso software. O que fizemos aqui foi implementar um software breakpoint. Agora vamos executar esse programa no gdb e não por breakpoint algum (dentro do gdb) e só executar:

    (gdb) r
    Starting program: /home/zlad/code
    
    Program received signal SIGTRAP, Trace/breakpoint trap.
    0x000055555555515f in main ()
    (gdb) disas
    Dump of assembler code for function main:
       0x0000555555555139 <+0>: push   %rbp
       0x000055555555513a <+1>: mov    %rsp,%rbp
       0x000055555555513d <+4>: sub    $0x10,%rsp
       0x0000555555555141 <+8>: movl   $0x2,-0x4(%rbp)
       0x0000555555555148 <+15>:    mov    -0x4(%rbp),%eax
       0x000055555555514b <+18>:    mov    %eax,%esi
       0x000055555555514d <+20>:    lea    0xeb0(%rip),%rdi
       0x0000555555555154 <+27>:    mov    $0x0,%eax
       0x0000555555555159 <+32>:    callq  0x555555555030 <printf@plt>
       0x000055555555515e <+37>:    int3  
    => 0x000055555555515f <+38>:    mov    $0x0,%eax
       0x0000555555555164 <+43>:    leaveq
       0x0000555555555165 <+44>:    retq  
    End of assembler dump.
    (gdb) 

    Veja que a nossa interrupção foi capturada pelo GDB, pois ele detectou um breakpoint trap e é exatamente isso que iremos fazer. Nosso tracer será capaz de detectar quando irá ocorrer um SIGTRAP, ou seja, um sinal que deve ser tratado por nosso sistema operacional.

    Finalmente implementando

    Vamos finalmente começar o nosso pequeno tracer, que será capaz colocar breakpoints, executar instrução por instrução e imprimir os registradores na tela!

    Para inserir a interrupção de breakpoint (int 3) não precisamos de muito, pois já existe um mnemónico para isso que é o int3 e que tem como valor 0xCC. Para inserir breakpoints precisamos de um endereço (que vá ser executado) e uma maneira de escrever nesse local na memória virtual do nosso processo.

    Já vimos anteriormente o ptracer e nele sabemos que temos alguns enums que podem ser passados como seu primeiro argumento. São eles o PEEK_DATA e o POKE_DATA, que servem para buscar algo na memória de um processo e escrever algo na memória de um processo, respectivamente. Segue a função que vamos usar para adicionar breakpoints no nosso tracer:

    uint64_t add_breakpoint(pid_t pid, uint64_t address)
    {
        uint64_t break_opcode = 0xCC;
        uint64_t code_at = ptrace(PTRACE_PEEKDATA, pid, address, NULL);
        uint64_t breakpoint_code = (code_at & ~0xFF) | break_opcode;
           
        ptrace(PTRACE_POKEDATA, pid, address, breakpoint_code);
       
        return code_at;
    }

    Respire fundo e vamos em partes, a ideia aqui é a seguinte:

    Dado o pid do nosso processo filho e um endereço de memória, vamos buscar o código que estava naquele local (code_at), salvar esse código (não só queremos adicionar um novo opcode, mas podemos futuramente querer executá-lo) e então vamos adicionar nossa instrução nos bytes menos significativos, ou seja, vamos executar ela primeiro.

    Usamos aqui uma variável de 64 bits por conta da arquitetura do meu sistema. Se você quiser tornar isto portável, é possível criar uma variável genérica baseada na arquitetura:

    #ifdef __i386__
    #define myvar uint32_t
    #else
    #define myvar uint64_t
    #endif

    Isso é opcional, mas caso você queira criar algo mais genérico, esse é o caminho.

    A operação bitwise que fizemos aqui também pode ser “nebulosa” para alguns, mas segue o equivalente de maneira mais “verbosa” e em python:

    >>> hex(0xdeadbeef & ~0xFF) # Mascarando byte menos significativo
    '0xdeadbe00'
    >>> hex(0xdeadbeef & ~0xFF | 0xCC) # Mascarando byte e adicionado opcode int3(0xCC)
    '0xdeadbecc'

    O que é feito aqui é uma jogada lógica. Vamos quebrar isso em passos:

    • Fazemos um AND com algo negado (0xFFFFFF00);
    • Fazemos um OR com o resultado que irá "preencher" o espaço vazio, visto que um valor OR 0 será sempre o valor com conteúdo;
    • No final mascaramos o último byte e colocamos nosso opcode;

    O nosso loop precisa executar enquanto nosso processo filho estiver sendo debugado. Em termos de estrutura de códigos vamos usar um laço que irá receber uma flag para sua execução:

    while (!WIFEXITED(status)) {
        // Our code
    }

    Caso você esteja perdido nessa função WIFEXITED, vale a pena dar uma olhada no artigo desta série sobre Forks. Agora é puramente uma questão de jogar com sinais e estruturar nosso código da maneira mais coesa possível, resumindo, pura programação ?

    Após nosso breakpoint ser definido em memória precisamos fazer o handling disso. Para isso usamos a função WSTOPSIG, que irá receber o status do nosso processo (que é atribuído na função wait) e irá nos dizer qual tipo de interrupção ocorreu:

    while (!WIFEXITED(status)) {
        wait(&status);
        signal = WSTPOPSIG(status);
    
        switch(signal) {
            case SIGTRAP:
                puts("We just hit a breakpoint!\n");
                display_process_info(pid);
                break;
        }
    }

    No momento que uma sigtrap for enviada para a gente podemos considerar que caímos no nosso breakpoint. Nesse momento, nosso processo filho está block (pois sofreu uma interrupção), esperando algum tipo de ação para continuar.

    A função display_process_info(pid) irá mostrar o atual estado dos nossos registrados, usando o enum PTRACE_GETREGS que recebe a struct regs (também já visto no artigo passado):

    void display_process_info(pid_t pid)
    {
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, pid, NULL, &regs);
       
        printf("Child %d Registers:\n", pid);
        printf("R15: 0x%x\n", regs.r15);
        printf("R14: 0x%x\n", regs.r14);
        printf("R12: 0x%x\n", regs.r12);
        printf("R11: 0x%x\n", regs.r11);
        printf("R10: 0x%x\n", regs.r10);
        printf("RBP: 0x%x\n", regs.rbp);
        printf("RAX: 0x%x\n", regs.rax);
        printf("RCX: 0x%x\n", regs.rcx);
        printf("RDX: 0x%x\n", regs.rdx);
        printf("RSI: 0x%x\n", regs.rsi);
        printf("RDI: 0x%x\n", regs.rdi);
        printf("RIP: 0x%x\n", regs.rip);
        printf("CS:  0x%x\n", regs.cs);
        printf("EFGLAS: 0x%x\n", regs.eflags);
    }

    O código do nosso loop final fica da seguinte forma:

    while (!WIFEXITED(status)) {
        signal = WSTOPSIG(status);
    
        switch(signal) {
            case SIGTRAP:
                puts("We just hit a breakpoint!\n");
                break;
        }
       
        printf("> ");
        fgets(input, 100, stdin);
        if (!strcmp(input, "infor\n")) {
            display_process_info(pid);
        } else if (!strcmp(input, "continue\n")) {
            ptrace(PTRACE_CONT, pid, NULL, NULL);
            wait(&status);
        }
    
    }
    
    printf("Child %d finished...\n", pid);
    return 0;
    }

    Não iremos focar em implementação pela parte da interação do úsuario pois não é o foco dessa série de artigos. Tentei ser o mais “verboso” possível no quesito UX ?. No projeto original usei a lib linenoise para criar uma shell interativa, mas isso fica para sua imaginação.

    Vamos executar:

    ~/.../mentebinaria/artigos >>> ./tracer hello 0x401122 #<== Endereco da main                  [130]
    Forking...
    
    Adding breakpoint on 0x401122
    We just hit a breakpoint!
    
    > infor
    Child 705594 Registers:
    R15: 0x0
    R14: 0x0
    R12: 0x401050
    R11: 0x2
    R10: 0x7
    RBP: 0x0
    RAX: 0x401122
    RCX: 0x225d7578
    RDX: 0x19a402c8
    RSI: 0x19a402b8
    RDI: 0x1
    RIP: 0x401123
    CS:  0x33
    EFGLAS: 0x246
    We just hit a breakpoint!
    
    > continue
    Hello world
    Child 705594 finished...

    A ideia aqui não é criar tudo para você. A partir de agora, com o conhecimento básico dessa série de artigos, é possível criar o seu próprio debugger ou ferramenta semelhante. Deixo aqui o meu projeto, sdebugger, que foi fruto do meu estudo sobre este tema. Todo conhecimento base que eu passei aqui foi o necessário para criar este projetinho.

    Agradeço a toda turma do Mente Binária pelo apoio e desculpa à todos pela demora para finalizar essa série de artigos. Tenho várias ideias para artigos futuros, então vamos nos ver em breve!

    Links úteis:

    Qualquer problema/erro por favor me chame ?


    Revisão: Leandro Fróes

    Feedback do Usuário

    Participe da conversa

    Você pode postar agora e se cadastrar mais tarde. Se você tem uma conta, faça o login para postar com sua conta.
    Nota: Sua postagem exigirá aprovação do moderador antes de ficar visível.

    Visitante

    • Isso não será mostrado para outros usuários.
    • Adicionar um análise...

      ×   Você colou conteúdo com formatação.   Remover formatação

        Apenas 75 emojis são permitidos.

      ×   Seu link foi automaticamente incorporado.   Mostrar como link

      ×   Seu conteúdo anterior foi restaurado.   Limpar o editor

      ×   Não é possível colar imagens diretamente. Carregar ou inserir imagens do URL.



  • Conteúdo Similar

×
×
  • Criar Novo...