HTB: Jigsaw Challenge

Jigsaw - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameJigsaw
CategoryMobile / Misc
DifficultyEasy
Authord3vn0mi

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:

Terminal window
# Recover the real APK from the password-protected archive
unzip -P hackthebox Jigsaw.apk.zip

Recon: 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:

Terminal window
# Extract the APK and pull embedded Dart file URIs out of the kernel blob
unzip -oq Jigsaw.apk -d apk
strings -n 8 apk/assets/flutter_assets/kernel_blob.bin \
| grep -oE "file:///[^ ]*\.dart" \
| sort -u \
| grep -v pub-cache

This 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:

Terminal window
jadx -d jadx_out --no-res Jigsaw.apk

This 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 ELFFile
import 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:

PartSourceContribution
onePure Dart (lib/flag.dart)List.generate sequences, rotated by shift 5 / 3
twoKotlin MethodChannel('parttwo')piecesOf.tB(oB, xP) → rR(oB ^ xP, 3)
threeNative libmenascyber.sorandFunc2 applied over .rodata blobs
# Combine the three fragments into the final key/IV material
key = p1[0:8] + p2[0:8] + p3[0:16] # 32 bytes -> AES-256
iv = p1[0:4] + p2[0:4] + p3[0:8] # 16 bytes -> CBC IV

The 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 rR operates on a sign-extended Byte: (byte)((v >> c) | (v << (8 - c))). For any v >= 0x80, the arithmetic right-shift on the signed byte pulls in 1 bits instead of 0 bits, so the result is ror8(v, 3) | 0xE0 — not a true bitwise rotate.
  • The C++ randFunc1 in the .so uses movzx (zero-extend) before rotating, producing a genuine ror8(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))) & 0xFF
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
# key/iv assembled from the three jigsaw pieces above
cipher = AES.new(key, AES.MODE_CBC, iv)
flag = unpad(cipher.decrypt(base64.b64decode(encrypted_flag)), 16)
print(flag) # HTB{REDACTED}

Key Steps

  1. 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.
  2. Identify the debug Flutter build — kernel_blob.bin in flutter_assets/ embeds Dart source in cleartext for debug builds; grep it for file:///.../lib/*.dart URIs.
  3. Decompile the DEX with jadx to recover the Kotlin MethodChannel handler (piecesOf.tB).
  4. Disassemble the native .so (libmenascyber.so, x86_64 slice) with capstone + pyelftools to recover randFunc1/randFunc2 and the .rodata seed blobs.
  5. Assemble the AES-256-CBC key/IV from three fragments: pure-Dart rotation, Kotlin platform-channel XOR+rotate, and native rotate over embedded blobs.
  6. 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.
  7. 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 .so disassembly 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 Byte vs. 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.