← Back to Writeups
HTBN/AMobile

Jigsaw

XESXOR8/23/20267 min read
#mobile#htb#n/a

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
PortServiceVersionNotes
<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

  1. Aes_cbc_decryption
  2. Flutter_debug_kernel_blob_extraction
  3. Java_arithmetic_shift_ror
  4. Multi_layer_key_assembly
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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

  1. Extracted APK (which is a ZIP) and identified it as a Flutter application
  2. 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 functions partthree_1 and partthree_2
  • classes3.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:

LayerSourceLanguageKey bytesIV bytes
Part 1kernel_blob.binDart[0:8][0:4]
Part 2classes3.dexJava/Kotlin[8:16][4:8]
Part 3libmenascyber.soC++ (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, calls Finally.decryptFlag()
  • package:jigsaw/flag.dart — Contains encrypted flag base64: aZ/KF0GsnN81j5XStQyKz3vXtktTVN5zFqy5lwTmub6fx5w70c+p08O0OWcn/9nh
  • package:jigsaw/services.dart — Contains AESCombinedService which 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 rR function: (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() calls randFunc2(data@0x5c0, output, xorkey@0x600, 32) — returns 32-byte key
  • partthree_2() calls randFunc2(data@0x5e0, output, xorkey@0x5f0, 16) — returns 16-byte IV
  • randFunc2 does: 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

  1. Flutter debug builds leak full Dart source in kernel_blob.bin — always check with strings
  2. Java signed vs unsigned shifts — The >> operator in Java sign-extends, while native code with movzbl does unsigned operations. This subtle difference was the main "gotcha" of the challenge
  3. Three-layer architecture — Deliberately splits secrets across Dart, JVM, and native to require proficiency in all three RE domains
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR