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.

Service & kerentanan
| # | Service | Stack | Kerentanan inti | Status kita |
|---|---|---|---|---|
| 1 | Canting VM | C (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 |
| 2 | Cytransfer Domain | Python/Flask + kripto | w1: proof Schnorr tak terikat commitment → forgery kepemilikan. w2: VDF Wesolowski l tak diikat hash_to_prime → l=2 bikin persamaan trivial. | ✅ ditambal |
| 3 | Kurir | Python/Flask | w1: SSRF filter blacklist-string → /internal/render pickle.loads = RCE. w2: SSRF kedua /webhook/notify → baca flag mentah. | ✅ ditambal |
| 4 | Evidentia | Python/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.
| Peran | Pegang | Tugas tambahan |
|---|---|---|
| P1 (saya) | Web (utama) — exploit + pegang harness | Attack lead; tambah vektor tiap wave |
| P2 | Web #2 + forensic | Defense semua soal + pantau Live Map + submit flag |
| P3 | Reverse + pwn + mobile | Pivot 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.
| Flag | Letak | Bug |
|---|---|---|
| Seal 1 | SRAM 0x3000 (vault, dari flag.txt) | Verifier jalan satu pass linier, tak mengikuti lompatan |
| Seal 2 | Spool 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/rendermasih 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.

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.