SNI CTF 2025 - Reverse Engineering
againN2
Intro
Challenge againN2 tampil sederhana: arsip dist.zip berisi satu ELF main dan file r yang tampak seperti ciphertext. Ketika dijalankan, binary cuma minta input dan mengembalikan Encoded message: 1 untuk input apa pun. Kedengarannya seperti encoder 1 arah — tapi kita diminta “solve it”, berarti yang disimpan di r adalah flag yang dikodekan oleh binary ini. Mari buka dan bedah.
Recon: “ini encoder apa sih?”
Isi folder setelah ekstrak:
$ unzip dist.zip
$ ls
main r
$ cat r
tRDyU3W3Uu3/3SodS33UdSo/mhu8sFW8/WF/Md8uwBGk
main adalah PIE 64-bit yang sudah di-strip:
$ file main
ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped
Running tanpa argumen:
$ ./main
Enter message: test
Encoded message: BMm
Outputnya pendek, jadi kemungkinan setiap karakter input dipetakan per-karakter ke tabel tertentu. Waktunya melihat ASM.
Bedah singkat .text: bit swap + tabel 62-char
objdump -d main menunjukkan tiga blok penting:
- Fungsi mungil di 0x11c9 — ini memanipulasi bit input sebelum dipakai:
11c9: mov edi, edi
11d6: shr al, 1 ; b1 = (x>>1) & 1
11e6: shr al, 5 ; b5 = (x>>5) & 1
11ef: cmp [rbp-0x1], al ; bandingkan b1 dan b5
11f8: xorb $0x2, [rbp-0x14] ; toggle bit1 jika beda
11fc: xorb $0x20, [rbp-0x14] ; toggle bit5 jika beda
1200: movzx eax, BYTE PTR [rbp-0x14]
Pseudocode-nya:
uint8_t twiddle(uint8_t x) {
uint8_t b1 = (x >> 1) & 1;
uint8_t b5 = (x >> 5) & 1;
if (b1 != b5) {
x ^= 0x02; // flip bit1
x ^= 0x20; // flip bit5
}
return x;
}
- Encoder utama di 0x1206 — melintasi string input dan mengisi buffer output:
1221: call 1090 <strlen@plt> ; len = strlen(msg)
1239: ... movzx eax, BYTE PTR [rax] ; ambil char
124e: call 11c9 <twiddle>
1256: and eax, 0x3f ; pakai 6 bit
127a: lea rcx, [rip+0xd9f] ; 0x2020
1281: movzx eax, BYTE PTR [rax+rcx] ; lookup tabel
1285: mov BYTE PTR [rdx], al ; tulis output
1293: ... ; loop sampai len
12a0: mov BYTE PTR [rax], 0 ; null-terminate
- Main di 0x12aa — alur program:
char in[0x100], out[0x100];
printf("Enter message: ");
fgets(in, 0x100, stdin);
in[strcspn(in, "\n")] = 0; // buang newline
encode(in, out); // fungsi di 0x1206
printf("Encoded message: %s\n", out);
Tabel lookup berada di .rodata pada offset 0x2020:
Z1aB2bC3cD4dE5eF6fG7gH8hI9iJ0jKkLlMmNnOoPpQqRrSsTtUuVvWwXxYy+/
Total 62 karakter (angka 62 dan 63 tidak dipakai karena hasil twiddle(x) & 0x3f tidak pernah 62–63).
Memutar balik encoder: cari plaintext dari r
Encoder bersifat deterministik per-karakter:
out[i] = table[ twiddle(in[i]) & 0x3f ]
Untuk me reverse, cukup:
- Ambil karakter output
c. - Cari indeks
idx = table.index(c). - Enumerasi
xyang mungkin di ASCII printable, pilih yang memenuhi(twiddle(x) & 0x3f) == idx.
Karena twiddle hanya menukar bit1/bit5 ketika beda, setiap idx punya 1–2 kandidat. Sudah cukup untuk brute-force dengan preferensi karakter alfanumerik/kronologis.
alphabet = "Z1aB2bC3cD4dE5eF6fG7gH8hI9iJ0jKkLlMmNnOoPpQqRrSsTtUuVvWwXxYy+/"
def twiddle(x: int) -> int:
b1 = (x >> 1) & 1
b5 = (x >> 5) & 1
if b1 != b5:
x ^= 0x02
x ^= 0x20
return x
def decode_char(c):
idx = alphabet.index(c)
return [chr(x) for x in range(32, 127) if (twiddle(x) & 0x3f) == idx]
def decode(s):
res = []
for ch in s:
cand = decode_char(ch)
# pilih yang kelihatan “masuk akal”: huruf/angka/braces
res.append(cand[0])
return "".join(res)
enc = "tRDyU3W3Uu3/3SodS33UdSo/mhu8sFW8/WF/Md8uwBGk"
print(decode(enc))
Output langsung membentuk flag yang readable:
SNI{reverse_engineering_custom64_vm_bitswap}
Flag
SNI{reverse_engineering_custom64_vm_bitswap}
Jajajaja
Intro
Challenge Jajajaja ini kelihatannya simpel banget: satu Jajajaja.exe, kalau dijalankan muncul jendela “activation” ala software bajakan, dan kita diminta masukin license key berbentuk empat blok hex yang dipisah tanda minus.
Di permukaan, ini terasa seperti “product key validator” biasa. Tapi begitu dibongkar, ternyata ada:
- Sebuah fungsi bit‑twiddling yang agresif di Java bytecode,
- Sebuah panel “SUCCESS!” yang nyembunyiin dekripsi ChaCha20,
- Dan satu environment variable yang sengaja diselipin di dalam stub Windows launcher.
Di write-up ini aku ajak kamu jalan pelan tapi tajam: mulai dari recon, reversing KeyValidator pakai Z3, ngupas Flag + ChaCha20, sampai akhirnya keluar flag yang sebenarnya.
Recon: “.exe” tapi isinya Java?
Mulai dari yang paling basic:
$ ls
Jajajaja.exe
$ file Jajajaja.exe
Jajajaja.exe: Zip archive, with extra data prepended
.exe tapi tips dari file bilang ini ZIP dengan “extra data” di depan — klasik Launch4j-style wrapper buat Java .jar.
List isi arsipnya:
$ unzip -l Jajajaja.exe
Archive: Jajajaja.exe
warning [Jajajaja.exe]: 63488 extra bytes at beginning or within zipfile
Length Date Time Name
--------- ---------- ----- ----
1891 ... com/flab/jajajaja/ChaCha20.class
793 ... com/flab/jajajaja/CodeUI$1.class
3471 ... com/flab/jajajaja/CodeUI.class
2430 ... com/flab/jajajaja/Flag.class
586 ... com/flab/jajajaja/Jajajaja.class
1511 ... com/flab/jajajaja/KeyValidator.class
...
Extract dulu:
$ unzip -o Jajajaja.exe -d extracted
Struktur package-nya rapi: Jajajaja sebagai entry point, CodeUI buat UI activation, KeyValidator buat cek key, Flag buat tampilan sukses, dan ChaCha20 yang kelihatan “lebih penting” dari namanya.
Melihat UI & alur besar
Kita nggak butuh full decompiler dulu; cukup javap untuk ngintip bytecode dan struktur class.
Entry point:
$ javap -classpath extracted -c com.flab.jajajaja.Jajajaja
Compiled from "Jajajaja.java"
public class com.flab.jajajaja.Jajajaja {
public static void main(java.lang.String[]);
Code:
0: new #7 // class com/flab/jajajaja/Jajajaja$1
3: dup
4: invokespecial #9 // Method com/flab/jajajaja/Jajajaja$1."<init>":()V
7: invokestatic #10 // Method java/awt/EventQueue.invokeLater:(Ljava/lang/Runnable;)V
10: return
}
Inner class Jajajaja$1 ternyata cuma bikin JFrame dan mengisi konten dengan CodeUI:
$ javap -classpath extracted -c com.flab.jajajaja.Jajajaja\$1
...
public void run();
Code:
0: new #7 // class javax/swing/JFrame
3: dup
4: ldc #9 // String Jajajaja Activator
...
50: aload_1
51: new #43 // class com/flab/jajajaja/CodeUI
54: dup
55: invokespecial #45 // Method com/flab/jajajaja/CodeUI."<init>":()V
58: invokevirtual #46 // Method javax/swing/JFrame.add:(Ljava/awt/Component;)Ljava/awt/Component;
61: pop
...
Lanjut ke CodeUI, bagian yang menarik ada di handler tombol ACTIVATE:
$ javap -classpath extracted -c -p com.flab.jajajaja.CodeUI | sed -n '260,520p'
...
private void activateButtonActionPerformed(java.awt.event.ActionEvent);
Code:
0: aload_0
1: getfield #34 // keyField
4: invokevirtual #135 // JTextField.getText:()Ljava/lang/String;
7: invokevirtual #139 // String.trim:()Ljava/lang/String;
10: astore_2
11: aload_2
12: invokestatic #144 // KeyValidator.validate:(Ljava/lang/String;)Z
15: ifeq 56
18: aload_0
19: invokestatic #150 // SwingUtilities.getWindowAncestor
...
33: aload_3
34: new #167 // new Flag()
37: dup
38: invokespecial #169 // Flag.<init>
41: invokevirtual #170 // JFrame.add(Component)
...
56: ... setText("Invalid License Key. Please try again.")
Jadi alurnya:
- User input license key di
keyField. KeyValidator.validate(key)dipanggil.- Kalau valid → frame di-wipe dan diisi panel
Flag, kalau tidak → pesan error merah.
Artinya, seluruh logika RE‑nya ada di dua kelas:
KeyValidator(cek key)Flag+ChaCha20(apa yang ditampilkan setelah valid).
Reversing KeyValidator: constraint fest
Mari fokus ke fungsi statis validate(String):
$ javap -classpath extracted -c com.flab.jajajaja.KeyValidator
Potongan pentingnya:
public static boolean validate(java.lang.String);
Code:
0: aload_0
1: ifnull 13
4: aload_0
5: invokevirtual #7 // String.length()
8: bipush 35
10: if_icmpeq 15
13: iconst_0
14: ireturn
15: aload_0
16: ldc #13 // "-"
18: invokevirtual #15 // String.split("-")
21: astore_1
22: aload_1
23: arraylength
24: iconst_4
25: if_icmpeq 30
28: iconst_0
29: ireturn
Sampai sini, formatnya jelas:
- Panjang string harus 35,
- Format:
xxxxxxxx-xxxxxxxx-xxxxxxxx-xxxxxxxx(4 blok, 8 hex per blok → 4×8 + 3-= 35).
Lanjut sedikit lagi — di sini mulai menarik:
30: aload_1
31: iconst_0
32: aaload
33: bipush 16
35: invokestatic #19 // Long.parseLong(part0, 16)
38: lstore_2 // a
39: aload_1
40: iconst_1
41: aaload
42: bipush 16
44: invokestatic #19 // Long.parseLong(part1, 16)
47: lstore 4 // b
49: aload_1
50: iconst_2
51: aaload
52: bipush 16
54: invokestatic #19 // part2
57: lstore 6 // c
59: aload_1
60: iconst_3
61: aaload
62: bipush 16
64: invokestatic #19 // part3
67: lstore 8 // d
Empat blok hex di-cast ke long 64‑bit (tapi nanti dipotong ke 32‑bit lewat mask). Setelah itu, ada rangkaian check bitwise / aritmetika:
69: lload_2
70: lload 4
72: lxor
73: ldc2_w #25 // 991153055
76: lcmp
77: ifeq 82
80: iconst_0
81: ireturn
82: lload 4
84: lload 6
86: ladd
87: ldc2_w #27 // 4294967295
90: land
91: ldc2_w #29 // 3548082989
94: lcmp
95: ifeq 100
98: iconst_0
99: ireturn
100: lload_2
101: ldc2_w #31 // 4919
104: lmul
105: ldc2_w #27 // 2^32-1
108: land
109: ldc2_w #33 // 2871439159
112: lcmp
113: ifeq 118
116: iconst_0
117: ireturn
Dilanjut:
118: lload 6
120: lload 8
122: land
123: ldc2_w #35 // 3195405
...
132: lload 6
134: lload 8
136: lxor
137: ldc2_w #37 // 2882216434
...
146: lload 4
148: bipush 13
150: lshl
151: lload 4
153: bipush 19
155: lushr
156: lor
157: ldc2_w #27 // & 0xffffffff
160: land
161: lstore 10 // e = rol32(b,13)
163: lload 10
165: ldc2_w #39 // 3735928559
168: lxor
169: ldc2_w #41 // 794719367
...
178: lload_2
179: lload 4
181: ladd
182: lload 6
184: ladd
185: lload 8
187: ladd
188: ldc2_w #43 // 65535
191: land
192: ldc2_w #45 // 31147
...
201: lload_2
202: lload 8
204: lxor
205: invokestatic #47 // Long.bitCount
208: bipush 15
...
215: lload_2
216: bipush 16
218: lushr
219: lload 4
221: bipush 16
223: lushr
224: ladd
225: lload 6
227: bipush 16
229: lushr
230: ladd
231: lload 8
233: bipush 16
235: lushr
236: ladd
237: lstore 12
239: lload 12
241: ldc2_w #43 // & 0xffff
244: land
245: ldc2_w #51 // 26566
...
254: iconst_1
255: ireturn
Kalau kita tulis ulang sebagai pseudocode (alamat di sini aku pake offset bytecode sebagai “address”:
// validate(String key) @ bytecode 0
if (key == null) return false; // 0–13
if (key.length() != 35) return false; // 4–10
parts = key.split("-"); // 15–21
if (parts.length != 4) return false; // 22–29
// parse 4 blok hex
a = parseLong(parts[0], 16); // 30–38
b = parseLong(parts[1], 16); // 39–47
c = parseLong(parts[2], 16); // 49–57
d = parseLong(parts[3], 16); // 59–67
// constraint 1: XOR
if ( (a ^ b) != 991153055 ) return false; // 69–81
// constraint 2: (b+c) mod 2^32
if ( ((b + c) & 0xffffffff) != 3548082989L ) // 82–99
return false;
// constraint 3: (a * 4919) mod 2^32
if ( (a * 4919L & 0xffffffffL) != 2871439159L ) // 100–117
return false;
// constraint 4–5: AND dan XOR antara c dan d
if ( (c & d) != 3195405L ) return false; // 118–131
if ( (c ^ d) != 2882216434L ) return false; // 132–145
// constraint 6: rotasi b, lalu xor dengan 0xDEADBEEF
e = Integer.rotateLeft((int)b, 13) & 0xffffffffL; // 146–161
if ( (e ^ 3735928559L) != 794719367L ) // 163–177
return false;
// constraint 7: jumlah 4 blok (mod 2^16)
if ( ((a + b + c + d) & 0xffffL) != 31147L ) // 178–200
return false;
// constraint 8: bitcount(a ^ d) == 15
if ( Long.bitCount(a ^ d) != 15 ) // 201–214
return false;
// constraint 9: jumlah upper 16 bit dari tiap blok (mod 2^16)
sum_hi = (a >>> 16) + (b >>> 16) + (c >>> 16) + (d >>> 16); // 215–237
if ( (sum_hi & 0xffffL) != 26566L ) return false; // 239–249
return true; // 254–255
Secara manual ini bisa di-massage jadi sistem persamaan mod 2^32, tapi jauh lebih enak dicolok ke solver SMT. Di sini aku pakai Z3 lewat Python.
Memecahkan license key dengan Z3
Kita treat a, b, c, d sebagai 32‑bit unsigned dan langsung encode semua constraint di atas:
from z3 import *
MASK32 = (1 << 32) - 1
a, b, c, d = [BitVec(v, 32) for v in 'abcd']
s = Solver()
s.add((a ^ b) == 991153055)
s.add((b + c) & MASK32 == 3548082989)
s.add((a * 4919) & MASK32 == 2871439159)
s.add((c & d) == 3195405)
s.add((c ^ d) == 2882216434)
rot = ((b << 13) | LShR(b, 19)) & MASK32
s.add((rot ^ 3735928559) == 794719367)
s.add((a + b + c + d) & 0xffff == 31147)
x = a ^ d
pop = Sum([ZeroExt(32-1, Extract(i, i, x)) for i in range(32)])
s.add(pop == 15)
s.add(((LShR(a,16) + LShR(b,16) + LShR(c,16) + LShR(d,16)) & 0xffff) == 26566)
print(s.check())
if s.check() == sat:
m = s.model()
vals = [m[v].as_long() & MASK32 for v in (a, b, c, d)]
print('hex:')
for v in vals:
print(f"{v:08x}")
Jalankan:
$ python solve_key.py
sat
hex:
68544401
53478f9e
8033e38f
2bf8c27d
Jadi license key yang valid adalah:
68544401-53478F9E-8033E38F-2BF8C27D
Sampai sini, kita sudah bisa “mengaktifkan” software. Tapi itu belum flag — panel Flag nunjukin sesuatu yang lain.
Panel Flag & ChaCha20: di mana flag sebenarnya?
Sekarang saatnya ngintip kelas Flag:
$ javap -classpath extracted -c -p com.flab.jajajaja.Flag
Compiled from "Flag.java"
public class com.flab.jajajaja.Flag extends javax.swing.JPanel {
private javax.swing.JTextField flagField;
private javax.swing.JLabel successLabel;
...
private void initComponents();
Code:
0: aload_0
1: new #21 // JLabel successLabel
...
186: aload_0
187: getfield #31 // flagField
190: ldc #83 // "4cyqC2Y5nLRYn/XbyB4xg25Ie0oi3Y+4LR1YWDA="
192: ldc #85 // "oqKbQ+ltdeq80Mxk"
194: sipush 1337
197: invokestatic #87 // ChaCha20.decrypt(String,String,int)
200: invokevirtual #93 // JTextField.setText(String)
203: goto 223
206: astore_1
...
Yang menarik:
flagFielddibuat non‑editable dan diformat monospaced.setText()diisi dengan hasilChaCha20.decrypt(...).- Argumen decrypt:
- Ciphertext (Base64):
4cyqC2Y5nLRYn/XbyB4xg25Ie0oi3Y+4LR1YWDA= - Nonce (Base64):
oqKbQ+ltdeq80Mxk - Counter:
1337
- Ciphertext (Base64):
Kalau decrypt gagal, exception message-nya dimasukkan ke text field (ini berguna untuk debugging kalau environment nggak pas).
Sekarang lihat kelas ChaCha20:
$ javap -classpath extracted -c com.flab.jajajaja.ChaCha20
Compiled from "ChaCha20.java"
public class com.flab.jajajaja.ChaCha20 {
public static java.lang.String decrypt(java.lang.String, java.lang.String, int) throws java.lang.Exception;
Code:
0: ldc #9 // String MAKEY
2: invokestatic #11 // System.getenv("MAKEY")
5: astore_3
6: aload_3
7: ifnull 17
10: aload_3
11: invokevirtual #17 // String.isEmpty()
14: ifeq 27
17: new #23 // RuntimeException("Environment variable MAKEY is not set.")
...
27: invokestatic #30 // Base64.getDecoder()
30: aload_3
31: invokevirtual #36 // decode(env)
34: astore 4 // key bytes
36: invokestatic #30 // Base64.getDecoder()
39: aload_1
40: invokevirtual #36 // decode(nonceB64)
43: astore 5 // nonce bytes
45: invokestatic #30 // Base64.getDecoder()
48: aload_0
49: invokevirtual #36 // decode(ctB64)
52: astore 6 // ciphertext bytes
54: ldc #42 // "ChaCha20"
56: invokestatic #44 // Cipher.getInstance
59: astore 7 // cipher
61: new #50 // ChaCha20ParameterSpec
64: dup
65: aload 5 // nonce
67: iload_2 // counter
68: invokespecial #52 // (byte[] nonce, int counter)
71: astore 8
73: new #55 // SecretKeySpec
76: dup
77: aload 4 // key bytes
79: ldc #42 // "ChaCha20"
81: invokespecial #57 // SecretKeySpec(key,"ChaCha20")
84: astore 9
86: aload 7
88: iconst_2 // Cipher.DECRYPT_MODE
89: aload 9
91: aload 8
93: invokevirtual #60 // cipher.init(mode,key,paramSpec)
96: aload 7
98: aload 6
100: invokevirtual #64 // cipher.doFinal(ct)
103: astore 10 // plaintext bytes
105: new #18 // new String(...)
108: dup
109: aload 10
111: invokespecial #68
114: areturn
}
Pseudocode‑nya:
static String decrypt(String ctB64, String nonceB64, int counter) throws Exception {
String env = System.getenv("MAKEY");
if (env == null || env.isEmpty()) {
throw new RuntimeException("Environment variable MAKEY is not set.");
}
byte[] key = Base64.getDecoder().decode(env);
byte[] nonce = Base64.getDecoder().decode(nonceB64);
byte[] ct = Base64.getDecoder().decode(ctB64);
Cipher cipher = Cipher.getInstance("ChaCha20");
ChaCha20ParameterSpec params = new ChaCha20ParameterSpec(nonce, counter);
SecretKeySpec sk = new SecretKeySpec(key, "ChaCha20");
cipher.init(Cipher.DECRYPT_MODE, sk, params);
byte[] pt = cipher.doFinal(ct);
return new String(pt);
}
Artinya:
- Flag dienkripsi dengan ChaCha20,
- Key disimpan di environment variable
MAKEYdalam bentuk Base64, - Nonce dan counter di-hardcode,
- Ciphertext di-hardcode di
Flag.
Berarti kuncinya: cari nilai MAKEY. Dan karena ini Launch4j, kemungkinan besar ada di bagian stub .exe luar.
Menemukan MAKEY di launcher
Balik ke Jajajaja.exe (bukan isi ZIP-nya), kita coba strings:
$ strings -n 4 Jajajaja.exe | rg "MAKEY"
MAKEY=IKMitMLmeZ3uVceCf5s4gyqsFrNls54ml9e9IRWpd9k=
Boom. Launcher‑nya nyalain Java dengan environment:
MAKEY=IKMitMLmeZ3uVceCf5s4gyqsFrNls54ml9e9IRWpd9k=
Jadi:
MAKEY(Base64) → ChaCha20 key,- Nonce dan ciphertext sudah kita tahu dari bytecode
Flag.
Next step: tiru persis implementasi ChaCha20 Java (nonce 12‑byte + counter int) di Python, lalu decrypt.
Decrypt ChaCha20 secara manual
Pertama cek parameter:
$ python - << 'PY'
from base64 import b64decode
key_b64 = 'IKMitMLmeZ3uVceCf5s4gyqsFrNls54ml9e9IRWpd9k='
nonce_b64 = 'oqKbQ+ltdeq80Mxk'
ct_b64 = '4cyqC2Y5nLRYn/XbyB4xg25Ie0oi3Y+4LR1YWDA='
key = b64decode(key_b64)
nonce = b64decode(nonce_b64)
ct = b64decode(ct_b64)
print('key_len', len(key), 'nonce_len', len(nonce), 'ct_len', len(ct))
print('key_hex', key.hex())
PY
Output:
key_len 32 nonce_len 12 ct_len 29
key_hex 20a322b4c2e6799dee55c7827f9b38832aac16b365b39e2697d7bd2115a977d9
Java JCE ChaCha20 (dengan ChaCha20ParameterSpec(byte[] nonce, int counter)) menggunakan:
- 32‑byte key,
- 12‑byte nonce,
- 32‑bit block counter sebagai word ke‑13 dalam state.
Supaya identik, aku implementasi ChaCha20 block function manual sesuai spec:
from base64 import b64decode
import struct
key = b64decode('IKMitMLmeZ3uVceCf5s4gyqsFrNls54ml9e9IRWpd9k=')
nonce = b64decode('oqKbQ+ltdeq80Mxk')
ct = b64decode('4cyqC2Y5nLRYn/XbyB4xg25Ie0oi3Y+4LR1YWDA=')
const = b"expand 32-byte k"
def quarterround(a, b, c, d):
a = (a + b) & 0xffffffff; d ^= a; d = ((d << 16) | (d >> 16)) & 0xffffffff
c = (c + d) & 0xffffffff; b ^= c; b = ((b << 12) | (b >> 20)) & 0xffffffff
a = (a + b) & 0xffffffff; d ^= a; d = ((d << 8) | (d >> 24)) & 0xffffffff
c = (c + d) & 0xffffffff; b ^= c; b = ((b << 7) | (b >> 25)) & 0xffffffff
return a, b, c, d
def chacha20_block(key, counter, nonce):
st = list(
struct.unpack('<4I', const) +
struct.unpack('<8I', key) +
(counter & 0xffffffff,) +
struct.unpack('<3I', nonce)
)
working = st.copy()
for _ in range(10):
# column rounds
working[0], working[4], working[8], working[12] = quarterround(working[0], working[4], working[8], working[12])
working[1], working[5], working[9], working[13] = quarterround(working[1], working[5], working[9], working[13])
working[2], working[6], working[10], working[14] = quarterround(working[2], working[6], working[10], working[14])
working[3], working[7], working[11], working[15] = quarterround(working[3], working[7], working[11], working[15])
# diagonal rounds
working[0], working[5], working[10], working[15] = quarterround(working[0], working[5], working[10], working[15])
working[1], working[6], working[11], working[12] = quarterround(working[1], working[6], working[11], working[12])
working[2], working[7], working[8], working[13] = quarterround(working[2], working[7], working[8], working[13])
working[3], working[4], working[9], working[14] = quarterround(working[3], working[4], working[9], working[14])
out = [(working[i] + st[i]) & 0xffffffff for i in range(16)]
return struct.pack('<16I', *out)
def chacha20_encrypt(key, nonce, counter, data):
out = bytearray()
block_counter = counter
offset = 0
while offset < len(data):
ks = chacha20_block(key, block_counter, nonce)
block = data[offset:offset+64]
out.extend(bytes([b ^ k for b, k in zip(block, ks)]))
offset += 64
block_counter += 1
return bytes(out)
pt = chacha20_encrypt(key, nonce, 1337, ct)
print(pt)
Jalankan:
$ python decrypt_flag.py
b'SNI{r3v_J4v4_L4unch4r_9f2b1e}'
Yang menarik di sini: kita sama sekali nggak perlu menjalankan aplikasinya dengan environment MAKEY asli — cukup treat binary sebagai sumber data, ekstrak parameternya, dan reimplementasi cipher.
Flag
SNI{r3v_J4v4_L4unch4r_9f2b1e}
oh_pints
Intro
Challenge oh_pints ini kelihatannya simpel dan “ramah”: sebuah binary pinst yang nge-render maze di terminal, dan kita “cuma” diminta jalan dari start ke E pakai w/a/s/d. Di permukaan, ini kelihatan kayak game CLI santai buat ngetes kesabaran dan skill pathfinding.
Tapi begitu dibongkar, ternyata isi perutnya lebih menarik: di balik PyInstaller stub ada Python 3.12 yang dikemas, sebuah manager game yang ngurus maze, dan satu fungsi get_flag() yang pakai PRNG linear buat nge-XOR ciphertext panjang. Kita tidak perlu benar‑benar menyelesaikan maze-nya; cukup ngobrol langsung sama bytecode‑nya.
Di write-up ini aku ceritain alurnya: mulai dari recon terhadap binary PyInstaller, bedah bytecode manager.pyc, mengubah ASM (Python bytecode) jadi pseudocode yang enak dibaca, sampai akhirnya brute force seed PRNG dan reconstruct flag‑nya.
Recon: “kok maze-nya dibungkus PyInstaller?”
buka folder challenge:
$ ls
dis312.py
dump_dis.py
main.py
manager.dis.txt
manager.py_failed
opcode312.py
pinst
pinst_extracted
__pycache__
pyinstxtractor.py
venv
pinst jelas kandidat utama. Cek tipenya:
$ file pinst
pinst: ELF 64-bit LSB executable, x86-64, dynamically linked, ...
Kalau dijalankan, kita langsung disambut maze ASCII:
$ ./pinst
---------------------------------------------------------------------------
| ? ? ? ? ? ? ? ? ... |
---------------------------------------------------------------------------
Moves: 0
Welcome to the Maze Challenge! Navigate to 'E'.
Use 'w' (up), 'a' (left), 's' (down), 'd' (right) to move. Type 'q' to quit.
Enter your move (w/a/s/d) or 'q' to quit:
Satu step salah / keluar dari batas, langsung mati dengan exit code negatif (nanti kelihatan di bytecode ada panggilan ke os._exit(-1)). Jadi secara “intended gameplay”, kita disuruh cari path valid dari start ke end, dan ketika sampai E baru get_flag() dipanggil.
Kalau lihat isi pinst_extracted/, kelihatan banget kalau ini binary PyInstaller:
$ file pinst_extracted/*
...
pinst_extracted/main.pyc: Byte-compiled Python module for CPython 3.12
pinst_extracted/PYZ.pyz: data
pinst_extracted/PYZ.pyz_extracted: directory
...
Di dalam PYZ.pyz_extracted kita menemukan semua modul Python yang dipak:
$ ls pinst_extracted/PYZ.pyz_extracted
...
manager.pyc
render.pyc
tile.pyc
...
Jadi target sesungguhnya ada di manager.pyc – modul yang nge-manage state permainan dan flag‑nya.
Bytecode 3.12: decompiler nyerah, disassembler masuk
Kalau coba decompile pakai tool Python yang belum siap 3.12, hasilnya cuma:
# main.py
Unsupported Python version, 3.12.0, for decompilation
Untungnya, author sudah sekalian ngasih kita disassembly siap pakai di manager.dis.txt (hasil pydisasm). Ini bentuknya semacam “ASM versi Python bytecode”: ada konstanta, nama variabel, dan instruksi per offset.
Bagian awal manager.dis.txt nunjukin struktur modul:
# Method Name: <module>
# Filename: manager.py
...
# 4: <Code311 code object Manager at 0x7f474761e360, file manager.py>, line 5
...
5: 42 PUSH_NULL
44 LOAD_BUILD_CLASS
46 LOAD_CONST (<Code311 code object Manager ...>, line 5)
48 MAKE_FUNCTION (No arguments)
50 LOAD_CONST ("Manager")
52 CALL 2
60 STORE_NAME (Manager)
Jadi entry point-nya adalah class Manager, dengan beberapa method:
__init__– bikin maze, set posisi pemain, dan_move_count.move– handle inputw/a/s/ddan increment_move_count.check_win– cek apakah posisi pemain sudah dienddan kalau iya, panggilget_flag.get_flag– fungsi yang kita incar.
Sedikit kita lihat __init__ untuk konteks:
# Method Name: __init__
...
7: 2 LOAD_GLOBAL (NULL + Tile)
12 LOAD_FAST (x)
14 LOAD_FAST (y)
16 CALL 2
24 LOAD_FAST (self)
26 STORE_ATTR (_tile)
...
15: 272 LOAD_CONST (0)
274 LOAD_FAST (self)
276 STORE_ATTR (_move_count)
...
17: 286 LOAD_FAST (self)
288 LOAD_ATTR (NULL|self + render_game)
308 CALL 0
316 POP_TOP
318 RETURN_CONST (None)
Garis besarnya:
class Manager:
def __init__(self, x, y, moves):
self._tile = Tile(x, y)
self._tile.init_zeros()
if not self._tile.generate(target_path_length=moves):
raise ValueError("Could not generate a maze")
self._render = Render(self._tile)
self._player_pos = self._tile.start
self._move_count = 0
self.render_game()
Artinya: seed PRNG di get_flag() nanti adalah _move_count, yaitu banyaknya langkah valid yang kita lakukan sampai goal. Kita tidak tahu nilainya di awal, dan gameplay normal memaksa kita jalan sendiri di maze. Tapi dari sisi RE, kita bisa treat _move_count sebagai integer seed yang bisa kita brute force.
Address & ASM: bedah Manager.get_flag
Sekarang fokus ke fungsi utama:
# Method Name: get_flag
# Filename: manager.py
# First Line: 19
# Constants:
# 1: '4bb6b048334940fa92f7fef985b9aa93eb1c70b44ed1bfec2045bb545b46a9b76eb6902d41b6b9334548773ef2a654c371ff9694e8e9fa'
...
19: 2 RESUME 0
20: 4 LOAD_GLOBAL (NULL + bytearray)
14 LOAD_GLOBAL (bytes)
24 LOAD_ATTR (NULL|self + fromhex)
44 LOAD_CONST ("4bb6b0...e9fa")
46 CALL 1
54 CALL 1
62 STORE_FAST (c)
22: 64 LOAD_GLOBAL (NULL + print)
74 LOAD_CONST ("Waiting...")
76 CALL 1
84 POP_TOP
23: 86 LOAD_GLOBAL (NULL + range)
96 LOAD_GLOBAL (NULL + len)
106 LOAD_FAST (c)
108 CALL 1
116 CALL 1
124 GET_ITER
126 FOR_ITER (to 184)
130 STORE_FAST (i)
24: 132 LOAD_FAST (c)
134 LOAD_FAST (i)
136 COPY 2
138 COPY 2
140 BINARY_SUBSCR ; ambil c[i]
144 PUSH_NULL
146 LOAD_CLOSURE (self)
148 BUILD_TUPLE 1
150 LOAD_CONST (<Code311 code object <lambda> ..., line 24)
152 MAKE_FUNCTION (closure)
154 LOAD_FAST (i)
156 LOAD_CONST (10000000)
158 BINARY_OP (+)
162 CALL 1 ; lambda(i+10_000_000)
170 BINARY_OP (^=) ; c[i] ^= ...
174 SWAP
176 SWAP
178 STORE_SUBSCR ; tulis balik ke c[i]
>> 182 JUMP_BACKWARD (to 126)
...
26: 186 LOAD_GLOBAL (NULL + print)
196 LOAD_CONST ("Flag: ")
198 LOAD_FAST (c)
200 LOAD_ATTR (NULL|self + decode)
220 LOAD_CONST ("latin-1")
222 CALL 1
230 FORMAT_VALUE 0
232 BUILD_STRING 2
234 CALL 1
>> 242 POP_TOP
244 RETURN_CONST (None)
Kalau kita tulis dalam pseudocode Python:
def get_flag(self):
# ciphertext disimpan sebagai hex string
c = bytearray(bytes.fromhex(
"4bb6b048334940fa92f7fef985b9aa93eb1c70b44ed1bfec2045bb545b46a9b"
"76eb6902d41b6b9334548773ef2a654c371ff9694e8e9fa"
))
print("Waiting...")
for i in range(len(c)):
# perhatikan: lambda di-capture dengan self (buat pakai _move_count)
c[i] ^= self._lambda(i + 10_000_000) & 0xFF
print("Flag:", c.decode("latin-1"))
Yang menarik justru lambda yang dipakai buat menghasilkan keystream–nya. Di bagian paling bawah file disassembly:
# Method Name: <lambda>
# First Line: 24
# Constants:
# 1: -1
# 2: 7438
# 3: 9332
# 4: 14837
...
24: 2 RESUME 0
4 LOAD_DEREF (self)
6 LOAD_ATTR (_move_count)
26 BUILD_LIST 1
28 COPY 1
30 STORE_FAST (s)
32 LOAD_GLOBAL (NULL + range)
42 LOAD_FAST (n)
44 CALL 1
52 GET_ITER
...
>> 62 FOR_ITER (to 128)
66 STORE_FAST (_)
68 LOAD_FAST (s)
70 LOAD_ATTR (NULL|self + append)
90 LOAD_FAST (s)
92 LOAD_CONST (-1)
94 BINARY_SUBSCR ; s[-1]
98 LOAD_CONST (7438)
100 BINARY_OP (*)
104 LOAD_CONST (9332)
106 BINARY_OP (+)
110 LOAD_CONST (14837)
112 BINARY_OP (%)
116 CALL 1 ; s.append(...)
124 LIST_APPEND 2
>> 126 JUMP_BACKWARD (to 62)
128 END_FOR
...
134 BUILD_LIST 2
136 LOAD_CONST (0)
138 BINARY_SUBSCR
142 LOAD_CONST (-1)
144 BINARY_SUBSCR
148 RETURN_VALUE
Pseudocode‑nya:
def stream_value(self, n: int) -> int:
s = [self._move_count] # seed = banyak langkah valid
for _ in range(n):
s.append((s[-1] * 7438 + 9332) % 14837)
return s[-1]
Jadi get_flag() itu kira-kira:
def get_flag(self):
c = bytearray.fromhex(HEX)
for i in range(len(c)):
keystream = stream_value(self, i + 10_000_000)
c[i] ^= keystream & 0xFF
print("Flag:", c.decode("latin-1"))
Kombinasi konstanta 7438, 9332, 14837 ini klasik PRNG linear (linear congruential generator) dalam bentuk:
s_{k+1} = (a * s_k + b) mod mdengan a = 7438, b = 9332, m = 14837.
Seed awalnya s_0 = _move_count, dan tiap byte flag pakai s_n dengan n = i + 10_000_000. Tantangannya: kita tidak tahu seed-nya, tapi modulonya kecil (14837), sehingga ruang seed cuma 0–14836. Ini sangat brute‑forceable.
Strategi: brute force seed, bukan maze
Pilihan kita:
- Main maze beneran, jalan sampai goal, dan amati berapa
_move_countsaatcheck_win()terpenuhi. Secara gameplay, ini mungkin panjang dan riskan karena salah satu langkah ke dinding langsungos._exit(-1). - Perlakukan
_move_countsebagai seed PRNG dan brute force semua kemungkinan 0..14836 sampai ciphertext berubah menjadi string yang masuk akal (ASCII printable, dan mudah‑mudahan ada patternSNI{).
Karena LCG‑nya relatif kecil, opsi (2) jauh lebih menarik.
Masalahnya: stream_value(self, n) butuh iterasi sebanyak n dan n di sini sekitar 10 juta per byte. Kalau kita jalankan literally seperti pseudocode di atas untuk tiap byte dan tiap seed, bakal lama sekali.
Triknya adalah membaca lambda sebagai komposisi fungsi affine:
- Definisikan
f(x) = (a*x + b) mod m. stream_value(n)sebenarnya adalahfyang dikomposisikan n kali terhadap seed awal. Artinya, ada pasangan(A_n, B_n)sehingga:
f^n(x) = (A_n * x + B_n) mod m
Kalau (A_n, B_n) bisa dihitung cepat (pakai binary exponentiation untuk fungsi affine), kita bisa langsung mendapat nilai keystream untuk seed apa pun tanpa perlu loop 10 juta kali.
Dari ASM ke rumus: exponentiation by squaring buat LCG
Secara matematis:
- Kalau
f(x) = a*x + b(mod m), - Maka:
f(f(x)) = a*(a*x + b) + b = a^2 * x + a*b + b- Komposisi dua affine
A1*x + B1danA2*x + B2menghasilkan:A = A2 * A1B = A2 * B1 + B2
Ini bisa dipakai di exponentiation by squaring: kita treat f sebagai “basis” dengan pasangan (baseA, baseB), lalu pakai bit‑decomposition n untuk menghitung (A_n, B_n) dalam O(log n).
Implementasi Python yang dipakai:
def affine_pow(a, b, n, m):
# f(x) = a*x + b (mod m)
# return (A, B) s.t. f^n(x) = A*x + B (mod m)
A, B = 1, 0 # identitas: x -> x
baseA, baseB = a % m, b % m
while n > 0:
if n & 1:
# kompon basis ke (A,B)
A, B = (baseA * A) % m, (baseA * B + baseB) % m
# square basis: f^{2k}
baseA, baseB = (baseA * baseA) % m, (baseA * baseB + baseB) % m
n >>= 1
return A, B
Keystream untuk posisi ke‑i dan seed s0 kemudian:
n = i + 10_000_000
A, B = affine_pow(a, b, n, m)
val = (A * s0 + B) % m
byte = val & 0xFF
Dengan ini, kita bisa:
- Precompute
(A_i, B_i)untuk semua posisi byte ciphertext (panjangnya pendek), satu kali saja. - Untuk setiap candidate seed
s0di 0..14836, kita cek beberapa byte pertama plaintext: harus ASCII printable, dan idealnya cocok patternSNI{di awal.
Script brute force: cari seed yang bikin SNI{…}
Ciphertext-nya diambil langsung dari konstanta hex di get_flag:
from binascii import unhexlify
hexstr = '4bb6b048334940fa92f7fef985b9aa93eb1c70b44ed1bfec2045bb545b46a9b' \
'76eb6902d41b6b9334548773ef2a654c371ff9694e8e9fa'
ct = bytearray(unhexlify(hexstr))
m = 14837
a = 7438
b = 9332
Lalu brute force seed:
from string import printable
def affine_pow(a, b, n, m):
A, B = 1, 0
baseA, baseB = a % m, b % m
while n > 0:
if n & 1:
A, B = (baseA * A) % m, (baseA * B + baseB) % m
baseA, baseB = (baseA * baseA) % m, (baseA * baseB + baseB) % m
n >>= 1
return A, B
# precompute (A_i, B_i) untuk tiap posisi byte
AB = []
for i in range(len(ct)):
n = i + 10_000_000
AB.append(affine_pow(a, b, n, m))
candidates = []
for seed in range(m): # 0..14836
ok = True
out0 = []
for i in range(6): # cek 6 byte pertama
A, B = AB[i]
val = (A * seed + B) % m
p = ct[i] ^ (val & 0xFF)
if p not in range(32, 127): # ASCII printable
ok = False
break
out0.append(chr(p))
if ok:
candidate = ''.join(out0)
# filter yang kelihatan plausible
if candidate.startswith('S') or candidate.startswith('SNI{'):
candidates.append((seed, candidate))
print('candidate count:', len(candidates))
for s, cand in candidates[:50]:
print(s, cand)
output pentingnya:
candidate count: 19
...
12201 SNI{N1
...
Dari semua kandidat, seed 12201 langsung standout: plaintext mulai dengan SNI{N1, sangat mirip format flag. Itu cukup kuat sebagai hipotesis bahwa:
_move_countpada saat mencapai goal (dan memanggilget_flag) adalah 12201.
Kita tidak perlu benar‑benar membuktikan ini dengan menyelesaikan maze; cukup pakai seed tersebut untuk mendekripsi seluruh ciphertext.
Dekripsi final: reconstruct flag
Dengan seed 12201 di tangan, tinggal satu langkah: generate keystream penuh dan XOR dengan ciphertext:
from binascii import unhexlify
hexstr = '4bb6b048334940fa92f7fef985b9aa93eb1c70b44ed1bfec2045bb545b46a9b' \
'76eb6902d41b6b9334548773ef2a654c371ff9694e8e9fa'
ct = bytearray(unhexlify(hexstr))
m = 14837
a = 7438
b = 9332
seed = 12201
def affine_pow(a, b, n, m):
A, B = 1, 0
baseA, baseB = a % m, b % m
while n > 0:
if n & 1:
A, B = (baseA * A) % m, (baseA * B + baseB) % m
baseA, baseB = (baseA * baseA) % m, (baseA * baseB + baseB) % m
n >>= 1
return A, B
AB = []
for i in range(len(ct)):
n = i + 10_000_000
AB.append(affine_pow(a, b, n, m))
pt_bytes = []
for i, c in enumerate(ct):
A, B = AB[i]
val = (A * seed + B) % m
pt_bytes.append(c ^ (val & 0xFF))
pt = bytes(pt_bytes)
print(pt)
Output‑nya:
b'SNI{N1c3_0ne_Y0u_S0lV3d_Th3_M4zeD_Th3_Fl4g_1s_Th3_Fl4g}'
Tanpa perlu satu pun langkah valid di maze, kita langsung “teleport” ke akhir dengan memanfaatkan struktur PRNG dan ukuran modulus yang kecil.
Flag
SNI{N1c3_0ne_Y0u_S0lV3d_Th3_M4zeD_Th3_Fl4g_1s_Th3_Fl4g}