Jigsaw
Jigsaw
Platform: HackTheBox | Category: Mobile | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-02-08 | Status: Solved Techniques: aes_cbc_decryption, flutter_debug_kernel_blob_extraction, java_arithmetic_shift_ror, multi_layer_key_assembly, native_so_disassembly
Summary
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?
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
HackTheBox| ID:20260208_hackthebox_jigsaw - Tags: reverse_engineering, android, apk, aes, kotlin, multi_layer, flutter, dart, native_library
- Indicators: Flutter APK (~72MB), kernel_blob.bin with readable Dart source, libmenascyber.so native library, AES-256-CBC with key split across 3 layers, XOR + rotate-right byte transforms
- Source:
20260208_hackthebox_jigsaw.md
Foothold
Vulnerability / Misconfiguration
- Aes_cbc_decryption
- Flutter_debug_kernel_blob_extraction
- Java_arithmetic_shift_ror
- Multi_layer_key_assembly
- Native_so_disassembly
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- aes_cbc_decryption
- flutter_debug_kernel_blob_extraction
- java_arithmetic_shift_ror
- multi_layer_key_assembly
- native_so_disassembly
- Tags: reverse_engineering, android, apk, aes, kotlin, multi_layer, flutter, dart, native_library
Original Writeup
<details><summary>Click to expand original content</summary>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
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR