mobilefreemedium

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/
flutter_debug_kernel_blob_extractionjava_arithmetic_shift_rornative_so_disassemblyaes_cbc_decryptionmulti_layer_key_assembly

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

  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

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups