Jump to content
  • Construindo seu debugger - Parte 3: ptrace

       (2 reviews)

    anderson_leite

    Olá, já faz um bom tempo desde do ultimo artigo sobre a construção de debuggers mas, sem mais delongas, vamos dar continuidade a esta série! 😀 

    Neste artigo iremos falar um pouco sobre uma chamada de sistema que é capaz de controlar quase todos os aspectos de um processo: a syscall PTRACE (process trace). Antes de continuarmos, vale ressaltar que todo o código utilizado neste artigo está disponível no repositório do Github.

    De acordo com o manual do Linux (man ptrace), a syscall ptrace é definida assim:

    "A syscall ptrace provê meios para que um processo (denominado "tracer") possa observar, controlar a execução de um outro processo (denominado "tracee"), examinar e modificar a memória e registradores do "tracee". É primariamente utilizado para a implementação de 'breakpoint debugging' e para rastreamento de syscalls".

    Em outras palavras, podemos utilizar a ptrace para controlar um outro processo sobre o qual termos permissões sobre!

    Por exemplo, execute:

    strace /bin/ls

    O comando "strace" acima, é utilizado para que se possa rastrear todas as syscalls que um programa realiza. Vale lembrar que toda a técnica utilizada para o rastreamento de syscalls envolve o conteúdo abordado nos artigos anteriores, então é de suma importância que você tenha lido (ou saiba) o primeiro artigo sobre Sinais e o segundo sobre Forks.

    Antes de começar a rastrear um dado comando, o strace precisa ter controle total sobre a execução do processo alvo, para isso é feito um fork do processo em questão e o mesmo é "traceado". Voltaremos neste assunto em breve.

    A wrapper da ptrace é definida em <sys/ptrace.h> e tem o seguinte protótipo:

    #include <sys/ptrace.h>
    
      long ptrace(enum __ptrace_request request, pid_t pid,
                  void *addr, void *data);

    Onde o primeiro argumento request é um enum onde cada valor define uma ação em cima do "tracee", tais como TRACEME, GETEREGS, SETREGS e etc. O segundo argumento, pid, é o PID (Process Identification) do processo que queremos "tracear", o terceiro argumento addr é um endereço para alguma interação que a ser realizada da memória do processo "traceado" e o quarto e último argumento data é algum tipo de dado passado para o processo.

    Agora que você ja conhece o formato desta syscall, vamos fazer um pequeno breakdown do comando "strace".

    Execute:

    strace strace /bin/ls 2>&1 | grep -A2 clone

    Por mais bizarro que o comando acima pareça, o que vamos fazer aqui é rastrear todas as syscalls que o strace faz usando o próprio strace! Como a saída padrão do strace não é o stdout (dê uma lida em standart streams, caso esteja confuso) então é primeiro redirecionar a saída de erro para a saída padrão, para que seja possível rodar o grep no que queremos.

    Estamos buscando aqui, alguma chamada a syscall clone, que é sempre chamada quando é feito um fork. A chamada à ptrace vem logo em seguida:

    clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f7c4aa8ea10) = 16203
    ptrace(PTRACE_SEIZE, 16203, NULL, 0)    = 0

    Nesse caso, o strace cria um processo filho e em seguida usa o ptrace com o argumento SEIZE para iniciar o rastreamento (tracing) de um processo sem interrompê-lo, como analisaremos em seguida. Dessa maneira o strace é capaz de interceptar cada chamada de sistema feita pelo processo!

    Dê uma olhada no comando ltrace, que diferente do strace, rastreia todas as chamadas à bibliotecas (libraries trace) e tente fazer o mesmo que fizemos acima!

    Algumas ações notáveis que podemos fazer com a ptrace:

    • PTRACE_PEEKTEXT, PTRACE_PEEKDATA
      • Ler uma word em um dado endereço.
    • PTRACE_POKETEXT, PTRACE_POKEDATA
      • Copiar uma word para um determinado endereço (injete dados na memória).
    • PTRACE_GETREGS
      • Ler os registradores de um processo, que será guardado na struct user_regs_struct em <sys/user.h>.
    • PTRACE_SETREGS
      • Escrever nos registradores de um processo (também no formato da struct acima).

    Execute "man ptrace" para uma abordagem mais detalhadas de todos os valores disponíveis. 👍

     

    Implementando um simples tracer

    Agora que já temos uma base de forks e uma ideia de como o ptrace funciona, podemos unificar os dois e tenho certeza que o ptrace irá ficar mais claro. A partir de agora ele é fundamental para a implementação do nosso debugger.

    O primeiro passo é definir o escopo de como será feito o nosso "tracer": vamos rastrear um processo que já esta sendo executado ou vamos criar um novo? Para o nosso debugger, iremos apenas criar um fork e trocar sua imagem de execução para a do programa que queremos debugar, usando uma das funções da família exec.

    Primeiro vamos usar a função execl, que faz parte do leque de funções exec (man 3 exec) que trocam a imagem do nosso processo por outra, ou seja, o nosso programa é realmente trocado por outro em uma execução.

    A função execl é definida como:

    #include <unistd.h>
      
    int execl(const char *pathname, const char *arg, ...
                           /* (char  *) NULL */);

    Onde o primeiro argumento pathname é caminho completo do nosso executável alvo e os demais argumentos, que podem ser vários, são os argumentos para o programa que será executado.

    Para seguir um padrão, o primeiro argumento que geralmente colocamos é o caminho do programa em questão (lembrem que no array argv a posição 0 guarda o nome do programa em si), o resto dos argumentos são opcionais e seguem no modelo de lista de argumentos que são delimitados por um argumento NULL, que geralmente usamos para finalizar a lista.

    Agora considere o seguinte exemplo:

    #include <unistd.h>
    #include <stdio.h>
    
    
    int main(int argc, char* const* argv)
    {
    	if (argc < 3) {
    		printf("Usage: %s <command> <args>\n", argv[0]);
    		return 1;
    	}
    	
    	const char* command = argv[1];	
    	char* const* args = &argv[1];
    
    	printf("First arg => %s\n", args[0]);
    	execv(command,  args);
    	puts("Continua?\n");
    
    	return 0;
    }

    Compile com

    $ gcc -o exec exec.c
    $ ./exec /bin/ls -lah

    Este programa bem simples demonstra como a exec funciona.

    O que acabamos de criar aqui foi uma espécie de wrapper para qualquer comando: ele irá pegar o nome do comando e os seus respectivos argumentos e trocar sua execução atual pela a que você especificou.

    Note também a string "Continue?" que deveria ser impressa na tela. Esta nunca será impressa pois o nosso programa virou de fato, outro.

    Interessante, não? Usando um pouco de criatividade, podemos criar novos processos filhos combinando forks + exec, ou seja, criamos um fork do nosso processo e trocamos sua imagem por outra! Dessa maneira, por exemplo, temos total controle sobre o comando ls.

    Modificando um pouco o código acima e seguindo a ideia de forks, temos:

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/ptrace.h>
    #include <unistd.h>
    
    int main(int argc, char* const* argv)
    {
    	if (argc < 3) {
    		printf("Usage: %s <command> <args>\n", argv[0]);
    		return 1;
    	}
    	
    	const char* command = argv[1];	
    	char* const* args = &argv[1];
    	pid_t child_pid = fork();
    
    	// Neste ponto, todas as variaveis sao copiadas para o nosso fork
    	// o fork NAO recebe as mesmas variaveis, apenas uma cópia ;)	
    	if (!child_pid) {
    		// Hora de transformar nosso fork em outro programa
    		ptrace(PTRACE_TRACEME, NULL, NULL, NULL);
    		execv(command, args);
    	}	
    	
    	char in;
    	do {
    		puts("Iniciar processo ? [y/n]: ");
    		in = getchar();
    	} while (in != 'y');
    
    	ptrace(PTRACE_CONT, child_pid, NULL, NULL);
    	
    	return 0;
    }

    Compile

    $ gcc -o fork_exec fork_exec.
    $ ./fork_exec /bin/ls

    O programa acima realiza os primeiros passos do nosso tracer: é passado o caminho de um programa e os argumentos para o mesmo. Com isso criamos um fork e usamos o ptrace no própio fork com o argumento TRACEME. Este parâmetro indica que o este processo será "traced" pelo seu processo pai. Em seguida trocamos a nossa execução para o nosso programa alvo. Neste momento temos total controle sobre a execução, no exemplo acima, do comando ls.

    Quando um processo inicia sua execução com TRACEME + exec, o mesmo recebe um sinal de interrupção (SIGTRAP) até que o seu processo pai indique que ele deve continuar sua execução. Por isso, o nosso processo pai, que retém o PID do processo filho, usa o ptrace com o argumento CONT para que seja enviado o signal para dar continuidade de execução.

    E depois?

    Agora toda a comunicação entre os processos pai e o filho se dará via sinais e usaremos a syscall wait constantemente.

    Lembra que definimos acima algumas funções que podemos usar em conjunto com a ptrace? Para já irmos adiantando alguns artigos, vamos fazer um programa que mostra o estado dos registradores para um processo, passo a passo. Vamos usar dois parâmetros para a ptrace: GETREGS e STEP. Segue o código:

    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    
    #include <unistd.h>
    
    #include <sys/types.h>
    #include <sys/ptrace.h>
    #include <sys/user.h>
    #include <sys/wait.h>
    
    
    void display_regs(struct user_regs_struct* regs)
    {
        printf("RIP: 0x%x\n", regs->rip);
        printf("RBP: 0x%x\n", regs->rbp);
        printf("RSP: 0x%x\n", regs->rsp);
    }
    
    int main(int argc, char* const* argv)
    {
    
        if (argc < 2) {
            fprintf(stderr, "Usage: %s <program_path>\n", argv[0]);
            return 1;
        }
    
        const char* progName = argv[1];
        
        pid_t child = fork();
    
        if (!child) {
            ptrace(PTRACE_TRACEME, NULL, NULL, NULL);
            execl(progName, progName, NULL);
        }
        
        int status;
        int options = 0;
        int signal;
    
        // Estrutura que mantem os registradores
        struct user_regs_struct regs;
    
        /// Capta primeiro sinal de parada do filho
        waitpid(child, &status, 0);
        signal = WSTOPSIG(status);
    
        if (signal == SIGTRAP) {
            printf("Processo alvo %s esperando pronto para iniciar\n\n", progName);
        }
        
        printf("Executando 10 instruções\n");
        for (int i = 0; i < 10; ++i) {
            printf("Passo: %d\n", i+1);
            // Executa uma instrução
            ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
            // Espera sinal do filho
            waitpid(child, &status, 0);
            // Copia o estado atual dos registradores
            ptrace(PTRACE_GETREGS, child, NULL, &regs);
    
            // Função local para imprimir os principais registradores
            display_regs(&regs);
            puts("\n\n");
        }
    
        puts("Continuando...\n");
    
        /// Continua execução
        ptrace(PTRACE_CONT, child, NULL, NULL);
        waitpid(child, &status, 0);
    
        printf("Filho saiu com %d\n", WIFEXITED(status));
        return 0;
    }

     

    Compile:

    $ gcc -o tracer tracer.c
    $ ./tracer /bin/ls

    O código acima, além de criar e rastrear o processo, executa as primeiras 10 instruções e copia os estados dos registradores em cada passo. Logo após, continua a execução do programa normalmente.

    A estrutura user_reg_struct, definida em <sys/user.h>, contém todos os registradores que estão disponíveis na sua arquitetura. O código foi escrito considerando um ambiente x86-64.

    Com o estudo da ptrace, fechamos toda a introdução para construirmos o nosso debugger de fato, que vamos começar a desenvolver no próximo artigo, incialmente com capacidade de por breakpoints, imprimir o atual estado dos registrados e executar instrução por instrução do processo.

    Qualquer dúvida ou correção sinta-se livre de por nos comentários!  😁

    Links úteis:

    Até a próxima!

    Edited by Fernando Mercês



    User Feedback

    Join the conversation

    You can post now and register later. If you have an account, sign in now to post with your account.

    Guest

    Guest seijinz

       1 of 1 member found this review helpful 1 / 1 member

    sensacional!

    antigamente era difícil achar artigos assim em português, só tinha nas revistas phrack e 29A

     

    parabéns

    Share this review


    Link to review
    Josicler Leme Silva

      

    Muito bom otimo artigo com conteudo excelente😀

    Share this review


    Link to review

  • Similar Content

    • By l0gan
      Em todos os sistemas operacionais existem arquivos estruturados. Imagine um bloco segmentado em diversas partes e cada uma sendo uma área que armazena um tipo de dado específico (ex.: cabeçalho, área de código, área de dado inicializado, área de dado estático, área de dado não inicializado, área de referência de definições externas/outros objetos) servindo de referência para resguardar determinada classe de dado do respectivo arquivo binário para serem usados durante a execução do software ou até mesmo para fornecer informações que ajudam no processo de debugging. O conceito dessa formatação do arquivo (file format) é presente em todos os sistemas operacionais populares como Windows e Unix-like – isso inclui o macOS.
      Sabendo que o macOS é um sistema operacional do Unix é de se esperar que seus arquivos binários também tenham um “formato”, e estes são conhecidos como “arquivos de objeto do Mac” ou simplesmente Mach-O. Com esse entendimento o propósito deste artigo é dar uma visão técnica geral sobre a estrutura de arquivos construídos com este formato.
       
      Por que é importante conhecer o formato Mach-O?
      Algumas pessoas acreditam que o sistema operacional macOS (atualmente na versão denominada Catalina) é mais seguro que outros sistemas operacionais existentes pelo fato de não ser afetado por malware. Grande engano! Atualmente, vemos muitas publicações de vulnerabilidades relacionadas ao macOS, o que demonstra que este sistema operacional é, sim, um alvo em potencial.
      A grande pergunta que sempre faço é: “O que é mais interessante para um criminoso?”. Neste contexto, por “criminoso” me refiro à qualquer indivíduo que se utiliza dos meios eletrônicos para cometer fraudes. Deixando dispositivos móveis de lado, minha opinião é que hajam duas alternativas principais:
      Infectar o maior número de hosts possível (Windows ou Linux); Infectar um número mais restrito de hosts, porém algo mais direcionado a usuários, em geral, de cargos executivos, por exemplo: Diretores, CSO, etc. ou usuários domésticos, que muitas vezes permitem que softwares de fonte desconhecida sejam executados livremente em seu sistema operacional, ao desativar mecanismos de segurança como o gatekeeper; Se eu fosse um criminoso, optaria pela segunda opção; pois, atualmente o MacBook está se tornando cada vez mais popular.
      A imagem abaixo nos mostra a grande quantidade de arquivos Mach-O que foram analisados no VirusTotal nos últimos 7 dias desde a escrita deste artigo:

      Estes são os tipos de arquivos submetidos ao VirusTotal nos últimos 7 dias, obtidos em 25/julho/2020.
      Repare que a imagem não reporta arquivos infectados, mas sim os binários de cada tipo analisados. Bom, é perceptível que Mach-O está ganhando uma certa predominância hoje em dia, embora ainda seja bem inferior ao número do arquivo executável do Windows (Win32.exe).
      Apenas a título de curiosidade, o Mach-O tem um formato multi arquitetura, também conhecido como “fat binary” (conforme podemos ver na imagem abaixo)  aonde ele suporta 3 tipos de arquiteturas diferentes: x86_64, i386 e ppc7400:


      Aqui temos uma tabela com todos os “Magic Number” (valor numérico de texto usado para identificar um formato de arquivo) referentes à binários do tipo Mach-O:

      Ainda nesta linha de pesquisa, a técnica utilizada para gerar um binário suportado com várias plataformas (cross-compiling) é demonstrada na imagem  abaixo utilizando o compilador gcc:

      Usando o comando file do macOS vemos o tipo do arquivo e a arquitetura da plataforma que é suportado:

      O formato Mach-O de 64-bits
      Conforme observado anteriormente os binários Mach-O tem três regiões principais: Cabeçalho (Header); Comandos de carregamento (Load Commands); e, Dados (Data). A imagem abaixo representa a estrutura básica dos arquivos Mach-O 64-bit:

      No Header, encontram-se especificações gerais do binário, como seu magic number e a arquitetura alvo. Podemos encontrar este header em /usr/include/mach-o/loader.h:

      Conhecendo um pouco mais a estrutura do mach header podemos notar que ela é composta por 8 membros, cada um possuindo 4 bytes, ou seja: 4 * 8 = 32. Podemos ver os primeiros 32 bytes do binário, isto é, os valores do header abaixo:


      A região Load Commands especifica a estrutura lógica do arquivo e informações para que o binário possa ser carregado em memória e utilizado pelo sistema. Ela é composta por uma sequência de diversos modelos de commands numa tupla, por exemplo: “[load_command, specific_command_headers]” -- definindo as diferentes “seções lógicas” (commands) do binário. Cada command necessita de um ou mais cabeçalhos específicos, por isso, o segundo membro da tupla (specific_command_headers) pode variar de acordo com o tipo de command da mesma em questão:

      A título de exemplo, podemos ver também o command LC_SEGMENT_64  do cabeçalho do binário Mach-O:

      Neste mesmo contexto, podemos ver que as bibliotecas dinâmicas (dylib) "libncurses" e "libSystem" foram carregadas nos commands 12 e 13, que pertencem ao cabeçalho LC_LOAD_DYLIB.
      Deste jeito, o kernel consegue mapear as informações do executável para um espaço de memória que pode ser acessado simultaneamente por múltiplos programas na finalidade de prover comunicação entre eles ou para evitar compartilhamento de dados supérfluos – tal conceito é conhecido como memória compartilhada:

      Podemos ver também que a section __text contém o segmento __TEXT:

      E por fim temos a Data, onde temos instruções armazenadas logo após a região LOAD_Commands. Na região Data é que são definidas as permissões de leitura e gravação. Dependendo do tipo de Mach-O a maneira como essa região é usada varia.
      Quando analisamos um binário um dos primeiros pontos para o início dos testes é a inspeção do binário em um debugger a partir de seu entrypoint. No caso do deste Mach-O que estamos analisando percebemos que o código é colocado na seção __TEXT, as bibliotecas são carregadas no cabeçalho LC_LOAD_DYLIB e o LC_MAIN é o cabeçalho que aponta para o ponto de entrada (entrypoint) :


      Por enquanto já temos uma noção básica da estrutura dos binários Mach-O. Em um próximo artigo, iremos detalhar melhor este binário com foco em engenharia reversa para identificar ações de software malicioso.

      Para ajudar, recomendo a você artigos da H2HC Magazine sobre pilhas, registradores etc., dos colegas Fernando Mercês, Ygor da Rocha Parreira, Gabriel Negreiros, Filipe Balestra e Raphael Campos nas edições 7, 8, 9, 10 e 11. Outra referência para auxiliar nesta análise é o artigo "Montando sua máquina virtual para engenharia reversa em macOS"[11].

      Até lá!

      Referências
      Palestra H2HC University Vídeo Demo Malware Keranger Mach-O Vídeo Demo Crackme Mach-O Calling Conventions OS X ABI Mach-O File Format Revista H2HC ed7 Revista H2HC ed8 Revista H2HC ed9 Revista H2HC ed10 Revista H2HC ed11 Montando sua máquina virtual para engenharia reversa em macOS
    • By 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:
       

      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.
       

      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:
      ELF Interruptions Breakpoints Interrupt Descriptor Table Qualquer problema/erro por favor me chame 🙂
    • By Marioh
      Cá estava eu programando com o nasm, tentando (apenas tentando mesmo) reproduzir os wrappers de systemcall que existem na glibc, quando me deparei com o tamanho de um bináriozinho em assembly que só retorna um valor, um "hello world" no nasm, ali no canto do diretório. O binário tinha 4.2K, nada realmente muito pesado, mas para um programa que não utiliza nenhuma biblioteca e só retorna um valor me pareceu muito estranho.
      Código do programa:
      BITS 32 global _start _start: mov eax, 1 mov ebx, 10 int 0x80 Para compilar e testar:
      [mario@zrmt rivendell]$ nasm -f elf32 elrond.asm [mario@zrmt rivendell]$ ld -m elf_i386 -s elrond.o -o elrond [mario@zrmt rivendell]$ ./elrond [mario@zrmt rivendell]$ echo $? 10 Aqui vai o hexdump do binário:
      [mario@zrmt rivendell]$ hexdump -C elrond 00000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| 00000010 02 00 03 00 01 00 00 00 00 90 04 08 34 00 00 00 |............4...| 00000020 20 10 00 00 00 00 00 00 34 00 20 00 02 00 28 00 | .......4. ...(.| 00000030 03 00 02 00 01 00 00 00 00 00 00 00 00 80 04 08 |................| 00000040 00 80 04 08 74 00 00 00 74 00 00 00 04 00 00 00 |....t...t.......| 00000050 00 10 00 00 01 00 00 00 00 10 00 00 00 90 04 08 |................| 00000060 00 90 04 08 0c 00 00 00 0c 00 00 00 05 00 00 00 |................| 00000070 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00001000 b8 01 00 00 00 bb 2a 00 00 00 cd 80 00 2e 73 68 |......*.......sh| 00001010 73 74 72 74 61 62 00 2e 74 65 78 74 00 00 00 00 |strtab..text....| 00001020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00001040 00 00 00 00 00 00 00 00 0b 00 00 00 01 00 00 00 |................| 00001050 06 00 00 00 00 90 04 08 00 10 00 00 0c 00 00 00 |................| 00001060 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 00 |................| 00001070 01 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00 |................| 00001080 0c 10 00 00 11 00 00 00 00 00 00 00 00 00 00 00 |................| 00001090 01 00 00 00 00 00 00 00 |........| 00001098 Da pra perceber que de 0x72 à 0xfff todos os bytes são 0. Humm... suspeito. Não sou especialista e posso estar terrívelmente errado, mas não lembro dessa quantidade de zeros no manual do formato ELF. Se abrirmos o binário com o readelf veremos o seguinte:
      [mario@zrmt rivendell]$ readelf elrond -h ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x8049000 Start of program headers: 52 (bytes into file) Start of section headers: 4128 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 2 Size of section headers: 40 (bytes) Number of section headers: 3 Section header string table index: 2 Três Section Headers, dois Program Headers e mais um bando de coisa. Como não precisamos das seções para executar o programa irei ignorá-las por agora. Não precisamos das seções para executar o programa devido ao fato de que elas são feitas para auxiliar o linker no momento de construção do binário. Como o binário já está construído e nenhuma das seções representa objetos dinâmicos, elas podem ser ignoradas.
      Então vamos diminuir esse programa aí. Primeiramente, devemos descobrir o endereço base do programa, para isto, basta pegar o entrypoint (0x8049000) e diminuir o offset do Program Header que tem a flag de executável (que vai conter o devido código do programa). Lembrando que o entrypoint é composto pelo endereço base do programa (para ser mapeado em memória) + “endereço” (no arquivo) do primeiro byte que corresponde ao código executável. O que vamos fazer aqui é achar esse primeiro byte, que pode ser encontrado no Program Header, onde se tem a flag de executável que recebe o nome de p_offset. Vejamos o readelf -l:
      [mario@zrmt rivendell]$ readelf -l elrond Elf file type is EXEC (Executable file) Entry point 0x8049000 There are 2 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x08048000 0x08048000 0x00074 0x00074 R 0x1000 LOAD 0x001000 0x08049000 0x08049000 0x0000c 0x0000c R E 0x1000 Section to Segment mapping: Segment Sections... 00 01 .text Para ajudar: de acordo com o manual o campo p_offset é “O offset do início do arquivo onde o primeiro byte do segmento se encontra”. Como estamos lidando com um segmento executável esse primeiro byte vai ser o início do nosso código.
      Então dá para ver que o segundo Program Header (que possui a flag de executável) tem offset 0x001000! Então o endereço base é 0x08048000 (0x08049000 - 0x00001000) ! Já que temos o endereço base podemos excluir os zeros (caso contrário o programa ficaria quebrado e não iríamos conseguir analisá-lo com o readelf), alto lá! Apenas os inúteis! Mas quais são os inúteis ? Todos os que os Program Headers apontam, pois esses serão os  bytes do programa mapeados em memória, então vamos deixar eles lá. Vou usar o hyx como editor hexa, mas o hte também funciona.
      Após excluirmos todos os zeros entre 0x74 e 0x1000:
      [mario@zrmt rivendell]$ hyx elrond 0000> 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| 0010: 02 00 03 00 01 00 00 00 00 90 04 08 34 00 00 00 |............4...| 0020: 20 10 00 00 00 00 00 00 34 00 20 00 02 00 28 00 | .......4. ...(.| 0030: 03 00 02 00 01 00 00 00 00 00 00 00 00 80 04 08 |................| 0040: 00 80 04 08 74 00 00 00 74 00 00 00 04 00 00 00 |....t...t.......| 0050: 00 10 00 00 01 00 00 00 00 10 00 00 00 90 04 08 |................| 0060: 00 90 04 08 0c 00 00 00 0c 00 00 00 05 00 00 00 |................| 0070: 00 10 00 00 00 b8 01 00 00 00 bb 2a 00 00 00 cd |...........*....| 0080: 80 00 2e 73 68 73 74 72 74 61 62 00 2e 74 65 78 |...shstrtab..tex| 0090: 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |t...............| 00a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 0b 00 00 |................| 00c0: 00 01 00 00 00 06 00 00 00 00 90 04 08 00 10 00 |................| 00d0: 00 0c 00 00 00 00 00 00 00 00 00 00 00 10 00 00 |................| 00e0: 00 00 00 00 00 01 00 00 00 03 00 00 00 00 00 00 |................| 00f0: 00 00 00 00 00 0c 10 00 00 11 00 00 00 00 00 00 |................| 0100: 00 00 00 00 00 01 00 00 00 00 00 00 00 |.............| Ahh muito mais enxuto! Porém o bicho tá todo quebrado. Se executarmos:
      [mario@zrmt rivendell]$ ./elrond Bus error (core dumped) Um “Bus error” não é nada mais que uma tentativa de read ou write em um espaço de memória desalinhado. Como citado no manual os mapeamentos tem que ser alinhados com as páginas de memória, ou seja, 4KB.
      Vamos consertá-lo! Vamos ter que consertar: o entrypoint e o mapeamento do segundo Program Header, ou seja, seu endereço virtual, físico e seu offset. Como estamos alterando as posições dos segmentos (isto é, o nome oficial para o que um Program Header mapeia)  teremos que alterar seu mapeamento no arquivo junto com o entrypoint (que aponta para o primeiro byte de um segmento executável). Na verdade, o endereço físico pode ser ignorado, o manual cita que os “System V” ignoram endereços físicos de aplicações, mas iremos adicioná-los em prol da completude.
      Revisando... o entrypoint vai ser o endereço base mais o offset do segundo Program Header, e esse offset vai ser 0x75 (lembre-se que era 0x1000, mas com a retirada dos zeros entre 0x74 e 0x1000 efetivamente reduzimos o entrypoint em 0xFFF - 0x74 = 0xF8B,  logo, o entrypoint vai ser 0x1000 - 0xF8B = 0x75) então nosso entrypoint vai ser 0x08048075. Esse também vai ser o endereço virtual e o endereço físico do header.
      Então troquemos:
      O entrypoint no Header ELF por 0x08048075 O offset do section header por 0x00000075 Os endereços virtuais e físicos do segundo Program Header por 0x08048075 Agora mais do que nunca teremos que ter atenção. Saque seu editor de hexa preferido e lembre-se que estamos lidando com little endian. Vou usar o hyx, que é um editor hexa um pouco parecido com o vi:

      No terminal de cima temos o arquivo original sem os zeros, já no de baixo temos o arquivo já alterado.
      Para ajudar:
      Vermelho: Entrypoint Amarelo: Offset do Header Verde: Endereço Virtual do Header Azul: Endereço Físico do Header Agora se executarmos:
      [mario@zrmt rivendell]$ ./elrond [mario@zrmt rivendell]$ echo $? 10 Como disse lá em cima, não alterei as seções e nesse caso (binário já linkado e sem bibliotecas dinâmicas) elas não são importantes. Tente ler elas pra ver o que acontece.
      No fim passamos de 4.2k para ...
      [mario@zrmt rivendell]$ ls -lh elrond -rwxr-xr-x 1 mario mario 269 --- -- --:-- elrond 269!
      Achei que a galera poderia gostar dessa pequena aventura, acho bem interessante principalmente para aprender bem sobre o formato. Se gostarem tenho planos pra parte dois!
×
×
  • Create New...