H
Kembali ke Works
Writeup

Write-Up: WRECK-IT 7.0 Final (Attack/Defense)

WRECK-IT 7.0 (Final — Attack/Defense)
ctfattack-defensewebpwncryptossrfteam
Write-Up: WRECK-IT 7.0 Final (Attack/Defense)

Analisis kerentanan dan patch 4 service (Canting VM, Cytransfer, Kurir, Evidentia) di babak final WRECK-IT 7.0 format Attack/Defense — VM verifier bypass, SSRF-to-RCE, forgery Schnorr/VDF, dan sandbox eval escape. Finalis peringkat 6.

WRECK-IT 7.0 Final — Attack/Defense

Materi tim untuk babak final WRECK-IT 7.0, satu-satunya writeup di sini yang formatnya bukan Jeopardy. Berisi analisis kerentanan tiap service, tooling serangan, dan tambalan yang benar-benar dipasang selama lomba berlangsung — bukan simulasi setelah acara selesai.

Format lomba: tiap tim menjalankan service yang sama persis. Tujuannya curi flag tim lawan (attack) sambil menambal service sendiri tanpa merusak SLA (defense). Flag WRECKIT7{...}, rotasi tiap ronde (~22 detik), expire ~44 detik. Defense = clone repo service → edit hanya file yang boleh diedit → git push → rebuild otomatis dan auto-redeploy.

Peran saya (P1): exploit web + pegang harness serangan multi-tim. Dua rekan setim pegang defense/forensik dan reverse/pwn/mobile. Hasil: finalis peringkat 6.

Console tim — Deployments

Service & kerentanan

#ServiceStackKerentanan intiStatus kita
1Canting VMC (VM bytecode)Verifier satu-pass linier tak ikuti alur kontrol; cell() dibungkus uint16 → baca seluruh SRAM (seal1). spool_limit() menuruti nilai dari cartridge → chip spool terbuka (seal2).✅ ditambal
2Cytransfer DomainPython/Flask + kriptow1: proof Schnorr tak terikat commitment → forgery kepemilikan. w2: VDF Wesolowski l tak diikat hash_to_prime → l=2 bikin persamaan trivial.✅ ditambal
3KurirPython/Flaskw1: SSRF filter blacklist-string → /internal/render pickle.loads = RCE. w2: SSRF kedua /webhook/notify → baca flag mentah.✅ ditambal
4EvidentiaPython/Flask/timeline/pipeline?p= eval sandbox blacklist-substring; namespace lokal memuat flag → print(vars()) membocorkannya.exploit ✅, patch belum sempat (repo baru unlock setelah capture)

Strategi tim

Soal mostly web plus unsur reverse/pwn/forensic/mobile. Prinsipnya: fokus fire di web (mayoritas poin) dan defense-first di kategori yang tidak dikuasai.

PeranPegangTugas tambahan
P1 (saya)Web (utama) — exploit + pegang harnessAttack lead; tambah vektor tiap wave
P2Web #2 + forensicDefense semua soal + pantau Live Map + submit flag
P3Reverse + pwn + mobilePivot ke defense (patch dari PCAP) kalau attack mentok

Enam aturan yang dipegang sepanjang lomba: satu orang pegang harness (hindari duplikat serangan), satu orang patcher terpusat (hindari konflik git), satu mata selalu di Live Map, mulai dan patch secepat mungkin, kategori sulit didekati defense-first plus tiru dari PCAP, dan SLA di atas segalanya — jangan overpatch sampai service sendiri ikut mati.


1. Canting VM — verifier satu-pass vs runtime tanpa batas

Soal pwn/VM: cartridge bytecode divalidasi cv_verify() lalu dijalankan cv_run(). Semua keamanannya ada di verifier — cv_run() sendiri nol cek batas. Jadi tiap celah di sini bentuknya sama: bikin runtime berbeda dari apa yang diyakini verifier.

FlagLetakBug
Seal 1SRAM 0x3000 (vault, dari flag.txt)Verifier jalan satu pass linier, tak mengikuti lompatan
Seal 2Spool 0x400 (dari flag2.txt)spool_limit() menuruti spool_len yang ditulis penyerang sendiri di cartridge

⚠️ Box kita sendiri belum tertambal saat PCAP diperiksa — tim lain sudah memanen kedua flag dari kita. Ini yang jadi prioritas tambal tertinggi begitu ketahuan.

1.1 Seal 1 — verifier tidak mengikuti alur kontrol

cv_verify() mengiterasi pc = 0 .. n-1 berurutan. Untuk instruksi lompat ia cuma memastikan targetnya masih di dalam program, lalu lanjut ke pc+1 — tak pernah menelusuri cabang, tak pernah mengulang loop sampai fixpoint. Range abstrak yang dipakai untuk memutuskan kelayakan jadi hasil satu jalur lurus, padahal runtime bisa melompat mundur berkali-kali.

Sementara di vm.c sama sekali tak ada cek batas — indeksnya cuma dibungkus modulo 64K:

static uint8_t *cell(cv_vm *m, uint64_t off)
{ return &m->sram[(uint16_t)(CLOTH_BASE + off)]; }   // 64K bebas

Cloth (area kerja legal) ada di 0x1000, vault di 0x3000 — cukup buat register r0 mencapai 0x2000 lewat loop mundur:

0: mov  r0, 0
1: cmp  r0, 0x2000
2: jlt  +2        ; r0 < 0x2000 -> lompat ke pc5, belum baca
3: ld   r1,[r0+0] ; verifier yakin r0=[0,0] jalur lurus -> LOLOS
4: emit r1
5: add  r0, 1
6: cmp  r0, 0x2100
7: jlt  -7        ; MUNDUR ke pc1  <- inti bug
8: halt

Varian keduanya cermin dari trik yang sama: verifier justru menjalankan baris yang runtime lewati (jmp melompati instruksi reset register), sehingga verifier menganalisis state yang tidak pernah benar-benar dieksekusi. Hasil kedua varian: emit 256 byte dari sram[0x3000..], persis isi flag — dan keduanya juga muncul di PCAP, dipakai tim lain persis sama.

Tambalan yang benar: verifier harus mengikuti alur kontrol (telusuri CFG dengan worklist, gabungkan state di tiap titik gabung, ulangi sampai fixpoint). Kalau tidak sempat menulis analisis selengkap itu, cukup satu baris di cell(): jepit indeks ke 0..CLOTH_SIZE-1 — mematikan seluruh kelas bug ini sekaligus, dan motif preset yang sah tetap lolos karena indeksnya memang selalu di dalam cloth.

1.2 Seal 2 — plafon yang ditentukan penyerang sendiri

spool.h menyiapkan konstanta plafon dan bahkan menegaskannya lewat _Static_assert, tapi spool_limit() tidak pernah memakainya — ia mengembalikan spool_len mentah, 2 byte di header cartridge yang ditulis sendiri oleh penyerang. Nilai itu menyetir dua hal sekaligus: batas yang dicek verifier, dan mask jendela baca di runtime.

Minta spool_len = 0x1000 → verifier mengizinkan indeks sampai 0xFFF, window runtime ikut melebar ke 0xFFF → seluruh chip spool terbuka, termasuk seal di 0x400. Yang membuat bug ini berbeda dari seal 1: indeksnya benar-benar sah menurut aturan verifier sendiri. Bug ini tidak bergantung sama sekali pada bug verifier — target yang sudah memperbaiki cv_verify() pun tetap bocor selama spool_limit() belum dijepit.

Tambalan: satu baris, jepit spool_len ke konstanta plafon yang sudah didefinisikan tapi tak terpakai (SPOOL_GRANT, di bawah batas memori privat papan). Motif preset yang sah minta plafon jauh di bawah itu, jadi SLA tetap aman.

Pola untuk soal VM/verifier

Cek dulu apakah verifier mengikuti alur kontrol — kalau ia berjalan linier for (pc = 0; pc < n; pc++), semua jaminannya runtuh begitu ada lompatan mundur, itu bug bukan detail. Cari nilai dari input yang menentukan batas pengecekannya sendiri — konstanta plafon yang sudah didefinisikan tapi tak pernah dipakai adalah tanda bahaya yang jelas. Dan runtime tanpa cek batas berarti satu asumsi verifier meleset saja langsung membuka baca memori bebas — menambal di runtime lebih murah dan lebih rapat daripada membetulkan analisis statis, dan tidak merusak motif yang sah.


2. Kurir — SSRF dua lapis ke deserialization RCE

Lapis 1 — SSRF. Filter awal is_safe() memfilter pakai blacklist string (127.0.0.1, localhost, 169.254.169.254). Alias loopback lain lolos semua: 127.1, 0.0.0.0, 127.0.0.2, representasi desimal 2130706433, [::1]. Klasik — blacklist string tidak pernah cukup untuk memfilter IP.

Lapis 2 — pickle. Endpoint internal cuma dijaga pengecekan remote_addr != '127.0.0.1' — dan request dari SSRF di atas memang datang dari loopback. Isinya pickle.loads(base64decode(...)), RCE langsung lewat __reduce__.

Rantai penuhnya satu request: GET /webhook/test?url=http://127.1/internal/render?job=<b64(pickle)>. Payload memakai subprocess.check_output (bukan os.system) karena balikannya berupa bytes, dan handler punya cabang yang mengembalikan isi bytes itu langsung sebagai body respons — jadi RCE ini dengan output langsung, bukan blind.

Dua jebakan waktu menyusun payload: karakter +// di base64 harus di-percent-encode dulu (kalau tidak, Flask target mengubah + jadi spasi dan merusak pickle-nya), dan perintah shell wajib diakhiri ; exit 0 karena check_output melempar exception pada exit code bukan nol — kalau tidak, yang kembali cuma pesan error, bukan isi flag.

Tambalan kita: resolve host dulu, tolak semua IP private/loopback/link-local/reserved (bukan cuma daftar string), dan pasang unpickler kustom yang menolak find_class — mematikan jalur __reduce__ sepenuhnya, sementara tipe data dasar (dict/bytes/str/int/list/tuple) tidak butuh find_class sehingga fungsi asli tetap jalan.

⚠️ Sisa risiko yang tidak bisa ditambal: kode inti (terkunci, tak boleh diedit) memanggil fungsi HTTP yang mengikuti redirect, sementara guard hanya memeriksa URL pertama. Server milik penyerang yang membalas 302 → http://127.0.0.1/internal/render masih tembus filter. Karena bagian itu terkunci, unpickler kustom itulah pertahanan yang sesungguhnya — jangan pernah dicopot hanya karena filter SSRF-nya terlihat sudah rapat.

Wave 2 — SSRF kedua, tanpa jaring pickle. Guard baru untuk endpoint callback mengulang kesalahan yang persis sama: daftar-hitam string, lubang alias loopback yang sama lolos lagi. Yang membuatnya lebih gawat dari wave 1: targetnya endpoint admin yang membaca file flag dan mengembalikannya apa adanya — tidak ada pickle di jalur ini, jadi unpickler kustom sama sekali tidak melindungi. Satu request lewat alias loopback sudah cukup.

Tambalan: guard callback disamakan dengan guard SSRF pertama (resolve dulu, tolak berdasarkan sifat IP), plus validasi tiap hop redirect — karena fungsi HTTP di kode inti tetap mengikuti redirect selama tujuan akhirnya lolos guard.


3. Cytransfer — forgery Schnorr, lalu VDF Wesolowski yang dipilih sendiri parameternya

3.1 Wave 1 — commitment tak pernah dicek ulang

Alur normal protokol Schnorr: pihak yang membuktikan kepemilikan kunci mengirim commitment t = G^r, menerima challenge c dari verifier, lalu menjawab s = r + c·x. Verifier mengecek G^s ≟ t·Y^c.

Versi awal endpoint verifikasi menyimpan commitment saat sesi dibuka, tapi tidak pernah membandingkannya dengan commitment yang dikirim ulang di endpoint transfer — yang dipakai untuk verifikasi adalah commitment kiriman terakhir. Urutan commit-lalu-challenge jadi bisa dibalik: lihat c dulu, baru mengarang t yang pas — tanpa tahu kunci rahasia x sama sekali.

1. GET  /domains                                  -> ambil Y (kunci publik domain target)
2. POST /session/open {domain, commitment:"0x4"}   -> dapat session id + challenge c
3. pilih s bebas;  t = G^s · Y^(-c) mod P
4. POST /transfer {sid, commitment: t, response: s}
   server cek:  t · Y^c = G^s · Y^(-c) · Y^c = G^s  -> LOLOS

Invers dihitung pakai teorema kecil Fermat (pow(Y, P-1-c, P)), bukan mengasumsikan orde grup dari Y, supaya rumusnya benar untuk Y mana pun. Parameter grup (MODP 2048-bit standar) sama untuk semua tim, jadi cukup di-hardcode; yang perlu diambil per target cuma Y.

Verifikasi lewat PCAP box sendiri membuktikan patch-nya menahan: dua puluh percobaan forgery dari tujuh IP penyerang berbeda semuanya ditolak "commitment mismatch" setelah tambalan terpasang, nol yang berhasil dapat seal. Salah satu payload lawan sempat diverifikasi ulang angkanya dan cocok persis dengan pola forgery di atas — mereka memilih s = 1 supaya payload-nya pendek.

Tambalan: tolak kalau commitment yang dikirim di transfer tidak sama dengan yang disimpan saat sesi dibuka, plus hapus sesi setelah verifikasi berhasil supaya sekali pakai (mencegah replay dan penyelundupan commitment berbeda di panggilan kedua).

3.2 Wave 2 — VDF Wesolowski, penyerang memilih prima pembuktinya sendiri

Fungsi verifikasi VDF (terkunci, tak boleh diedit) mengecek persamaan pi^l · x^r ≡ y (mod N) dengan r = 2^T mod l, tapi satu-satunya syarat atas l yang dicek di lapisan yang boleh diedit cuma l > 1. Padahal skema Wesolowski hanya sound kalau l adalah hasil hash-ke-prima dari x dan y — nilai yang tidak boleh dipilih pembukti. Karena l, y, dan pi semuanya datang dari penyerang, persamaannya jadi identitas kosong:

l = 2  ->  r = 2^T mod 2 = 0  ->  pi^2 · x^0 = pi^2 = y
pi = 1, y = 1  ->  1 == 1  LOLOS

Payload lengkapnya cuma {"y":"0x1","pi":"0x1","l":"0x2"} — nol kuadrat modular dikerjakan, padahal delay VDF yang diminta seharusnya butuh triliunan operasi squaring berurutan untuk diselesaikan jujur.

Tambalan: karena fungsi verifikasi inti terkunci, perbaikannya harus di lapisan pemanggil — hitung ulang l yang seharusnya dari x dan y, tolak kalau tidak cocok dengan yang dikirim penyerang. Proof yang jujur selalu memakai l hasil hash yang sama, jadi tidak mengganggu jalur normal.

Pola yang berulang di dua service ini

Blacklist string untuk host/IP selalu bocor — resolve dulu, baru tolak berdasarkan properti IP-nya. pickle.loads atas input yang bisa dipengaruhi penyerang sama dengan RCE, titik; guard "internal only" yang bersandar pada alamat IP pengirim runtuh begitu ada SSRF di service yang sama. Untuk protokol kripto, yang perlu diperiksa bukan rumus matematikanya — rumus verifikasi di kedua service ini sudah benar — melainkan orkestrasi-nya: nilai yang seharusnya terkunci (commitment, parameter prima) ternyata masih bisa dipilih ulang oleh penyerang setelah melihat nilai acak dari server.


4. Evidentia — sandbox eval blacklist yang membocorkan variabel flag sendiri

Berbeda dari tiga service lain, repo Evidentia baru bisa diakses setelah flag-nya berhasil dipanen — jadi analisis di bawah murni dari probing service yang hidup, bukan dari membaca source langsung.

Service forensik dengan modul timeline, metadata EXIF, dan disk image. Satu endpoint meng-eval ekspresi Python dari parameter query, dijaga sandbox yang cuma blacklist substring mentah pada string ekspresi — kata seperti flag, import, open, class, eval, os, globals, serta karakter titik dan kutip.

Tapi ekspresi itu dieval di namespace lokal yang memuat variabel flag, dan memanggil print(vars()) menumpahkan seluruh isi namespace itu — tanpa satu pun kata atau karakter terlarang, sehingga lolos total:

GET /timeline/pipeline?p=print(vars())
-> {'count': 6, 'total': 2319872, ..., 'flag': 'WRECKIT7{...}', ...}

Halaman metadata sempat memajang umpan FLAG{sample_not_real} yang bukan flag asli, dan path traversal langsung ke file flag ditolak — jalur yang benar-benar terbuka murni lewat kebocoran scope eval ini.

Kenapa blacklist ini kalah: blacklist substring tidak akan pernah cukup — vars(), dir(), list comprehension, konkatenasi string, selalu ada jalan lain untuk merujuk sesuatu tanpa menyebut namanya secara harfiah. Akar masalahnya bukan kata apa yang lolos filter, tapi flag berada di scope yang sama dengan tempat eval dijalankan.

Tambalan yang direkomendasikan (belum sempat diterapkan — repo baru terbuka menjelang akhir sesi): keluarkan flag dari scope eval sepenuhnya, jalankan eval dengan namespace tertutup ({"__builtins__": {}} plus dict allowlist berisi hanya variabel statistik yang memang perlu diakses ekspresi), atau lebih kuat lagi, parse ekspresi dengan modul ast dan tolak node apa pun selain operasi aritmetika atas variabel yang di-whitelist — membuang blacklist string sama sekali.

Console tim — Flags


Penutup

Attack/Defense menuntut kecepatan yang beda dari Jeopardy — flag yang sama bisa dipanen berkali-kali dari banyak tim sampai mereka menambal, tapi celah yang sama juga bisa dipakai lawan menyerang balik selama box sendiri belum ditambal. Beberapa hal yang paling terasa dari babak ini:

Verifier/parser yang tidak mengikuti alur kontrol sebenarnya adalah verifier yang bohong. Canting VM membuktikan ini dua kali dengan cara berbeda — analisis satu-pass yang percaya pada jalur lurus, dan runtime yang mempercayai nilai plafon dari input penyerang sendiri.

Kunci komunikasi dalam protokol kriptografi harus benar-benar terkunci. Rumus di kedua bug Cytransfer sudah matematis benar; yang bolong justru di lapisan orkestrasi yang lupa mengikat nilai yang seharusnya tidak boleh dipilih ulang setelah challenge diketahui.

Guard berbasis alamat IP pengirim runtuh begitu ada SSRF di service yang sama — dan kesalahan yang sama bisa terulang persis di wave berikutnya kalau tambalannya cuma menambah entri ke blacklist yang sudah terbukti gagal, bukan mengganti pendekatannya.

Kecepatan patch sama pentingnya dengan kecepatan exploit. Box sendiri sempat bocor sebelum sempat ditambal untuk Canting VM — dan PCAP membuktikan lawan langsung memanfaatkannya. Di attack/defense, celah yang belum ditambal bukan cuma milik sendiri untuk dieksploitasi.