Ir para conteúdo
  • Como um binário ELF consegue chamar uma função externa?

       (1 análise)

    Fabiano Furtado

    Introdução
     

    Escrevi este artigo pois me deparei recentemente, durante um CTF, com uma técnica Return Oriented Programming (ROP) que não conhecia, chamada de ret2dl ou ret2dl_resolve, e precisei entender como esse processo de chamada de uma função externa funciona (pelo menos, o seu início!).

    Este texto foi baseado nestes excelentes artigos em inglês https://ypl.coffee/dl-resolve/ e https://syst3mfailure.io/ret2dl_resolve, mas complementei o assunto com algumas informações extras que achei relevante. Deixo também como referência um paper de 2015 muito bem escrito da USENIX  para estudos. Recomendo a sua leitura.

    Para alinharmos sobre o que foi usado, este artigo tem como base a arquitetura AMD x86 64 bits, sistema operacional Linux, a função externa puts(), e o programa helloworld.c compilado:

    $ cat <<'EOF' > helloworld.c  # usando o "Here Documents" do bash para criarmos o arquivo
    // helloworld.c
    // Compilar com: gcc -Wall -O2 -Wl,-z,lazy helloworld.c -o helloworld
    //                                     *---> "lazy-binding"
    #include <stdio.h>
    int main(void) {
      puts("Hello World!"); // função externa: o código da "puts()" está localizado na LIBC
      return 0;
    }
    EOF

    Do ponto de vista da funcionalidade, este código é BEM simples pois "só" imprime "Hello World" na tela. Entretanto, do ponto de vista do sistema operacional, o processo é bem mais complexo (deixo aqui um link interessante sobre o assunto: https://lwn.net/Articles/631631/). Como será visto, ao se chamar a puts(), acontecem várias chamadas de funções externas, com o objetivo de se descobrir qual é o endereço dessa função.

    O processo de chamada de uma função externa é complexo, mas por que isso acontece?

    Resumidamente, por causa das proteções que um binário ELF moderno possui. Atualmente, há vários métodos para se proteger binários ELF.

    Podemos compilar o programa usando o PIE (Executável Independente de Posição), onde todas as suas dependências (normalmente shared objects) são carregadas em locais aleatórios na memória virtual sempre que o aplicativo é executado.

    Isso acontece graças a uma implementação do kernel chamada ASLR (Address Space Layout Randomization) e, teoricamente, fica difícil de se prever em qual endereço de memória virtual o binário foi carregado, principalmente em programas compilados em 64 bits, devido ao seu grande espaço de endereçamento.

    Tendo em mente este conceito de randomização de endereços, ao se executar um binário, a LIBC é carregada em memória e o endereço de suas funções são descobertos em tempo de execução. Essa descoberta pode acontecer, basicamente, de duas maneiras, dependendo de como o binário foi compilado (na verdade, essa é uma característica do linker, mais especificamente do GNU ld, e não do compilador GCC):

    1. Partial RELRO: O endereço da função externa é descoberto no momento de se chamar a própria função, em um processo chamado de lazy-binding ou, traduzindo, "amarração tardia" (link editado com -Wl,-z,lazy). Neste cenário, a inicialização do binário é mais rápida e a parte não-PLT da seção GOT (.got) é read-only, mas a seção .got.plt é writable. Vou explicar sobre essas seções mais adiante.
    2. Full RELRO: Todos os endereços das funções externas são descobertos ao se iniciar o binário (link editado com -Wl,-z,now), levando a um processo de inicialização mais demorado. Todo o GOT (.got e .got.plt) é marcado como somente read-only.

    Mas o que é RELRO?

    Relocation Read-Only (RELRO) é uma técnica genérica de mitigação de exploração para proteger as seções de dados de um binário ou processo ELF.

    Neste processo de descoberta do endereço da função externa, as seções .got e .got.plt são usadas?

    Sim! Essas duas seções são as mais importantes para o processo e trabalham em conjunto no processo de lazy-binding: o Procedure Linkage Table (PLT)  e o Global Offset Table (GOT). A seção .plt é sempre read-only e contém código executável, responsável por iniciar o processo de descoberta do endereço da função externa e fazer a sua chamada. 

    A GOT não contém código executável e é responsável por armazenar os endereços das funções externas. Abaixo mostrarei o dump de cada uma. Cada função externa que foi declarada no binário possui uma área na seção got.plt, para que seu endereço virtual seja armazenado, e uma área na .plt, onde será chamada (através de, literalmente, um jump).

     A seção <nome da função>@plt é sempre usada quando as funções externas precisam ser chamadas pelo binário (você entenderá isso no dump). Neste exemplo, temos a puts@plt.

    Nosso binário foi link editado com o modo lazy-binding, ou seja, possui Parcial RELRO. Seguem algumas informações sobre:

    $ file helloworld
    helloworld: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ccf36bd5d7298575faf6c5d7612b07306264be7d, for GNU/Linux 4.4.0, not stripped
    
    $ ldd helloworld    # como podem perceber, nosso binário possui 3 dependências
        linux-vdso.so.1 (0x00007ffe78cb2000)
        libc.so.6 => /usr/lib/libc.so.6 (0x00007efd5a156000)
        /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007efd5a38f000)
    
    $ checksec -f helloworld
    [*] '/tmp/helloworld'
        Arch:     amd64-64-little
        RELRO:    Partial RELRO
        Stack:    No canary found
        NX:       NX enabled
        PIE:      PIE enabled

    Iniciando a chamada da "puts()"
     

    Por onde começamos? Vamos fazer um objdump em nosso binário, mais especificamente na chamada da puts( de dentro da main():

    $ objdump -dM intel helloworld    # obs: saída com adaptações
    ...
    0000000000001040 <main>:
        0x1040:     48 83 ec 08             sub    rsp,0x8
        0x1044:     48 8d 3d b9 0f 00 00    lea    rdi,[addr of "Hello World!"] # RDI aponta para "Hello World!\n"
        0x104b:     e8 e0 ff ff ff          call   0x1030                       # call <puts@plt>
    ...

    Obs: Como o binário está compilado com o PIE ativo, a saída do objdump mostra apenas os offsets (deslocamentos) das instruções.

    Para se calcular o endereço virtual onde a função foi carregada, pega-se o endereço base do binário em execução (que é dinâmico devido ao PIE) e soma-se esse offset que está associado à função. No texto, algumas vezes descrevo endereço, mas, na verdade, estou querendo dizer offset. Enfim...

    Exemplo: 0x555555554000 (endereço base dinâmico) + 0x1040 (offset da main()) = 0x555555555040 (endereço virtual da main())

    Com o dump, verificamos que a instrução call puts()(0x104b) é feita para um código que está localizado na seção .plt, mais especificamente call <puts@plt>, e este código da .plt é responsável por chamar a puts()na LIBC.

    Para entendimento, seguem os códigos da PLT e da GOT para a função puts():

    0000000000001030 <puts@plt>:            # código responsável por chamar a "puts()" na LIBC
        0x1030:     ff 25 e2 2f 00 00       jmp    QWORD PTR [0x4018]    # <puts@GLIBC_2.2.5>
        0x1036:     68 00 00 00 00          push   0x0
        0x103b:     e9 e0 ff ff ff          jmp    0x1020 <_init+0x20>   # jump para o início da ".plt"
    0000000000004000 <_GLOBAL_OFFSET_TABLE_>:
    ...
        0x4018:     36 10 00 00 00 00 00 00    # "0x1036" em "little-endian", apontando inicialmente para a "push" da "puts@plt".

    A call 0x1030 # <puts@plt> feita no endereço 0x104b da main()executa a instrução jmp QWORD PTR [0x4018]. O endereço apontado por [0x4018], resulta em 0x1036 (este código tem o mesmo efeito que jmp 0x1036), que está na própria puts@plt.

    Como esta é a primeira chamada para a puts(), ainda não sabemos o verdadeiro endereço desta função na LIBC (lazy-binding) e o compilador inicia a GOT (em 0x4018) com o endereço 0x1036, que pertence a puts@plt (veja o comentário feito em 0x4018 acima).

    O objetivo é descobrir o endereço da puts() dentro do shared object libc.so.6, para que o mesmo seja gravado na GOT no endereço 0x4018, substituindo o atual endereço 0x1036 pelo endereço virtual dessa função externa.

    Quando o código em 0x1036 é executado, o mesmo dá um push 0x0 para a stack (segundo parâmetro reloc_index da função _dl_runtime_resolve() - explicarei essa função e parâmetro depois), e depois dá um jmp 0x1020 para o início da PLT, onde fica a call <_dl_runtime_resolve@plt>.

    0000000000001020 <puts@plt-0x10>: # início da PLT "call <_dl_runtime_resolve@plt>"
        0x1020:     ff 35 e2 2f 00 00       push   QWORD PTR [0x4008]      # 0x4008: "link_map"  <GOT+0x8>
        0x1026:     ff 25 e4 2f 00 00       jmp    QWORD PTR [0x4010]      # 0x4010: "_dl_runtime_resolve()"  <GOT+0x10>

    Mas que função é essa _dl_runtime_resolve()?

    No meu caso, a PLT chama a função _dl_runtime_resolve_xsavec(), responsável por descobrir, de alguma forma, o endereço da puts() na LIBC (para efeitos didáticos, vou considerar o nome da _dl_runtime_resolve_xsavec() como _dl_runtime_resolve()).

    A instrução push [0x4008], localizada no endereço 0x1020, coloca o primeiro parâmetro link_map da função _dl_runtime_resolve() na stack. Por curiosidade, este endereço do struct "link_map" pertence ao espaço de endereçamento do dynamic linker/loader /lib64/ld-linux-x86-64.so.2, bem como a função _dl_runtime_resolve()(eu achava que esta _dl_runtime_resolve()era uma função da LIBC, mas descobri que não era usando o GDB durante a depuração, através do comando vmmap. Ele mostra todas as libs carregadas e o mapa de endereçamento virtual. (BEM útil!). Com estes dois parâmetros configurados na stack, podemos chamar a função _dl_runtime_resolve(link_map, reloc_index).

    gef➤  vmmap
    [ Legend:  Code | Heap | Stack ]
    Start              End                Offset             Perm Path
    0x0000555555554000 0x0000555555555000 0x0000000000000000 r-- /tmp/helloworld
    0x0000555555555000 0x0000555555556000 0x0000000000001000 r-x /tmp/helloworld
    0x0000555555556000 0x0000555555557000 0x0000000000002000 r-- /tmp/helloworld
    0x0000555555557000 0x0000555555558000 0x0000000000002000 r-- /tmp/helloworld
    0x0000555555558000 0x0000555555559000 0x0000000000003000 rw- /tmp/helloworld
    0x0000555555559000 0x000055555557a000 0x0000000000000000 rw- [heap]
    0x00007ffff7d8a000 0x00007ffff7d8c000 0x0000000000000000 rw- 
    0x00007ffff7d8c000 0x00007ffff7db8000 0x0000000000000000 r-- /usr/lib/libc.so.6
    0x00007ffff7db8000 0x00007ffff7f2e000 0x000000000002c000 r-x /usr/lib/libc.so.6
    0x00007ffff7f2e000 0x00007ffff7f82000 0x00000000001a2000 r-- /usr/lib/libc.so.6
    0x00007ffff7f82000 0x00007ffff7f83000 0x00000000001f6000 --- /usr/lib/libc.so.6
    0x00007ffff7f83000 0x00007ffff7f86000 0x00000000001f6000 r-- /usr/lib/libc.so.6
    0x00007ffff7f86000 0x00007ffff7f89000 0x00000000001f9000 rw- /usr/lib/libc.so.6
    0x00007ffff7f89000 0x00007ffff7f98000 0x0000000000000000 rw- 
    0x00007ffff7fc0000 0x00007ffff7fc4000 0x0000000000000000 r-- [vvar]
    0x00007ffff7fc4000 0x00007ffff7fc6000 0x0000000000000000 r-x [vdso]
    0x00007ffff7fc6000 0x00007ffff7fc8000 0x0000000000000000 r-- /usr/lib/ld-linux-x86-64.so.2
    0x00007ffff7fc8000 0x00007ffff7fef000 0x0000000000002000 r-x /usr/lib/ld-linux-x86-64.so.2
    0x00007ffff7fef000 0x00007ffff7ffa000 0x0000000000029000 r-- /usr/lib/ld-linux-x86-64.so.2
    0x00007ffff7ffb000 0x00007ffff7ffd000 0x0000000000034000 r-- /usr/lib/ld-linux-x86-64.so.2
    0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000036000 rw- /usr/lib/ld-linux-x86-64.so.2
    0x00007ffffffde000 0x00007ffffffff000 0x0000000000000000 rw- [stack]
    0xffffffffff600000 0xffffffffff601000 0x0000000000000000 --x [vsyscall]

    Importante: os parâmetros para a função _dl_runtime_resolve()estão sendo passados pela stack (de acordo com a Linux x86_64 Calling Convention, os registradores rdi, rsi, rdx, rcx, r8 e r9 são reservados para a passagem de parâmetros das funções), "quebrando" esta calling convention da arquitetura Linux x86_64. Por que isso acontece? 

    Os registradores da Calling Convention ficam reservados para a passagem dos parâmetros da função em evidência: a puts()utiliza o registrador rdi para armazenar o endereço do parâmetro Hello World! (0x1044: lea rdi,[addr of Hello World!]). Como os registradores estão "reservados", a passagem de parâmetro da _dl_runtime_resolve() é feita utilizando-se a stack. Achei bem interessante essa "quebra", pois pensava que sempre teríamos de respeitar essa Calling Convention... bem, nem sempre! ?

    Quando utilizamos o Parcial RELRO, as entradas da .got.plt ficam configuradas assim:

    1. offset para a seção .dynamic (calculado durante o processo de link-edição);
    2. endereço da link_map (armazenado ao se iniciar o ELF);
    3. endereço da função _dl_runtime_resolve() (armazenado ao se iniciar o ELF);
    4. endereço da puts()(descoberto ao se chamar a própria função);

    Vale lembrar que cada entrada da .got.plt possui 8 bytes (64 bits).

    Estes conceitos podem parecer confusos, mas não são. Entre no GDB e faça o debugging da main(), passo a passo, entrando em cada função, e você entenderá melhor o que foi descrito até aqui. 
    Use comandos simples do GDB como ni, si, vmmap e b*main para fazer a depuração, disas <endereço> para o disassembly e x/6gx <endereço> para verificar a memória.

    Percebeu que um programa simples esconde processos bem mais complexos que, talvez, você nem desconfiava que existisse? Legal, né? Então, vamos continuar!


    Por dentro da "_dl_runtime_resolve()"

    Como descrito anteriormente, antes de chamarmos a _dl_runtime_resolve(), as seguintes estruturas são utilizadas durante processo: .plt e .got.plt

    Depois que entramos nessa função, as estruturas \.dynamic, .rela.plt, .dynsym e .dynstr são utilizadas para encontrarmos o endereço da puts().

    Mas, afinal, o que faz a _dl_runtime_resolve()? De forma geral:

    1. Encontra a string puts\0, através das estruturas .dynamic, .rela.plt, .dynsym e .dynstr;
    2. Procura a puts()em todas as libs carregadas (shared objects) e encontra o seu endereço dinâmico na LIBC;
    3. Atualiza o endereço dinâmico da puts()na 4a entrada da seção .got.plt (0x4018);
    4. Faz um jump para puts()(apenas esta vez. Na próxima chamada, a função será chamada diretamente pela PLT - endereço 0x1030);

    Neste artigo, vamos mostrar em detalhes este primeiro item.

    Cada entrada da seção .dynamic possui 16 bytes de tamanho, e é composta por dois campos: d_tag (8 bytes) e d_val (8 bytes). O campo d_val armazena os offsets para seções .rela.plt, .dynsym e .dynstr. Veja:

    $ readelf -d helloworld     # saída com modificações
    
    Dynamic section at offset 0x2df8 contains 26 entries:
     d_tag    Type                         Name/Value
     0x01   (NEEDED)             Shared library: [libc.so.6]
     ...
     0x05   (STRTAB)             0x0488          # d_val = 0x0488 - offset para ".dynstr"
     0x06   (SYMTAB)             0x03e0          # d_val = 0x03e0 - offset para ".dynsym"
     ...
     0x17   (JMPREL)             0x0618          # d_val = 0x0618 - offset para ".rela.plt"

    O campo d_tag armazena um valor inteiro para cada estrutura do ELF. Veja as definições em https://code.woboq.org/userspace/glibc/elf/elf.h.html. Por exemplo:

    ...
    #define DT_STRTAB        5            /* 0x05 - Address of string table */
    #define DT_SYMTAB        6            /* 0x06 - Address of symbol table */
    ...
    #define DT_JMPREL        23           /* 0x17 - Address of PLT relocs */

    Tendo como referência o campo l_info da struct link_map e utilizando a seção .dynamic, a função _dl_runtime_resolve()consegue encontrar a .rela.plt (definida pela struct Elf64_Rela), e obtém um index que está vinculado ao campo r_info (veja mais informações sobre essa struct em https://man7.org/linux/man-pages/man5/elf.5.html), e usa esse index para localizar a seção .dynsym (definada pela struct Elf64_Sym). 
    Nesta seção, há um campo chamado st_name que armazena o offset para a string puts\0, localizada na .dynstr. Repare que está tudo conectado através de "ponteiros", apontando para outros "ponteiros". A partir da .dynamic, a função _dl_runtime_resolve()você consegue localizar em memória todas as estruturas necessárias para fazer corretamente o seu trabalho.

    Como exemplo, quando a _dl_runtime_resolve()quer descobrir onde está a .dynstr, ela verifica o link_map->l_info[DT_STRTAB] (veja que o #define acima é utilizado aqui!), onde há um ponteiro para a entrada da STRTAB, dentro da .dynamic.

    Não colocarei aqui a struct "link_map", pois é bem extensa (por exemplo, armazena até o endereço base do binário ELF), mas o único campo dela que interessa pra gente é o l_info, já citado acima.

    Seguem as 3 structs citadas:

    typedef struct {  /* .dynamic */
      Elf64_Sxword d_tag;     /* 8 bytes */
      union {                 /* 8 bytes */
          Elf64_Xword d_val;
          Elf64_Addr  d_ptr;
        } d_un;
    } Elf64_Dyn;  /* Total = 16 bytes */
    
    typedef struct {  /* .rela.plt */      |  typedef struct {  /* .dynsym */
      Elf64_Addr r_offset;  /* 8 bytes */  |    uint32_t      st_name;     /* 4 bytes */
      uint64_t   r_info;    /* 8 bytes */  |    unsigned char st_info;     /* 1 byte  */
      int64_t    r_addend;  /* 8 bytes */  |    unsigned char st_other;    /* 1 byte  */
    } Elf64_Rela;  /* Total = 24 bytes */  |    uint16_t      st_shndx;    /* 2 bytes */
                                           |    Elf64_Addr    st_value;    /* 8 bytes */
                                           |    uint64_t      st_size;     /* 8 bytes */
                                           |  } Elf64_Sym;  /* Total = 24 bytes */

    Como podemos ver, as structs Elf64_Rela e Elf64_Sym possuem 24 bytes de tamanho em ambiente 64 bits. Em 32 bits, o tamanho é diferente.

    Bom, vamos entender esse processo com mais detalhes.

    Descobrindo os "offsets" das estruturas que usaremos:

    $ readelf -S helloworld   # saída com adaptações
    There are 30 section headers, starting at offset 0x36c0:
    
    Section Headers:
      [Nr] Name          Type         Address
    ...
      [ 6] .dynsym       DYNSYM       03e0
      [ 7] .dynstr       STRTAB       0488
    ...
      [11] .rela.plt     RELA         0618
      [12] .init         PROGBITS     1000
      [13] .plt          PROGBITS     1020
    ...
      [21] .dynamic      DYNAMIC      3df8
      [22] .got          PROGBITS     3fd8
      [23] .got.plt      PROGBITS     4000
      [27] .symtab       SYMTAB       0000
      [28] .strtab       STRTAB       0000

    Obs: Para melhor entender as estruturas abaixo, aconselho importar o binário para o Ghidra, pois ele mostra visualmente cada seção descrita abaixo!

    Até agora, usamos o binário sem ele estar sendo executado e, devido ao PIE, obtemos somente os offsets das funções. Vamos executá-lo no GDB:

    # Obtendo o base address do binário:
    gef➤  deref -l 1 $_base()
    0x0000555555554000│+0x0000: 0x00010102464c457f  # veja aqui o "magic number" do cabeçalho ELF

     

    # Obtendo o conteúdo da .got.plt, somando o endereço base + o offset da .got.plt (obtido via "readelf -S"):
    gef➤  x/4gx 0x555555554000 + 0x4000        # saída com adaptações
    0x555555558000: 0x000000003df8             # "offset" para a seção ".dynamic"
    0x555555558008: 0x7ffff7ffe2a0             # "link_map"
    0x555555558010: 0x7ffff7fda5d0             # "_dl_runtime_resolve()"
    0x555555558018: 0x555555555036             # "puts()", ainda apontando para ".plt" (0x555555554000 + 0x1036)

    Chamei de slots as posições alinhadas em memória das estruturas que estamos procurando. Em síntese, é onde a informação que estamos procurando está localizada dentro da link_map.

    # Descobrindo a ".dynstr": "link_map->l_info[DT_STRTAB]", desfazendo os apontamentos:
    gef➤  x/gx (0x7ffff7ffe2a0 + (8*13))       # "link_map" + 13 "slots" = 0x7ffff7ffe308
    0x7ffff7ffe308: 0x555555557e78             # 0x555555557e78 = endereço da seção ".dynamic", que aponta para:
    
    # a seção ".dynstr":
    gef➤  x/2gx 0x555555557e78
    0x555555557e78: 0x000000000005      0x555555554488
                     *-----> d_tag       *-----> d_val

    Obs: 0x555555557e78 aponta para "d_tag". "d_val" = "d_tag" + 8 bytes!

    d_val = 0x555555554488: 0x555555554000 (endereço base) + 0x488 (que é o offset para .dynstr)

    Pronto! Através do link_map e a .dynamic, conseguimos descobrir o endereço da .dynstr (0x555555554488)!

    Usando a mesma estratégia, vamos descobrir o endereço de link_map->l_info[DT_SYMTAB] e link_map->l_info[DT_JMPREL]:

    # Descobrindo a .dynsym: link_map->l_info[DT_SYMTAB], desfazendo os apontamentos:
    gef➤  x/gx (0x7ffff7ffe2a0 + (8*14))       # "link_map" + 14 "slots" = 0x7ffff7ffe310
    0x7ffff7ffe310: 0x555555557e88             # 0x555555557e88 = endereço da seção ".dynamic", que aponta para:
    
    # a seção ".dynsym":
    gef➤  x/2gx 0x555555557e88
    0x555555557e88: 0x000000000006      0x5555555543e0
                     *-----> d_tag       *-----> d_val

    Obs: 0x555555557e88 aponta para d_tag. d_val = d_tag + 8 bytes!

    d_val = 0x5555555543e0: 0x555555554000 (endereço base) + 0x3e0 (que é o offset para .dynsym)

    Pronto! Através do link_map e a .dynamic, conseguimos descobrir o endereço da .dynsym (0x5555555543e0)!

    # Descobrindo a .rela.plt: "link_map->l_info[DT_JMPREL], desfazendo os apontamentos:
    gef➤  x/gx (0x7ffff7ffe2a0 + (8*31))       # "link_map" + 31 "slots" = 0x7ffff7ffe398
    0x7ffff7ffe398: 0x555555557ef8             # 0x555555557ef8 = endereço da seção ".dynamic", que aponta para:
    
    # a seção ".rela.plt":
    gef➤  x/2gx 0x555555557ef8
    0x555555557ef8: 0x000000000017      0x555555554618
                     *-----> d_tag       *-----> d_val

    Obs: 0x555555557ef8 aponta para d_tag. d_val = d_tag + 8 bytes!

    d_val = 0x555555554618: 0x555555554000 (endereço base) + 0x618 (que é o offset para .rela.plt)

    Pronto! Através do link_map e a .dynamic, conseguimos descobrir o endereço da .rela.plt (0x555555554618)!

    De posse dos endereços das seções .rela.plt, .dynsym e .dynstr, a função _dl_runtime_resolve() tem condições de encontrar a string puts\0.

    A primeira estrutura a ser utilizada é a .rela.plt, pois possui informações sobre a realocação dos endereços das funções.

    $ readelf -r helloworld
    ...
    Relocation section '.rela.plt' at offset 0x618 contains 1 entry:
      Offset          Info           Type           Sym. Value    Sym. Name + Addend
    000000004018  000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0

    O readelf só nos mostra a função puts(), mas se o binário tivesse mais funções, ele as listaria aqui.

    Vamos conferir no debugger o conteúdo do endereço de memória da .rela.plt:

    gef➤  x/3gx 0x555555554618           # saída com modificações
    0x555555554618: 0x0000000000004018   # r_offset
    0x555555554620: 0x0000000300000007   # r_info
    0x555555554628: 0x0000000000000000   # r_addend

    Vamos explicar o que significa esses campos:

    •    r_offset: Contém o offset que determina onde o endereço do símbolo resolvido será armazenado na .got.plt, no caso 0x4018 (veja o objdump que fizemos no começo do artigo);
    •   r_info: Contém duas informações em um único campo:
      • tipo de realocação
      • index para a tabela de símbolos: será usado para localizar a estrutura Elf64_Sym correspndente na seção DYNSYM

    O campo r_info é interpretado através da sua definição no código da LIBC:

    #define ELF64_R_SYM(i)                  ((i) >> 32)
    #define ELF64_R_TYPE(i)                 ((i) & 0xffffffff)

    Após resolvermos essa matemática destes #define, encontramos que o tipo de realocação = 7 (R_X86_64_JUMP_SLOT) e o index para a tabela de símbolos = 3.

    De fato, se pegarmos o endereço inicial da tabela de símbolos .dynsym 0x5555555543e0 e somarmos 3*24 bytes (lembre-se que a estrutura .dynsym em 64 bits possui 24 bytes), encontraremos 0x555555554428, que é a Elf64_Sym da puts().

    $ readelf -s helloworld  # saída com modificações
    Symbol table '.dynsym' contains 7 entries:
      Num:  Value Size Type    Bind   Vis      Ndx Name
         0:  0x00    0  NOTYPE  LOCAL  DEFAULT  UND 
         1:  0x00    0  FUNC    GLOBAL DEFAULT  UND _[...]@GLIBC_2.34 (2)
         2:  0x00    0  NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT[...]
     ==> 3:  0x00    0  FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (3)
         4:  0x00    0  NOTYPE  WEAK   DEFAULT  UND __gmon_start__
         5:  0x00    0  NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMC[...]
         6:  0x00    0  FUNC    WEAK   DEFAULT  UND [...]@GLIBC_2.2.5 (3)

    Pela saída do readelf, verificamos que realmente o index 3 da tabela de símbolos corresponde à puts().

    Em relação ao tipo de realocação = 7 (R_X86_64_JUMP_SLOT - veja o readelf -r acima), há uma verificação sobre essa condição no código em ?

    assert ((reloc->r_info & 0xffffffff) == 0x7);

    Para resumir, esse assert verifica se reloc->r_info"é um JUMP_SLOT válido.

    Vejamos no GDB o conteúdo desses 24 bytes de memória da Elf64_Sym:

    gef➤  x/24xb 0x555555554428          # saída com modificações
    0x555555554428: 0x22 0x00 0x00 0x00  # st_name  = 0x22 - 4 bytes
    0x55555555442c: 0x12                 # st_info  = 0x12 - 1 byte
    0x55555555442d: 0x00                 # st_other = 0x00 - 1 byte
    0x55555555442e: 0x00 0x00            # st_shndx = 0x00 - 2 bytes
    0x555555554430: 0x00 0x00 ...        # st_value = 0x00 - 8 bytes
    0x555555554438: 0x00 0x00 ...        # st_size  = 0x00 - 8 bytes

    A string puts\0 é encontrada através do campo st_name, que representa o offset correspondente ao início da .dynstr (0x555555554488). Se somarmos este endereço com o valor de st_name, encontraremos a string:

    gef➤  x/2wx 0x555555554488+0x22
    0x5555555544aa: 0x73747570      0x62696c00
                      *----> string "puts" em hex no formato "little endian"

    Ou...

    gef➤  x/s 0x555555554488+0x22
    0x5555555544aa: "puts"

    Pronto! Alcançamos o nosso objetivo. A partir de agora, conforme descrito anteriormente, a _dl_runtime_resolve():

    • Procurará a puts() em todas as libs carregadas (shared objects) e encontrará o seu endereço dinâmico na LIBC;
    • Atualizará o endereço dinâmico da puts() na 4a entrada da seção .got.plt (0x4018);
    • Fará um jump para puts() (apenas esta vez. Na próxima chamada, a função será chamada diretamente pela PLT - endereço 0x1030);

    Para fecharmos o ciclo deste artigo, segue a .got.plt com o endereço virtual da puts()descoberto e atualizado:

    gef➤  x/4gx 0x555555554000 + 0x4000        # saída com adaptações
    0x555555558000: 0x000000003df8             # "offset" para a seção ".dynamic"
    0x555555558008: 0x7ffff7ffe2a0             # "link_map"
    0x555555558010: 0x7ffff7fda5d0             # "_dl_runtime_resolve()"
    0x555555558018: 0x7ffff7e075a0             # "puts()"

    ATUALIZADO!! Veja no vmmap que é um endereço da LIBC!

    Críticas, correções e comentários são bem-vindos.

    Obrigado!


    • Agradecer 1

    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.


    fredpassos

       2 de 2 membros acharam esta análise útil 2 / 2 membros

    Muito bem explicado e detalhado. Ótimas referências!

    Parabéns!

    • Curtir 1
    Link para a análise
    Compartilhar em outros sites


  • Conteúdo Similar

×
×
  • Criar Novo...