Entendendo ShellCodes em explorações de Buffer Overflow

SidHawks
6 min readJan 18, 2022

Se você já tem um breve conhecimento sobre Buffer overflow você deve saber que a vulnerabilidade acontece quando um programa recebe certo argumento e este é escrito em algum buffer com um determinado valor de tamanho sem nenhum tipo de tratamento referente àquela entrada, quando o programa recebe mais do que foi esperado já que a alocação de um buffer fica em um espaço reservado de memória, a entrada começa a sobrescrever os endereços no contexto presente o que permite fazer a alteração de como o programa é executado primordialmente.

Esse comportamento abre uma vantagem de exploração que é a inserção de shellcodes na STACK do programa (em binários sem proteções), os shellcodes são simplesmente instruções a nível de processador(operation codes ou OPCODEs) que dão a capacidade de interagir com o sistema através de ações podendo abrir arquivos, fazer leitura, chamar binários presentes no sistema entre outras ações.

Ps: Qualquer trecho de código que execute algum comando também pode ser chamado de shellcode.

Nesse caso visualizamos os opcodes com um dumper que consegue mostrar os códigos das instruções de uma forma legível, o binário se organiza com um stream de instruções que são utilizadas em momento de execução. Esse trecho de opcodes demonstrado não funcionaria em uma exploração de buffer overflow porque a instrução na linha do “call 410” contem dois Null Bytes, falaremos sobre posteriormente.

objdump -m Intel -d binary

Sabemos que a exploração acontece até determinado ponto que é o endereço de retorno, se chamarmos o endereço subsequente na STACK voltamos para ela e temos um contexto de exploração onde entra os shellcodes, como introduzido anteriormente você lembra que eles são instruções a nível de processador? Conseguindo alocar os shell codes junto com a nossa payload de exploração, certamente o binário executará as instruções passadas. Devemos ter em mente que os shellcodes não podem conter instruções do tipo Null(0x00), já que esses iriam parar a execução da STACK. O Null Byte é um tipo de terminador onde indica que um array de char(string) foi terminado, então o código acima pararia de ser executado na linha do call.

Criação de um ShellCode

Nesse cenário precisamos ter um pensamento apurado pois a formação de um shellcode não pode conter Null Bytes presentes, podemos fazer um shellcode com alguma system call que execute o BASH, executar instruções para ler um arquivo contendo informações sensíveis ou até mesmo uma conexão reversa, se o contexto de formação do shellcode aparecer os valores nulos e as operações nulas, devemos trabalhar com diferentes registradores no conceito de tamanho por exemplo: AL, AX, AH, EAX, RAX.

Como uma tentativa de eliminar o que atrapalha a criação do shellcode, se você alguma vez criou uma payload para explorar buffer overflow utilizando o msfvenom deve ter reparado que nele apresenta a opção -b que possibilita inserir os opcodes que você deseja eliminar e sempre enviamos -b “\0x00” justamente para eliminar os Null Bytes ou Bad Chars.

O exemplo abaixo mostra a criação da string que iremos utilizar para abrir o binário do BASH, o xxd exibe o output em hexadecimal, também precisamos inverter a ordem dos bytes pois estamos trabalhando em arquitetura little endian, foi inserida mais uma “/” para completar 8 bytes e eliminar algum null byte que poderia aparecer no momento de compilação.

No modelo subsequente vemos que houve uma subtração de RAX por RAX e RSI por RSI, essas instruções irá limpar qualquer valor que esteja nos endereços para executar a system call posteriormente, movemos o endereço zerado para a stack, alocamos a string que criamos anteriormente no RBX e enviamos para a stack, movemos o operation code da system call execve para [al](eliminando os null bytes que poderiam aparecer no endereço), e no RDI movemos o que desejamos abrir que no caso é o /bin/sh e ele está presente na stack por isso movemos o RSP para RDI, e no final executamos a syscall.

Comando para a compilação do código em assembly:

nasm -f elf64 sh.asm -o sh.o
ld sh.o -o sh

Após a compilação se você executar o binário irá perceber que ele faz o spawn de outro bash no mesmo terminal:

Para criar o shellcode definitivamente basta dumpar os opcodes com o Objdump e utilizar em suas explorações:

Formato final do shellcode:

"\x48\x29\xf6\x48\x29\xc0\x50\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x53\xb0\x3b\x48\x89\xe7\x0f\x05"

Existe um site muito interessante que contém uma database gigantesca de shellcodes em diversas arquiteturas você também pode utilizá-las de acordo com sua necessiadade: http://shell-storm.org/shellcode/

Agora vamos ver o funcionamento do shellcode na prática implementado um código vulnerável em C e compilando sem nenhum tipo de proteção a fim de entender como o shellcode será lido. Esse binário aloca o input digitado no terminal dentro array de char “name” e depois exibe o que foi digitado.

Comando para compilar o programa sem as proteções e comando para desabilitar o ASLR do sistema que é um randomizador de seguimentos de memória.

gcc program.c -o program -z execstack -fno-stack-protector -no-pie
sudo sysctl kernel.randomize_va_space=0

Executando o binário dentro do debugger se digitarmos uma string maior do que o buffer alocado ela sobrescreve o endereço de retorno, ou seja estamos sobrescrevendo a stack do programa alcançando o endereço de retorno gerando uma falha de segmentação.

Para obtermos o tamanho exato onde está o endereço de retorno basta escrever uma string contado de A a Z de oito em oito caracteres (x64) ou de quatro em quatro (x86). No exemplo utilzei o gerador de patterns cíclicos do pwntools https://github.com/Gallopsled/pwntools.

A partir da string destacada, começamos a sobrescrever o endereço de retorno, basta pegar o próximo endereço depois do topo da stack e juntar com o shellcode, utilizamos o endereço subsequente ao topo da stack porque ele permite retornar para o mesmo contexto de exploração e continuar executando outras instruções.

Formato do exploit de exploração junto com o shellcode criado:

crash = 'A'*56 //Limite
ret = "\x10\xdf\xff\xff\xff\x7f" // Retorno para a stack.
shellcode = "\x48\x29\xf6\x48\x29\xc0\x50\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x53\xb0\x3b\x48\x89\xe7\x0f\x05"
print(crash+ret+shellcode)

Depois de toda execução ocorrer conseguimos ver o shellcode sendo executado, o Pwndbg permite-nos visualizar todo o stream de instruções presentes no shellcode, semelhante ao código que criamos em assembly anteriormente.

Você também pode tentar executar fora do debugger, mas é preciso inspecionar caso o endereço de retorno mude para a execução funcionar normalmente.

Espero que eu tenha conseguido agregar algum conhecimento nesse post para aqueles que ainda não compreendiam o que é um shellcode, não sou um grande expert no assunto, mas estou tentando compartilhar todo o conhecimento que venho adquirindo, indico o curso CEB do mente binária para quem quiser aprender explorar falhas de buffer overflow e aprender o bypass das proteções presentes nos binários: https://www.youtube.com/watch?v=Ps3mZWQz01s&list=PLIfZMtpPYFP4MaQhy_iR8uM0mJEs7P7s3

Outra recomendação que nunca deixo de fazer é do livro x86–64 Assembly Language Programming with Ubuntu do Ed Jorgensen http://www.egr.unlv.edu/~ed/ é uma ótima maneira de aprender assembly, ele contém um capítulo falando justamente sobre buffer overflow.

--

--