$ cat writeup.md…
$ cat writeup.md…
HackTheBox
A secret lies hidden, protected by layers of logic and scattered clues. Your task is to uncover these fragments, piece them together, and solve the mystery. It's a challenge of patience, creativity, and determination. Can you reveal the secret?
A secret lies hidden, protected by layers of logic and scattered clues. Your task is to uncover these fragments, piece them together, and solve the mystery. It's a challenge of patience, creativity, and determination. Can you reveal the secret?
The challenge provides a password-protected ZIP containing Jigsaw.apk — a Flutter Android application (~72MB).
assets/flutter_assets/kernel_blob.bin — Contains Dart source code (Flutter debug build)lib/x86_64/libmenascyber.so — Small native library (4952 bytes) with exported functions partthree_1 and partthree_2classes3.dex — Java/Kotlin classes: MainActivity.kt, piecesOf.ktThe flag is encrypted with AES-256-CBC. The 32-byte key and 16-byte IV are assembled from three independent sources, each requiring a different reverse engineering approach:
| Layer | Source | Language | Key bytes | IV bytes |
|---|---|---|---|---|
| Part 1 | kernel_blob.bin | Dart | [0:8] | [0:4] |
| Part 2 | classes3.dex | Java/Kotlin | [8:16] | [4:8] |
| Part 3 | libmenascyber.so | C++ (native) | [16:32] | [8:16] |
Combined AES Key (32 bytes):
partone_key[0:8] + parttwo_key[0:8] + partthree_key[0:16]
Combined AES IV (16 bytes):
partone_iv[0:4] + parttwo_iv[0:4] + partthree_iv[0:8]
Using strings on kernel_blob.bin, full Dart source code was recovered across three files:
package:jigsaw/main.dart — Login UI, calls Finally.decryptFlag()package:jigsaw/flag.dart — Contains encrypted flag base64: aZ/KF0GsnN81j5XStQyKz3vXtktTVN5zFqy5lwTmub6fx5w70c+p08O0OWcn/9nhpackage:jigsaw/services.dart — Contains AESCombinedService which assembles the AES key/IV from 3 partsThe Finally class has the decryption logic but the return statement is commented out (//return decrypted;), returning "Developer forgot to uncomment" instead — forcing manual key recovery.
From the Dart source in kernel_blob.bin:
final List<int> _hardcodedKey = List<int>.generate(32, (i) => (i + 1) % 256); final List<int> _hardcodedIV = List<int>.generate(16, (i) => (i + 10) % 256); // _deterministicShuffle rotates the array by `shift` positions final shuffledKey = _deterministicShuffle(_hardcodedKey, 5); // key final shuffledIV = _deterministicShuffle(_hardcodedIV, 3); // iv
The _deterministicShuffle function simply rotates the array left by shift positions.
Result:
partone_key[:8] = [6, 7, 8, 9, 10, 11, 12, 13]partone_iv[:4] = [13, 14, 15, 16]Decompiled classes3.dex with jadx. The piecesOf class:
oB1, oB2, xP1, xP2tB(input, pattern): for each byte, XOR with pattern then rotate right by 3 bitsrR function: (byte) ((v >> c) | (v << (8 - c))) — rotate rightCritical subtlety: Java's >> operator performs sign-extending arithmetic shift right on bytes. For byte values ≥ 0x80, this produces different results than unsigned ROR:
// Java's >> sign-extends: // rR(0xA0, 3) → 0xF4 (arithmetic, sign-extended) // Unsigned ROR would give 0x14 (byte) rR(int v, int c) { return (byte) ((v >> c) | (v << (8 - c))); // ^^^^^ sign-extends for negative bytes! }
The MainActivityKt.get_parttwo() returns:
copyOfRange(parttwo_1, 0, 18) for key (first 8 bytes used)copyOfRange(parttwo_2, 0, 7) for IV (first 4 bytes used)Disassembled the x86_64 .so with objdump:
partthree_1() calls randFunc2(data@0x5c0, output, xorkey@0x600, 32) — returns 32-byte keypartthree_2() calls randFunc2(data@0x5e0, output, xorkey@0x5f0, 16) — returns 16-byte IVrandFunc2 does: output[i] = ROR(input[i] ^ xorkey[i], 3) — same XOR+rotate patternBut the native code uses movzbl (zero-extend) before sarl, making it an unsigned ROR — different from the Java version!
Raw bytes extracted from .rodata section using xxd:
0x5c0 (key data): 02 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f
0x5e0 (iv data): a0 a1 a2 a3 a4 a5 a6 a7 a8 a9 aa ab ac ad ae af
0x5f0 (iv xorkey): 1a 2b 3c 4d 5e 6f 70 81 92 a3 b4 c5 d6 e7 f8 09
0x600 (key xorkey): 5a 6b 7c 8d 9e af b0 c1 d2 e3 f4 05 16 27 38 49
5a 6b 7c 8d 9e af b0 c1 00 00 00 00 00 00 00 00
Combined all three parts into a 32-byte AES key and 16-byte IV, then decrypted the base64-encoded ciphertext using AES-256-CBC with PKCS7 padding removal.
#!/usr/bin/env python3 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 # === Part 1: Dart (deterministic shuffle = left rotation) === key1_full = [(i + 1) % 256 for i in range(32)] iv1_full = [(i + 10) % 256 for i in range(16)] # Rotate left by shift positions key1 = (key1_full[5:] + key1_full[:5])[:8] # [6,7,8,9,10,11,12,13] iv1 = (iv1_full[3:] + iv1_full[:3])[:4] # [13,14,15,16] # === Part 2: Java/Kotlin (XOR + arithmetic ROR3) === def java_ror3(v): """Java-style ROR with sign-extending >> (arithmetic shift)""" v = v & 0xFF if v >= 0x80: shifted = (v >> 3) | 0xE0 # sign-extend else: shifted = v >> 3 rotated = (shifted | ((v & 0x07) << 5)) & 0xFF return rotated # ... (apply tB transform to oB1/oB2 with xP1/xP2 patterns) # key2 = first 8 bytes of transformed parttwo_1 # iv2 = first 4 bytes of transformed parttwo_2 # === Part 3: Native (XOR + unsigned ROR3) === def unsigned_ror3(v): """Unsigned ROR as in native code (movzbl before sarl)""" v = v & 0xFF return ((v >> 3) | ((v & 0x07) << 5)) & 0xFF # key3 = [unsigned_ror3(data[i] ^ xorkey[i]) for i in range(32)][:16] # iv3 = [unsigned_ror3(data[i] ^ xorkey[i]) for i in range(16)][:8] # === Combine and decrypt === # aes_key = bytes(key1 + key2 + key3) # 32 bytes # aes_iv = bytes(iv1 + iv2 + iv3) # 16 bytes ct = base64.b64decode("aZ/KF0GsnN81j5XStQyKz3vXtktTVN5zFqy5lwTmub6fx5w70c+p08O0OWcn/9nh") # cipher = AES.new(aes_key, AES.MODE_CBC, aes_iv) # flag = unpad(cipher.decrypt(ct), AES.block_size).decode() # print(flag)
kernel_blob.bin — always check with strings>> operator in Java sign-extends, while native code with movzbl does unsigned operations. This subtle difference was the main "gotcha" of the challenge$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar