HTB: Jigsaw Challenge
Jigsaw - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Jigsaw |
| Category | Mobile / Misc |
| Difficulty | Easy |
| Author | d3vn0mi |
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 ships an APK (Jigsaw.apk) for a Flutter app called menascyber, built as a jigsaw puzzle: the AES key/IV material needed to decrypt the flag is deliberately split across three independent code paths — pure Dart, a Kotlin platform channel, and a native .so library — and only combining all three correctly yields a valid decryption.
Solution
Artifact recovery
The distributed Jigsaw.apk staged as 0 bytes. Listing the source archive showed the real APK inside a password-protected zip:
# Recover the real APK from the password-protected archiveunzip -P hackthebox Jigsaw.apk.zipRecon: a debug Flutter build leaks its Dart source
Unpacking the APK revealed a Flutter app. Critically, it was a debug build, which means the app’s compiled Dart source is embedded verbatim (unobfuscated, unstripped) inside assets/flutter_assets/kernel_blob.bin. Filtering that blob for embedded file:// URIs (excluding pub-cache/vendor paths) pointed straight at the project’s own sources:
# Extract the APK and pull embedded Dart file URIs out of the kernel blobunzip -oq Jigsaw.apk -d apkstrings -n 8 apk/assets/flutter_assets/kernel_blob.bin \ | grep -oE "file:///[^ ]*\.dart" \ | sort -u \ | grep -v pub-cacheThis surfaced lib/main.dart, lib/flag.dart, and lib/services.dart, dumping as readable Dart source straight out of the blob. Alongside the Dart code sat a small (~5KB) native library, libmenascyber.so, exporting four symbols: randFunc1, randFunc2, partthree_1, partthree_2.
Decompiling the Kotlin platform-channel side
The APK’s DEX was decompiled with jadx to recover the Kotlin MethodChannel('parttwo') handler:
jadx -d jadx_out --no-res Jigsaw.apkThis exposed the piecesOf class, whose tB(oB, xP) method computed rR(oB ^ xP, 3) — an XOR followed by an 8-bit rotate helper rR.
Disassembling the native library
libmenascyber.so shipped in both arm64-v8a and x86_64 variants. The x86_64 slice was disassembled with capstone/pyelftools to recover randFunc1/randFunc2 and locate the .rodata blobs they operated on:
from elftools.elf.elffile import ELFFileimport capstone
f = open('apk/lib/x86_64/libmenascyber.so', 'rb')elf = ELFFile(f)md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
# Locate randFunc1 / randFunc2 in the symbol table, then walk# their instruction stream to identify the rotate operation and# the .rodata offsets they consume.Assembling the jigsaw: three key fragments
The AES-256-CBC key and IV are built from three independently-produced pieces:
| Part | Source | Contribution |
|---|---|---|
| one | Pure Dart (lib/flag.dart) | List.generate sequences, rotated by shift 5 / 3 |
| two | Kotlin MethodChannel('parttwo') | piecesOf.tB(oB, xP) → rR(oB ^ xP, 3) |
| three | Native libmenascyber.so | randFunc2 applied over .rodata blobs |
# Combine the three fragments into the final key/IV materialkey = p1[0:8] + p2[0:8] + p3[0:16] # 32 bytes -> AES-256iv = p1[0:4] + p2[0:4] + p3[0:8] # 16 bytes -> CBC IVThe detail that decided it: two rotates that look the same but aren’t
Both the Kotlin rR and the native randFunc1 are nominally “rotate right by 3 bits” — but they aren’t bit-identical:
- Kotlin’s
rRoperates on a sign-extendedByte:(byte)((v >> c) | (v << (8 - c))). For anyv >= 0x80, the arithmetic right-shift on the signed byte pulls in1bits instead of0bits, so the result isror8(v, 3) | 0xE0— not a true bitwise rotate. - The C++
randFunc1in the.sousesmovzx(zero-extend) before rotating, producing a genuineror8(v, 3).
Assuming both helpers behaved identically (an easy trap, since they’re textually near-identical) produced garbage plaintext. Emulating each rotate exactly as its own language/runtime actually executes it — signed-shift quirk included — was what finally produced a clean AES-CBC decrypt with valid PKCS#7 padding:
def rotate_kotlin_signed(v, c): # Sign-extend v to a signed byte, then arithmetic-shift — matches JVM byte semantics sv = v - 256 if v >= 0x80 else v return ((sv >> c) | (sv << (8 - c))) & 0xFF
def rotate_native_unsigned(v, c): # movzx-based: true unsigned ror8 return ((v >> c) | (v << (8 - c))) & 0xFFfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadimport base64
# key/iv assembled from the three jigsaw pieces abovecipher = AES.new(key, AES.MODE_CBC, iv)flag = unpad(cipher.decrypt(base64.b64decode(encrypted_flag)), 16)print(flag) # HTB{REDACTED}Key Steps
- Recover the artifact — the distributed APK was inside a password-protected zip (
hackthebox); the “0-byte” staged file was a red herring from the staging step, not the challenge. - Identify the debug Flutter build —
kernel_blob.bininflutter_assets/embeds Dart source in cleartext for debug builds; grep it forfile:///.../lib/*.dartURIs. - Decompile the DEX with
jadxto recover the KotlinMethodChannelhandler (piecesOf.tB). - Disassemble the native
.so(libmenascyber.so, x86_64 slice) withcapstone+pyelftoolsto recoverrandFunc1/randFunc2and the.rodataseed blobs. - Assemble the AES-256-CBC key/IV from three fragments: pure-Dart rotation, Kotlin platform-channel XOR+rotate, and native rotate over embedded blobs.
- Faithfully emulate each rotate implementation — the Kotlin version operates on a signed byte (arithmetic shift, not a true rotate for values ≥ 0x80) while the native version zero-extends first; conflating the two silently corrupts the key.
- Decrypt with AES-256-CBC and PKCS#7-unpad to recover the flag.
Tools Used
unzip(password-protected archive extraction)strings/grep(kernel_blob.bin Dart-source recovery)jadx(DEX → Java/Kotlin decompilation)capstone+pyelftools(native.sodisassembly and symbol/section parsing)- Python
pycryptodome(AES-256-CBC decryption, PKCS#7 unpadding)
Key Learnings
- Flutter debug builds leak source. Unlike release builds (which ship a stripped AOT snapshot), debug builds embed the app’s Dart source verbatim inside
kernel_blob.bin— always check the build mode before reaching for a disassembler on Dart logic. - Cross-language “identical” helper functions can diverge on signedness. A rotate implemented on a signed Kotlin
Bytevs. an unsigned/zero-extended C++ byte are not the same function for inputs ≥ 0x80. When a puzzle scatters logic across runtimes, verify each implementation’s exact semantics rather than assuming textual similarity implies behavioral equivalence. - Password-protected distribution archives can masquerade as corrupted artifacts. A 0-byte staged file is worth checking against the original zip listing before assuming the challenge is broken.
- Split-secret designs reward patient reconstruction over shortcuts. Recovering each of the three key fragments independently (Dart, Kotlin, native) and validating the assembled key via PKCS#7 padding correctness was the reliable way to confirm the pieces were combined in the right order and slice widths.