Jigsaw
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?
$ ls tags/ techniques/
Jigsaw — HackTheBox
Description
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).
Analysis
Initial Reconnaissance
- Extracted APK (which is a ZIP) and identified it as a Flutter application
- Key files discovered:
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 functionspartthree_1andpartthree_2classes3.dex— Java/Kotlin classes:MainActivity.kt,piecesOf.kt
Architecture: 3-Layer Key Assembly
The 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]
Dart Source Recovery (kernel_blob.bin)
Using strings on kernel_blob.bin, full Dart source code was recovered across three files:
package:jigsaw/main.dart— Login UI, callsFinally.decryptFlag()package:jigsaw/flag.dart— Contains encrypted flag base64:aZ/KF0GsnN81j5XStQyKz3vXtktTVN5zFqy5lwTmub6fx5w70c+p08O0OWcn/9nhpackage:jigsaw/services.dart— ContainsAESCombinedServicewhich assembles the AES key/IV from 3 parts
The 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.
Solution
Part 1: Dart — Hardcoded + Deterministic Shuffle
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]
Part 2: Java/Kotlin — piecesOf Class with Signed Arithmetic Shift
Decompiled classes3.dex with jadx. The piecesOf class:
- Has hardcoded byte arrays
oB1,oB2,xP1,xP2 - Applies
tB(input, pattern): for each byte, XOR with pattern then rotate right by 3 bits - The
rRfunction:(byte) ((v >> c) | (v << (8 - c)))— rotate right
Critical 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)
Part 3: Native Library — libmenascyber.so
Disassembled the x86_64 .so with objdump:
partthree_1()callsrandFunc2(data@0x5c0, output, xorkey@0x600, 32)— returns 32-byte keypartthree_2()callsrandFunc2(data@0x5e0, output, xorkey@0x5f0, 16)— returns 16-byte IVrandFunc2does:output[i] = ROR(input[i] ^ xorkey[i], 3)— same XOR+rotate pattern
But 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
Final Decryption
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)
Lessons Learned
- Flutter debug builds leak full Dart source in
kernel_blob.bin— always check withstrings - Java signed vs unsigned shifts — The
>>operator in Java sign-extends, while native code withmovzbldoes unsigned operations. This subtle difference was the main "gotcha" of the challenge - Three-layer architecture — Deliberately splits secrets across Dart, JVM, and native to require proficiency in all three RE domains
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [mobile][free]APKey— HackTheBox
- [mobile][free]Protected— HackTheBox
- [mobile][Pro]Infinity Bank— HackTheBox
- [reverse][free]SAW— hackthebox
- [crypto][free]Cryptohorrific— hackthebox