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…
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 flagReveal
hiCTF{cryptography_should_be_implemnetd_properly}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.