Unearthing Secrets in Memory — A Full Write-Up of “Now You See Me” (Shakti CTF 2025)

Reverse-engineering a native library is one of the most common (and most fun) ways to hide a flag in a CTF.
In the Now You See Me challenge, the author takes that idea literally:
“Some secrets don’t like the spotlight. They prefer memory.”
Flag Format: ShaktiCTF{…}
Some challenges leave breadcrumbs in log-cats or UI strings.
This one hides every clue inside native memory and still tries to mislead us with decoys.
Let’s walk through the whole chain — from APK carving, to secret-DEX extraction, to the final XOR that prints the flag. Challenge File.
What’s in the ZIP?

unzip nowyouseeme.zip -d chall && cd chall && ls
Stage 1 — Decompile the APK
“Exploding” the package

apktool d nowyouseeme.apk -o now_you_see_me
d
stands for decoding.-o now_you_see_me
writes the result into an easy-to-spot folder.
Directory overview (trimmed):
now_you_see_me/
├─ AndroidManifest.xml ← readable XML
├─ assets/ ← extra files bundled by devs
├─ smali*/ ← Dalvik byte-code in text form
└─ lib/<abi>/ ← four tiny native libs (.so ≈ 5 kB each)
What is smali?
Android apps compile Java/Kotlin into Dalvik byte-code. apktool
prints that word-for-word using the smali assembly language, so you can read methods without any GUI.
Stage 2 — Recovering the real key from a hidden DEX
The obvious place to look for a secret key is the code. Let’s grep:

grep -R "getKey" now_you_see_me/smali* | head
Inside DataBridge.smali
(or the Kotlin sources, if they survived) You’ll spot:
const-string v0, "resources.dat"
invoke-virtual {v1, v0}, Landroid/content/res/AssetManager;->open(...)
# ↓ later ↓
invoke-virtual {zipFile}, Ljava/util/zip/ZipFile;->entries() ...
Plain-English translation
- The app opens
assets/resources.dat
With the Android AssetManager. - It treats the file as a ZIP, scans the entries, and loads a
*.dex
.
Extract that hidden DEX

# -p streams the file to stdout; '*.dex' keeps only that entry.
unzip -p now_you_see_me/assets/resources.dat '*.dex' > secret.dex
If unzip
prints “inflating: secret.dex”, you did it right.
Turn DEX into JAR
Some GUI decompilers choke on raw DEX; dex2jar
never does.

d2j-dex2jar secret.dex -o secret.jar
Result: secret.jar
You can open it in JD-GUI with a double-click.
Read the key in JD-GUI
Navigate to com.hidden.engine.AccessUtil
:

public static String getKey() {
return "Sh@ktiCtf_1337";
}
Key found: Sh@ktiCtf_1337
Keep it safe; we’ll need it for the native routine.
Side note: The main library also contains a literal "mysecretkey"
.
That is only a red herring; if you supply it you’ll hit an error branch.
Stage 3 — Inspecting the native library
Choose the x86–64 build
now_you_see_me/lib/x86_64/libnowyouseeme.so (≈ 5 662 bytes)
x86–64 is easiest to load on a desktop.
Open it in Ghidra (File → Import, accept defaults).
A single exported function
The dynamic symbol table shows:
Java_com_shaktictf_nowyouseeme_FlagEngine_getFlag
Double-click — that’s the JNI bridge all Java/Kotlin code calls.
Reading the algorithm
Ghidra’s decompile view (simplified and re-annotated):
// 1) decoy check, never triggered in legit flow
if (strcmp(user, "mysecretkey") != 0) { error(); }
// 2) CONSTANTS
size_t N = 0x38; // 56 bytes
uint8_t *T = &rodata[0x6B0]; // table to be decoded
// 3) CORE LOOP
for (int i = 0; i < N; ++i)
dest[i] = T[i] ^ user[i % strlen(user)];
// 4) JNI return
dest[N] = '\0';
return (*env)->NewStringUTF(env, (char*)dest);
Translation for beginners:
- XOR (
^
) is the most basic symmetric cipher: applying it twice with the same key returns the original value. - The code repeats the key when it runs out of bytes (
i % len
).
Therefore: feed the correct key → calculate 56 XORs → get flag as plain UTF-8.
Locate table T
Assembly snippet:
0x950 lea rax, [rip - 0x2A7] ; rip = 0x957 → target = 0x6B0
So 0x6B0
is the start of our 56-byte table.
Stage 4 — Dumping the table
Any hex view works; here’s radare2:
r2 -q -c 'px 0x38 @ 0x6b0' now_you_see_me/lib/x86_64/libnowyouseeme.so

Output:
0x000006b0 0000 2100 0000 0020 2024 4803 4668 3558 ..!.... $H.Fh5X
0x000006c0 3505 1036 2e47 393d 025b 5a59 3737 3403 5..6.G9=.[ZY774.
0x000006d0 4736 7218 0a2a 425a 0359 0c58 2634 1759 G6r..*BZ.Y.X&4.Y
0x000006e0 2747 396a 0106 034a 'G9j...J
Count them: 0x38
(56) bytes.
Stage 5 — One-file Python solver
#!/usr/bin/env python3
from pathlib import Path
LIB = "now_you_see_me/lib/x86_64/libnowyouseeme.so"
KEY = b"Sh@ktiCtf_1337" # from hidden DEX
OFF = 0x6B0 # table location
SIZE = 0x38 # 56 bytes
table = Path(LIB).read_bytes()[OFF:OFF+SIZE]
flag = bytes(t ^ KEY[i % len(KEY)] for i, t in enumerate(table))
print(flag.decode()) # human-readable!
Running:

$ python solve.py
ShaktiCTF{y0u_f0und_m3_b3hind_th3_1llusi0n_0f_c0d3_5050}
Success!
Final flag
ShaktiCTF{y0u_f0und_m3_b3hind_th3_1llusi0n_0f_c0d3_5050}
Conclusion
Reversing Android apps is as much about strategy as it is about skill. These lessons, like starting with apktool, staying alert to payloads hidden in assets, and treating every XOR as a puzzle with a simple solution, can save you hours of dead ends. Always dig beyond surface-level clues, question what looks too obvious, and don’t underestimate small native libraries. With practice and a sharp eye, these principles become second nature, and that’s when real progress begins.