August 14, 2026
Clearing FamPay’s Security CTF 6/6
One APK, six challenges, and a chain that ends in someone else’s S3 bucket. Here’s every solve, from strings to SSRF.

By codebreaker
7 min read
FamPay ran a genuinely well-built security CTF, and I managed to clear the full board: all 6 challenges (1,850 pts). What made it fun is that almost everything ships inside one Android APK ( fam-ctf.apk, the same file linked from every challenge card), and each challenge peels back a different layer of the same app. Hardcoded native secrets, weak Firebase rules, a debug App Check bypass, a backdoored custom signing scheme, a classic JWT bypass, and then the finale breaks out of the app entirely into AWS via an SSRF-to-metadata chain against a live EC2 instance.
This is a full walkthrough of all six, in ascending difficulty. Everything used is free and open-source, with no root or emulator needed.
Tooling: — unzip, strings, objdump, readelf, nm (binutils) -androguard decompiles classes*.dex back to readable Java - curl / python3 for hitting Firebase REST APIs and the CTF web endpoints directly - gcc + patchelf + dlopen for challenge 04, to run the bundled native lib directly through a fake JNIEnv
[01] The Library (100 pts, Static Analysis)
Many times devs put secrets in code and forget to remove. Can you find that secret?
An APK is just a zip:
unzip-ofam-ctf.apk-dextractedunzip-ofam-ctf.apk-dextractedInside lib/, alongside the standard libc++_shared.so, there's a custom native library: lib/arm64-v8a/libfam.so and lib/x86_64/libfam.so, both stripped ELF shared objects. "THE LIBRARY" is the hint: the flag lives in libfam.so, not the Java/Kotlin code.
No obfuscation at all. The flag was left as a plaintext string constant inside the stripped native library, right next to the JNI export that returns it:
stringslib/x86_64/libfam.so|grep-B2-A2FAM
Java_com_ctf_fam_MainActivity_getSecretFromNative FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}stringslib/x86_64/libfam.so|grep-B2-A2FAM
Java_com_ctf_fam_MainActivity_getSecretFromNative FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
Flag: FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
[02] The Database (200 pts, Firebase)
The door is open to anyone. You don't need a name to enter, but the room still has a lock. Hint: identity is optional here.
Pulled the Firebase Realtime Database URL and Web API key straight out of the app with strings:
https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2wwhttps://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2wwHitting the DB root unauthenticated is denied:
curl-s"https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/.json" # {"error" : "Permission denied"}curl-s"https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/.json" # {"error" : "Permission denied"}The vulnerability: "identity is optional" is the tell: Firebase Anonymous Auth is enabled, and the rule on the interesting path is something like ".read": "auth != null". It checks that some identity is present, not that it's a real one. Anonymous sign-in is instant and free.
Sign in anonymously via the Identity Toolkit REST API using the API key:
This returns an idToken JWT. Use it as the auth param against the RTDB REST API:
Flag: FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}
[03] The Vault (300 pts, Firebase)
The vault trusts no one it hasn't met. But the app already made introductions. Something in the code proves who you are. Hint: the token is hiding in plain sight.
An androguard decompile of MainActivity shows the real target is Firestore, protected by Firebase App Check:
The vulnerability: the App Check debug token provider is shipped in the release build (it should only exist in local dev). That means anyone can mint a valid App Check token, defeating the whole point of App Check. And the debug token isn't a plain string; it's a simple XOR "hiding in plain sight."
Disassembling getDebugToken reveals a per-byte decode loop:
decoded[i] = tableB[i] XOR tableA[i % 19] XOR 0xAA (36 bytes total)decoded[i] = tableB[i] XOR tableA[i % 19] XOR 0xAA (36 bytes total)with a 19-byte key at file offset 0x34f0 and 36-byte ciphertext at 0x3510:
Exchange the recovered debug token for a real App Check JWT, then use it as the X-Firebase-AppCheck header against the Firestore REST API:
Flag: FAM{4pp_ch3ck_byp4ss_g00d_j0b}
[05] The Vault Door (350 pts, Web)
NexaVault is an internal credential management portal. Only administrators can open the Vault. You have been given access to create an account. That's it.
Registering an account returns a session cookie, nx_access, that decodes as a JWT:
// header: {"alg":"HS256","typ":"JWT"} // payload: {"sub":"famtester01","role":"user"}// header: {"alg":"HS256","typ":"JWT"} // payload: {"sub":"famtester01","role":"user"}/famctf/admin requires role: admin.
The vulnerability: classic JWT bypass: the server accepts an unsigned token as long as the header claims alg: none, never checking whether that's a permitted algorithm.
Forge a token with alg: none, role: admin, and an empty signature:
Send it as the session cookie:
curl-s"https://ctf.fampay.co/famctf/admin" \ -H"Cookie: nx_access=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJmYW10ZXN0ZXIwMSIsInJvbGUiOiJhZG1pbiJ9." # Vault Payload: FAM{jwt_4lg_n0n3_byp4ss_gr4nt3d}curl-s"https://ctf.fampay.co/famctf/admin" \ -H"Cookie: nx_access=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJmYW10ZXN0ZXIwMSIsInJvbGUiOiJhZG1pbiJ9." # Vault Payload: FAM{jwt_4lg_n0n3_byp4ss_gr4nt3d}
Flag: FAM{jwt_4lg_n0n3_byp4ss_gr4nt3d}
[04] The Endpoint (400 pts, Reversing + Web)
The server only trusts what it can verify. Intercept. Modify. But can you keep it honest? Hint: only admin has clearance.
This is the one I'm proudest of. The app signs requests to https://ctf.fampay.co/api/check with a custom X-Signature. The native user list (XOR-obfuscated like ch.03) decodes to guest, player1, h4x0r, anonymous, n00b, with . And the server checks in a different order depending on role:
So I needed a valid X-Signature for username=admin. Critically: because every allowed user is stopped by the 403 gate before any signature check, the shipped signing path is never actually validated against the server, a perfect place to hide a bug.
computeSignature is a custom, non-cryptographic hash in libfam.so: message = method|path|body, a 19-byte XOR keystream decoding to FAM_s3cr3t_key_2026, an ARX mix run in parallel with a FNV-1a 64-bit hash, then 4 rounds of diffusion. But the key detail is a backdoor at 0x6b80:
if (fnv1a(message) == 0xdcc67eca15a7c732) // magic constant state[2] ^= 0xdeadbeefcafebabe; // corrupt word 2 before outputif (fnv1a(message) == 0xdcc67eca15a7c732) // magic constant state[2] ^= 0xdeadbeefcafebabe; // corrupt word 2 before outputAnd the magic constant is exactly:
The client deliberately corrupts the signature for the one input that matters (the admin request) while the server signs honestly. "Keep it honest" = undo the client's dishonesty.
Rather than hand-transcribe the ARX (error-prone), I ran the real code. The APK ships an x86_64 libfam.so, so on a Linux box it can be dlopen'd and called through a hand-built fake JNIEnv, a guaranteed-correct oracle. That meant recovering three JNI vtable offsets ( NewStringUTF 0x538, GetStringUTFChars 0x548, ReleaseStringUTFChars 0x550), patchelf-ing the bionic .so to load under glibc (clearing @LIBC symbol versions, dropping bionic NEEDED libs, and shimming 4 bionic-only symbols), then:
Native output already includes the backdoor XOR, so I undo it on word 2:
Flag: FAM{x_s1gn4tur3_r3v3rs3d_n1c3ly}
[06] The Cloud (500 pts, Cloud / SSRF)
An internal DevOps monitoring dashboard was accidentally deployed with debug endpoints enabled on a public-facing EC2 instance. Get the flag from the S3 bucket. Hint: debug endpoints reveal more than intended. The instance knows who it is.
The finale breaks out of the APK entirely. Full chain: SSRF (webhook) → IMDSv2 (via the instance-data alias) → EC2 IAM role creds → S3, with a VPC-origin trap on the final read that forces a presign-through-SSRF.
The box serves a NexOps Dashboard (Flask). Its /metrics/config debug endpoint ("debug endpoints reveal more than intended") hands over the entire target map:
The /internal/webhook endpoint is an unauthenticated URL fetcher (SSRF), self-describing its allow-list ( 169.254.x.x, *.amazonaws.com).
Step 1: Bypass the SSRF filter. Raw IPs (and decimal/hex/octal/IPv6-mapped encodings) are blocked, with the hint "Try harder. The instance has a name." The name is the legacy IMDS DNS alias instance-data, which maps to 169.254.169.254 and sails through the host-based filter:
Step 2: Defeat IMDSv2. IMDSv2 needs a PUT for a session token, then that token echoed on each GET. Two properties of the webhook make this possible: it mirrors the HTTP method and forwards custom headers:
Step 3: The VPC trap. With the temporary creds, ListBucket works from anywhere, but GetObject returns 403, because the bucket policy restricts reads to requests originating inside the VPC:
awss3lss3://fam-ctf-cloud-challenge/players/1130/# flag.txt (OK) awss3cps3://fam-ctf-cloud-challenge/players/1130/flag.txt- # An error occurred (403) ... Forbiddenawss3lss3://fam-ctf-cloud-challenge/players/1130/# flag.txt (OK) awss3cps3://fam-ctf-cloud-challenge/players/1130/flag.txt- # An error occurred (403) ... ForbiddenStep 4: Presign locally, fetch through the instance. Build a SigV4 presigned GET URL locally with the stolen creds (its host is *.s3.amazonaws.com, so it passes the allow-list), then have the instance fetch it via SSRF, and the S3 request now originates inside the VPC.
⚠️ Gotcha: the webhook mirrors the method, so you must call it with GET to match a URL presigned for GET (a POST gives
SignatureDoesNotMatch).
Flag: FAM{cl0ud_ssrf_imds_bff57ee7a254}
Key Takeaways
Six challenges, but a few themes run through all of them:
- Native code is not a secret store. Stripped symbols and XOR "obfuscation" fall to
strings/objdumpin minutes. If a value must stay secret, it belongs server-side, never in a client the attacker fully controls. - Never ship debug/dev artifacts to production. The App Check debug provider, the dev
SERVER_URL, and hardcoded keys were each a free win. - Firebase rules must check identity, not just presence of identity.
auth != nullis satisfied by free anonymous sign-in. - Enforce JWT algorithms server-side. Never trust
algfrom the token header; rejectalg: noneexplicitly. - Verification code that no valid request reaches is silently wrong. The ch.04 backdoor survived precisely because the role gate short-circuited the signature check for every legitimate user.
- A VPC-restricted bucket policy isn't isolation when an SSRF-able server lives inside the VPC. Validate SSRF targets by resolved IP, set the IMDSv2 hop limit to 1, and treat any in-VPC SSRF as full compromise of what that host's IAM role can reach.
Thanks to the FamPay security team for a genuinely thoughtful, well-layered CTF. Challenges that mirror real bugs are the best kind.
I'm a security engineer focused on offensive security: VAPT (web, API, mobile), cloud, and red teaming. If you enjoyed this, I'm always up to talk shop or hear about opportunities in offensive security. Find me on LinkedIn and GitHub.
Originally published at https://codebreaker25.github.io.