Neven Marić
Hidden Investigations CTF 2026

Hidden Investigations CTF 2026

Cipher

cipher.apk is an Android "vault" that unlocks 64 encrypted records behind one master passphrase. The crypto itself is textbook (PBKDF2 -> HMAC verifier -> per-record HKDF subkey -> AES-GCM), so the passphrase cannot be recovered by…

  • android
  • reverse-engineering
  • crypto
  • pbkdf2
  • hmac
  • aes-gcm
  • hkdf
  • brute-force
  • java-random

What this one teaches

  • When strong crypto guards a passphrase, look for how the passphrase is generated — a deterministic PRNG with a bounded seed space reduces the problem to brute force.
  • Replicating java.util.Random's 48-bit LCG exactly (including power-of-two vs rejection paths of nextInt) is essential for reproducing its output in Python.
  • HMAC-based verifier anchors let you validate a candidate passphrase offline without decrypting any records.

Task description

A locked Android vault guards dozens of encrypted records behind a single passphrase. The interface reveals almost nothing, so inspect what lies beneath, understand how access is derived, and recover the secret hidden inside.

Decompile

jadx on cipher.apk gives clean sources under com.hictf26.cipher. Key classes: crypto/CryptoEngine, crypto/Hkdf, crypto/AnchorReader, crypto/Names, legacy/LegacyPassphrase, legacy/Lexicon, legacy/SeedWindow, data/VaultStore. Encrypted material lives in res/raw: m4.bin (lexicon), n3.bin (verifier anchor), p7.bin (vault store).

Deobfuscate the algorithm names

Names.decode(values, seed) XOR-unmasks strings with x = (x*29+17)&255. Decoded: PBKDF2WithHmacSHA256, HmacSHA256, AES, AES/GCM/NoPadding.

Key derivation and verifier

CryptoEngine.deriveRoot runs root = PBKDF2WithHmacSHA256(passphrase, salt, iterations=32768, 256 bits) with the 16-byte salt from p7.bin. Verification computes HMAC-SHA256(root, "cipher-verifier-v3"), truncates to 16 bytes, and compares against an anchor rebuilt from n3.bin (AnchorReader): expected[i] = n3[((i*11)+7) % 96] ^ (((i*19)+167) & 255) for i in 0..15. This verifier lets us test a candidate passphrase offline without touching any record.

The real weakness

The passphrase is not arbitrary. LegacyPassphrase.fromSeed builds it deterministically from a seed: new java.util.Random(7640891576956012809L ^ seed), then six words picked with nextInt(256) from a 256-word lexicon (m4.bin, XOR-deobfuscated), joined by -, plus a checksum (nextInt(100) + bitCount(seed)*7) % 100. SeedWindow fixes the search range: x = (7283617 ^ rol(1688791,7)) + 681 = 210586314, bucket = ((x>>>11)^x)&4095 = 2915, base = bucket<<18 = 764149760, span = 2^18 = 262144. Only 262,144 passphrases are possible.

Brute force and decrypt

The solver replicates java.util.Random exactly (48-bit LCG, power-of-two and rejection paths of nextInt), regenerates each candidate passphrase for seeds 764149760 .. 764411903, runs PBKDF2, and HMAC-checks against the anchor. Across 32 cores it finished in ~2.5 min: seed = 764408707, passphrase smxvoisk-fcxfgnpl-poznukfv-zxpmztpo-yanlxwmm-yanlxwmm-42, root = 2803a69149bea3d3535a6ae6073cfb21c76f5ddeebcf7e6b719cbbd3ffb47879.

Each record uses a one-block HKDF-SHA256: prk = HMAC(salt, root), subkey = HMAC(prk, "v1" || index || 0x01). Then AES-256-GCM with the record’s nonce and AAD = "V1LT" || index; the plaintext is title + 0x1F + secret. 63 records are decoys (cloud tokens, recovery phrases, hex keys, memos). Record 47 (“Archived security memo”) holds the flag.

#!/usr/bin/env python3
import os, sys, hashlib, hmac, struct
from multiprocessing import Pool, cpu_count

HERE = os.path.dirname(os.path.abspath(__file__))
def rd(name):
    with open(os.path.join(HERE, name), "rb") as f:
        return f.read()

M4 = rd("m4.bin")   # lexicon
N3 = rd("n3.bin")   # anchor (verify)
P7 = rd("p7.bin")   # vault store

MASK48 = (1 << 48) - 1
MULT = 0x5DEECE66D
ADD = 0xB

class JRandom:
    __slots__ = ("seed",)
    def __init__(self, seed):
        self.seed = (seed ^ MULT) & MASK48
    def next(self, bits):
        self.seed = (self.seed * MULT + ADD) & MASK48
        r = self.seed >> (48 - bits)
        if r >= 0x80000000:
            r -= 0x100000000
        return r
    def nextInt(self, bound):
        if (bound & -bound) == bound:  # power of two
            return (bound * self.next(31)) >> 31
        while True:
            bits = self.next(31)
            val = bits % bound
            if bits - val + (bound - 1) >= 0:
                break
        return val

def load_lexicon():
    plain = bytearray(2048)
    for i in range(2048):
        mask = (((i * 73) + 93) ^ ((i >> 3) * 17)) & 255
        plain[i] = M4[i] ^ mask
    return [plain[i*8:i*8+8].decode("ascii") for i in range(256)]

WORDS = load_lexicon()

def popcount(x):
    return bin(x & 0xffffffffffffffff).count("1")

def legacy_passphrase(seed):
    r = JRandom(7640891576956012809 ^ seed)
    parts = [WORDS[r.nextInt(256)] for _ in range(6)]
    checksum = (r.nextInt(100) + (popcount(seed) * 7)) % 100
    return "-".join(parts) + "-%02d" % checksum

def expected_anchor():
    exp = bytearray(16)
    for i in range(16):
        exp[i] = N3[((i * 11) + 7) % 96] ^ (((i * 19) + 167) & 255)
    return bytes(exp)

EXPECTED = expected_anchor()
VERIFY_CONTEXT = b"cipher-verifier-v3"
MAGIC = b"V1LT"

def parse_store():
    b = P7
    off = 5  # skip magic + version
    salt = b[off:off+16]; off += 16
    count = b[off]; off += 1
    records = []
    for _ in range(count):
        index = b[off]; off += 1
        nonce = b[off:off+12]; off += 12
        length = struct.unpack(">H", b[off:off+2])[0]; off += 2
        ct = b[off:off+length]; off += length
        records.append((index, nonce, ct))
    return salt, records

SALT, RECORDS = parse_store()
ITER = 32768

def check_seed(seed):
    pw = legacy_passphrase(seed)
    root = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), SALT, ITER, 32)
    mac = hmac.new(root, VERIFY_CONTEXT, hashlib.sha256).digest()
    if mac[:len(EXPECTED)] == EXPECTED:
        return (seed, pw)
    return None

def hkdf_oneblock(ikm, salt, info):
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    return hmac.new(prk, info + b"\x01", hashlib.sha256).digest()

def decrypt_all(root):
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    out = []
    for (index, nonce, ct) in RECORDS:
        info = bytes([118, 49, index & 0xff])  # "v1" + index
        subkey = hkdf_oneblock(root, SALT, info)
        aad = MAGIC + bytes([index & 0xff])
        pt = AESGCM(subkey).decrypt(nonce, ct, aad)
        s = pt.decode("utf-8")
        sp = s.index("\x1f")
        out.append((index, s[:sp], s[sp+1:]))
    return out

def main():
    BASE = 764149760
    SPAN = 262144
    found = None
    with Pool(cpu_count()) as pool:
        for res in pool.imap_unordered(check_seed, range(BASE, BASE + SPAN), chunksize=256):
            if res is not None:
                found = res
                pool.terminate()
                break
    if not found:
        sys.exit("[!] no seed matched")
    seed, pw = found
    print(f"[+] MATCH seed={seed} passphrase={pw!r}")
    root = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), SALT, ITER, 32)
    for (index, title, secret) in decrypt_all(root):
        mark = "  <<< FLAG" if "hiCTF{" in secret else ""
        print(f"  [{index:02d}] {title} = {secret}{mark}")

if __name__ == "__main__":
    main()
The flagRevealhiCTF{cryptography_should_be_implemnetd_properly}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.