Neven Marić
Hidden Investigations CTF 2026

Hidden Investigations CTF 2026

VaultKeeper

The APK has many decoy flags. The real flag is never stored in plaintext. It is computed inside libvaultnative.so, and only when the full "claim" pipeline runs with no integrity or anti-debug faults.

  • android
  • reverse-engineering
  • native-library
  • vm
  • anti-debug
  • unicorn-emulation
  • decoy-flags
  • jni

What this one teaches

  • Decompiler output can be intentionally incomplete — only 2 of 6 XOR arrays were visible, making the decoy flag look like the real answer.
  • The real flag can be computed purely inside a native library's VM, never stored in plaintext — reversing the APK alone is not enough.
  • Emulating the native library with Unicorn (clean proc/status, stubbed libc) lets you run the full pipeline without triggering anti-debug faults.

Task description

“Your master key. Deep cold storage. One tap away.” VaultKeeper is the most secure mobile vault ever shipped — the marketing deck says so, twice. Your balance is already waiting inside, and the key to the whole vault is just one button press away. Well. One button press for someone. The APK is right here. See if the vault opens for you. If it doesn’t, maybe you didn’t earn it.

Submission result: goodFlag (“The flag is correct.”)

Decoys (all rejected or traps)

  • strings.xml: legacy_diagnostics_token, beta_key_fragment, old_reward_hint, stale_cache_entry, vendor_note.
  • Logcat “legacy master key” line in Diagnostics.emitTrace (5 taps on the sealed button).
  • FlagRepository.getMasterKey(): XOR of six byte arrays gives hiCTF{k0tl1n_d3c0mp1l3rs_ftw} (the decompiler shows only 2 of the 6 arrays).
  • Native getFlag / decryptMasterKey / flag_commit: hiCTF{str1ngs_4nd_x0r_w0nt_s4v3_y0u} (submitted, wrong).

Real logic

UI: tap the shield 3 times (gaps under 700 ms), then long-press the subtitle for 600 ms or more within 1.5 s. This “rite” arms a random 16-byte token for 3 s. Pressing REVEAL MASTER KEY then runs ClaimOrchestrator.runClaim:

  1. EnvAudit.fullPass - debugger, TracerPid, frida maps, ports, root, hooks, APK signature. Any hit calls noteManagedFault, which ORs bits into a native fault word.
  2. nvSeed(vault.bin, token) - SHA-256 of the 8 KB assets/vault.bin + "VK-SEED-v1", expanded into 128 u32 “cells”.
  3. CellMixer.k1(cells) (Java, 7 rounds of rotate/add/multiply by 0x85EBCA6B).
  4. nvUnseal(cells, token) - builds a 2048-byte VM program: prog[i] = rodata[0xf60+i] ^ byte(cells[(7+31i)&127] >> (8i&24)) ^ tbl51[i%51] ^ 0xa5.
  5. CellMixer.k2(prog, cells) (Java, per-256-byte XOR keystream from the cells).
  6. nvExec(prog, cells, token) - small register VM. Also timing check: elapsed time must be 400-15000 ms.
  7. nvFinal(token) - re-derives a SHA-256 chain from the VM’s executed opcode trace. The 51-byte flag is blobA ^ blobB ^ blobC ^ chain_bytes. It is checked against a SHA-256 tag (VK-TAG-2026). Any fault makes it output a decoy instead. The result goes to nv_sync, which scatters and wipes it.

The flag is independent of the random token (the token is only an integrity handle).

Recovery

A Unicorn-based emulator loads lib/x86_64/libvaultnative.so, applies relocations, and stubs libc with clean values (empty /proc/self/status and maps, no env vars, monotonic clock_gettime, deterministic /dev/urandom). It then calls, in order, JNI_OnLoad, nv_seed, Python k1, nv_unseal, Python k2, nv_exec and nv_final. The fault word stayed 0, so the tag check passed. Hooking the nv_sync entry captured the 51-byte plaintext:

hiCTF{!!I_10v3_4ndr0!d_m4n_m4k!ng_th3m_f331_g0Od!!}

The flagRevealhiCTF{!!I_10v3_4ndr0!d_m4n_m4k!ng_th3m_f331_g0Od!!}

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