Jump to content

Search the Community

Showing results for tags 'elf'.

  • Search By Tags

    Type tags separated by commas.
  • Search By Author

Content Type


Forums

  • Supporter area
    • Tools of the Trade
    • Finance transparency
  • MBConf
    • MBConf v1
    • MBConf v2
    • MBConf v3
  • Mente Binária
    • General
    • Computer Architecture
    • Certifications
    • Quantum computing
    • Cryptography
    • Challenges and CTF
    • Hardware Hacking
    • Electronics
    • Conferences
    • Forensics
    • Games
    • Data privacy and laws
    • Code breaking
    • Networking
    • Pentest
    • Speak to us!
    • Software releases
  • Career
    • Study and profession
    • Jobs
  • Reverse Engineering
    • General
    • Malware Analysis
    • Firmware
    • Linux and UNIX-like
    • Windows
  • Programming
    • Assembly
    • C/C++
    • Python
    • Other languages
  • Operating Systems
    • GNU/Linux and UNIX-like
    • Windows
  • Segurança na Internet's Discussão

Calendars

  • Conferences
  • Call for papers (CFP)

Categories

  • Trainings

Categories

  • Portal Mente Binária
  • Specials

Categories

  • Articles

Categories

  • Contributing

Categories

  • Professions

Categories

  • Tech basics
    • Text comprehension
    • English
    • Mathematics
  • Computing Basics
    • Lógica de Programação
    • Computers Architecture
    • Cryptography
    • Data Structures
    • Network
    • Operating Systems
  • Specifics
    • SO Internals
    • Web
    • Python
    • Javascript
    • Infrastructure
    • Go
    • Reverse Engineering
    • DevOps
    • C/C++
    • Log Analysis

Categories

  • Software

Categories

  • Crackmes
  • Documentation
  • Debuggers
  • PE tools
  • Books
  • Util
  • Packers
  • Unpackers
  • Virtual Machines

Find results in...

Find results that contain...


Date Created

  • Start

    End


Last Updated

  • Start

    End


Filter by number of...

Joined

  • Start

    End


Group


GitHub


Twitter


LinkedIn


Website

Found 5 results

  1. 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): 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. 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: offset para a seção .dynamic (calculado durante o processo de link-edição); endereço da link_map (armazenado ao se iniciar o ELF); endereço da função _dl_runtime_resolve() (armazenado ao se iniciar o ELF); 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: Encontra a string puts\0, através das estruturas .dynamic, .rela.plt, .dynsym e .dynstr; Procura a puts()em todas as libs carregadas (shared objects) e encontra o seu endereço dinâmico na LIBC; Atualiza o endereço dinâmico da puts()na 4a entrada da seção .got.plt (0x4018); 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!
  2. Durante o estudo de exploração de binários executáveis, uma das primeiras e mais importantes coisas que vemos são as proteções como NX, PIE, ASLR, de entre outras. Hoje vamos ver um pouco mais de perto cada uma delas. NX No eXecute (NX) é uma das proteções mais simples e mais lógica, implementada por volta de 2004. Durante a execução de um binário sem essa proteção ativada, a sua stack, heap e outras regiões possuíam poder de execução, simples assim. Os processadores da família x86 tardaram muito para implementar o bit NX, que basicamente é uma forma de sinalizar uma página em memória como executável ou não. Isso gerou projetos para tentar emular tal proteção via software, como o Exec Shield, um projeto da Red Hat que continha um patch no kernel do Linux para emular o funcionamento do NX. No kernel Linux, o suporte para o NX foi adicionado oficialmente em 2004. Patches como o mencionado acima, do Exec Shield, já existiam desde 2002. No Windows, a proteção está presente desde 2003 com o nome de DEP (Data Execution Prevention), porém a DEP emulada via software funciona de uma forma diferente: ela não se baseia no bit NX, mas sim checando sempre que uma exceção é realizada se o endereço da execução bate com a tabela de funções do binário. Essa proteção é ativada ou não durante o carregamento do binário na memória. No Linux isso é controlado com uma flag exec no segmento PT_GNU_STACK do binário. Já no Windows, as configurações ficam no arquivo Boot.ini, podendo ter até quatro opções diferentes: OptIn: Apenas em binários do sistema a proteção é ativada. OptOut: A DEP é ativada por padrão para todos os processos. AlwaysOn: Todos os processos têm a DEP ativada e não existe a possibilidade de desativar para um processo específico. AlwaysOff: A proteção não é habilitada para nenhum processo. PIE/PIC O Position-Independent Executable (PIE) ou Position-Independent code (PIC) não é em si uma proteção, mas uma técnica para criar código, assim como o nome diz, independente de posição. Isso significa que, onde o código for alocado, ele conseguirá executar sem problemas, diferente de binários comuns, os quais precisam ser carregados em lugares específicos para fazerem referências de forma correta. Essa técnica é usada principalmente para bibliotecas (shared libraries). O problema disso durante a exploração é que os endereços do binário também são randomizados com a ASLR, proteção que vai ser explicada a seguir, impossibilitando, assim, conhecer os endereços do binário e consequentemente utilizar Return Oriented Programming (ROP) para explorar uma vulnerabilidade deixa de ser viável. A solução para fazer bypass dessa proteção é conseguir um vazamento (leak) de memória, pois é possível calcular a base já que os offsets continuam os mesmos. Como o PIE é uma forma de gerar código, não é possível "desabilitá-lor", porém se a ASLR estiver desligada, o endereço base se manterá o mesmo, não tento efeito prático para mitigar uma ROP, por exemplo. ASLR A Address Space Layout Randomization (ASLR), assim como o nome diz, tem a função de randomizar endereços como os da heap, stack e bibliotecas. Como uma proteção em nível de sistema, cada sistema operacional mantém uma implementação própria. No Linux, a ALSR já está presente desde a sua versão 2.6.12, lançada em 2005. Executáveis PIE também possuem um endereço base randômico desde 2003, porém ambos com uma implementação fraca e com pouca aleatoriedade. O PaX e o Exec Shield (mencionado acima) foram responsáveis por patches com uma implementação mais complexa e segura. O endereço da stack com essas alterações podem ter até 524.288 variações diferentes. Por padrão, a ASLR é configurada no Linux através do arquivo /proc/sys/kernel/randomize_va_space e pode possuir três valores diferentes: 0 para desabilitar, 1 para randomizar stack, bibliotecas e executáveis PIE e 2, o valor mais alto, para randomizar também a base da heap. É possível, também, ativar ou desativar para processos isoladamente usando um recurso chamado personality . O Windows teve sua implementação da ASLR lançada em 2007 com uma abordagem diferente: a ASLR só é ativada em executáveis e DLLs que possuírem uma "flag" no processo de linkagem e todos os membros precisam possuir essa "flag". Por exemplo, suponha que A.exe dependa de B.dll e C.dll. Caso C.dll não tenha ASLR habilitada, A.exe não terá a proteção ligada. Hoje em dia essa é uma situação rara, pois apenas programas mais antigos não possuem suporte para ASLR. Canary O canary é uma proteção muito interessante. Ao compilar algum código com essa proteção, o compilador adiciona um fragmento de código no começo e no final de cada função. No prólogo da função, se cria uma variável local na stack com um valor aleatório e no final se acessa essa mesma variável para verificar a integridade do valor, validando se continua o mesmo. Caso tenha mudado, uma função de callback é chamada para gerar um abort, assim evitando transbordamentos de memória. O GCC, LLVM, Intel compiler entre outros compiladores possuem implementações do canary. O valor aleatório citado acima é o chamado canary ou stack cookie. Esse cookie pode ser classificado de três formas: terminator O terminator Canary se baseia na observação de que a maioria das vulnerabilidades de buffer overflow acontecem em cenários de manuseio de strings que finalizam com um null byte (\0). Em contrapartida, o cookie é construído com null byte (\0), CR (\r), LF (\n) e FF (\f). Como resultado, isso mitiga um buffer overflow em funções como strcpy(), pois, mesmo sabendo o valor do cookie, não é possível copiá-lo com esse tipo de funções, já que ela ira retornar assim que ler o nullbyte. random Neste tipo, Random Canaries são gerados randomicamente a cada execução a partir de alguma fonte de entropia. Além disso, esse valor não é facilmente lido, pois no carregamento do binário esse valor é armazenado em uma variável global, que é alocada em uma região não mapeada de memória para dificultar ainda mais seu acesso. As formas mais viáveis de ler o cookie são com um vazamento da stack ou sabendo o endereço exato da variável global. random XOR O random XOR Canary é o tipo mais complexo. Seu valor é basicamente determinado a partir de operações XOR de vários valores de controle, como stack pointer, endereço de retorno, etc. Dessa forma, se o Canary ou os dados usados no cálculo forem corrompidos, o programa irá gerar um abort. Para conseguir enganar essa checagem de integridade, é necessário vazar não apenas o cookie, mas também os dados usados no cálculo e o próprio algoritmo para gerar o mesmo. RELRO A RELocation Read-Only (RELRO) é uma proteção exclusiva do Linux, ou melhor, do formato ELF, que tem como intenção deixar as seções relacionadas a realocação como somente leitura (read-only). As seções de realocação, de forma resumida, são onde são armazenados os endereços de funções externas, como as da libc. Por padrão, essas regiões de memória possuem permissão de leitura e escrita (read/write), pois normalmente é utilizada lazy bind, uma forma de resolver esses endereços não em tempo de carregamento, mas durante a execução, logo antes da chamada. Essa proteção foca exatamente nessa região de meória. A realocação dos endereços é feita antes de passar o controle para o programa e a região é marcada como somente leitura, impossibilitando, assim, que um atacante a sobescreva com endereços maliciosos. Existem dois tipos de RELRO: partial e full, onde a partial marca como somente leitura a maioria das seções, exceto a Global Offset Table (GOT), na seção .got.plt. Já a full, como se deve imaginar, força que todos os símbolos e endereços sejam resolvidos durante o carregamento do binário, permitindo que a GOT seja completamente read-only. Referências - A hardware-enforced BOF protection: - http://index-of.es/EBooks/NX-bit.pdf - Windows ISV Software Security Defenses - https://docs.microsoft.com/en-us/previous-versions/bb430720(v=msdn.10) - Position-Independent Code with GCC for ARM Cortex-M - https://mcuoneclipse.com/2021/06/05/position-independent-code-with-gcc-for-arm-cortex-m/ - Effect of ASLR to Memory Deduplication - https://www.semanticscholar.org/paper/Effect-of-ASLR-to-Memory-Deduplication-Ratio-in-)-Piao-Sung/3f74d25c72322315ec3b6552e6c3d4413af95022 - Exploit Protection Mechanisms - https://ocw.cs.pub.ro/courses/cns/labs/lab-06 - Hardening ELF binaries using Relocation Read-Only (RELRO) - https://www.redhat.com/en/blog/hardening-elf-binaries-using-relocation-read-only-relro
  3. 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!
  4. Pessoal... Ontem achei um artigo na Internet bem escrito, interessante e detalhado sobre Engenharia Reversa em ELF. É um reversing básico, mas não tããããão básico assim. Acho que vale a pena conferir. http://manoharvanga.com/hackme/ Valeu!
  5. Olá, Estamos enviando pelos links abaixo alguns binários executáveis (ELF 64-bit LSB) que realizam tarefas bem simples, que podem ou não ser úteis. O exercício é que você descubra o que esses binários fazem, utilizando as ferramentas que julgar mais adequadas. Como resposta, espero que você me diga o que acha que eles fazem e quais foram as ferramentas usadas para isso? Binários: https://s3-sa-east-1.amazonaws.com/desafio-binarios/ddb1c9 https://s3-sa-east-1.amazonaws.com/desafio-binarios/da87fa https://s3-sa-east-1.amazonaws.com/desafio-binarios/d3ea79 https://s3-sa-east-1.amazonaws.com/desafio-binarios/cc9621
×
×
  • Create New...