Blog
March 4, 2026 Marie H.

NFCU Part 2: Bypassing SSL Pinning, Cracking the Device Fingerprint, and Getting a Working Auth Client

NFCU Part 2: Bypassing SSL Pinning, Cracking the Device Fingerprint, and Getting a Working Auth Client

Photo by <a href="https://unsplash.com/@pconrad?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Peter Conrad</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

NFCU Part 2: Bypassing SSL Pinning, Cracking the Device Fingerprint, and Getting a Working Auth Client

Part 1 ended at an impasse: the NFCU Android app was running in an emulator, mitmproxy was intercepting everything except the auth endpoints, and SSL pinning was blocking the connections I actually needed. I had the ForgeRock auth tree fully mapped and the OAuth client ID, but no way to capture the device fingerprint or the Akamai sensor data that the app was sending. I knew those two fields existed. I didn't know what was in them.

This is the account of getting past that. It took most of the same night.

The SSL Pinning Problem

The NFCU app uses certificate pinning on digitalomni.navyfederal.org and digitalauth.navyfederal.org. Pushing a mitmproxy CA cert into the Android emulator's user trust store doesn't help — Android 7+ doesn't trust user-installed CAs for app traffic by default, and the NFCU app explicitly disallows it via its network security config. The app targets a higher API level with cleartextTrafficPermitted=false and no user trust anchors in the config.

The standard bypass paths I tried first:

Frida-based pinning bypass — Frida requires root or a debuggable APK. The emulator can be rooted, but the NFCU app detects root (mfa_isrooted is one of the fingerprint fields) and refuses to authenticate.

APK repackage — Decompile the APK with apktool, patch the network security config, repack and sign. The NFCU app has APK tamper detection. Patched APKs terminate on launch.

Xposed framework — Same root detection problem.

The breakthrough was the Android APEX cert store. Android 14 moved the system CA cert store from /system/etc/security/cacerts/ into an APEX module at /apex/com.android.conscrypt/cacerts/. This is mounted read-only even for root. But on an emulator with root access, you can remount it.

# On Android 14 — the APEX mount point, not /system/etc/security/cacerts/
adb shell su -c "mount -o remount,rw /apex/com.android.conscrypt"
adb push mitmproxy-ca-cert.pem /tmp/
adb shell su -c "cp /tmp/mitmproxy-ca-cert.pem /apex/com.android.conscrypt/cacerts/<hash>.0"
adb shell su -c "chmod 644 /apex/com.android.conscrypt/cacerts/<hash>.0"

That gets you past the OS-level verification. But there's a second problem: SELinux. The APEX filesystem has an appdomain_tmpfs SELinux context, and copying a file there from /tmp/ with the wrong context causes a silent failure — the cert appears to copy correctly but isn't trusted. The fix is relabeling before copy:

adb shell su -c "chcon u:object_r:system_file:s0 /tmp/mitmproxy-ca-cert.pem"
adb shell su -c "cp /tmp/mitmproxy-ca-cert.pem /apex/com.android.conscrypt/cacerts/<hash>.0"
adb shell su -c "chcon u:object_r:system_file:s0 /apex/com.android.conscrypt/cacerts/<hash>.0"

After this and a reboot, the NFCU app's auth traffic was flowing through mitmproxy. First time.

The Akamai Bot Manager Problem

With traffic capture working, I started an auth session in the app and watched the flow. The first interesting thing: POST /api/auth/mobile/authn carries a header called x-acf-sensor-data. Large, opaque blob. Format prefix 6,a, — Akamai Bot Manager SDK v6.

I knew what would happen if I omitted it, so I tried first: sent the authn request without the header. The response was HTTP 200 with body {"code": "LGN014", "message": "Please try again"}. That's the "remember your device" error. Not a real response — the Akamai edge was returning a synthetic response and never forwarding the request to the NFCU backend.

The sensor data blob is generated by the Akamai BM SDK embedded in the app. It contains device fingerprint data, timing signals, and behavioral telemetry, signed with an Akamai RSA key embedded in the SDK. You can't fabricate it without the key. You can't replay it indefinitely — it has a validity window. But you can capture it from a live session and replay it for that session's duration. The captured value remained valid for roughly 1.5 hours in my testing.

The auth flow with the correct x-acf-sensor-data header returned authorization: Bearer <token> in the response headers. Token 1 of 3.

Mapping the Device Fingerprint

The second field I needed: deviceFingerprint. Sent in the authn POST body alongside credentials. Decoding the captured value was straightforward once I had it:

  1. Strip the _v02 prefix
  2. Base64-decode the remainder
  3. XOR every byte with 0x55
  4. The result is a URL-style query string

The plaintext:

fpdt=2&mfos=Android&mfov=14&mfwa=02:00:00:00:00:00&mfsc=2209|1080&fpln=en_US
&mfgc=00.0000|00.0000&mfpv=2&fpts=1772171439981&mfappid=com.navyfederal.android
&mfa_isrooted=false&mfa_id=3212038e3f2f8791&mfa_bd=goldfish_arm64&mfa_br=google
&mfa_ca1=arm64-v8a&mfa_fp=google/sdk_gphone64_arm64/emu64a:14/...&...&mfec=<512 chars>

Twenty-two parameters. The last one, mfec, is a base64-encoded RSA-2048 signature over all the preceding parameters concatenated. The private key is embedded in the NFCU Android APK. fpts is the current Unix timestamp in milliseconds and is part of the signed payload, so the signature changes every request.

That last part means you can't generate a fresh fingerprint without the APK's embedded key. What you can do: capture a complete fingerprint from a live session and replay it. The server validates the signature but appears to accept replayed fingerprints within a session window — probably because re-validation against a rotating timestamp would break legitimate users whose clocks drift.

The mfa_isrooted=false field is set by the app's root detection logic. On the emulator, with root access granted to the mitmproxy capture scripts, the app still reports false. The root detection runs at app startup, not at auth time, and the emulator passes most of its heuristics.

The Full Auth Flow

With fingerprint and sensor data captured, I mapped the complete six-step sequence:

Step Endpoint Returns
0 GET /api/auth/config/preauth XSRF-TOKEN, prd_oar, ak_bmsc cookies
1 POST /api/auth/mobile/authn Bearer token 1 (in authorization header)
2 GET /api/auth/tfa/options Available OTP phone numbers
3 POST /api/auth/tfa/challenge/otp OTP delivery confirmation
4 POST /api/auth/tfa/challenge/verification Bearer token 2
5 GET /api/auth/esi/activation (×3) Bearer token 3 on third call

Token 3 is the one that grants API access. The three-call esi/activation sequence is unusual — the first two calls return intermediate state, the third returns the final session token. Calling it once or twice and attempting to use the token returns 401.

The x-xsrf-token header must mirror the XSRF-TOKEN cookie set by preauth on every subsequent request. The x-nf-profile-tag is a 32-char alphanumeric generated per session, not per request — generate it once at preauth time and send it unchanged through the entire flow.

Required headers on all requests:

{
    "authorization": f"Bearer {token}",
    "x-xsrf-token": xsrf_token,          # from preauth cookie
    "x-nf-profile-tag": profile_tag,     # 32-char random, stable per session
    "x-sf-device-id": device_id,         # stable UUID, generated once
    "x-nf-device-metadata": device_meta, # base64(json device info)
    "cid": "Mobile",
    "platform": "AND",
    "appversion": "2026.2.1",
    "user-agent": "NavyFederal/2026.2.1 (Android 14)",
}

What the API Exposes

With a valid bearer token, the Backbase API at digitalomni.navyfederal.org exposes the full account picture. Endpoints confirmed working:

  • GET /api/retail-banking/product-summary — account overview with balances
  • GET /api/retail-banking/transactions — transaction history with cursor pagination
  • GET /api/cards-presentation-service/client-api/v2/cards — card list
  • GET /api/cards-presentation-service/client-api/v2/cards/{id}/rewards/summary — rewards balance

All return structured JSON. The transaction history endpoint supports date range filters and returns normalized transaction objects across account types.

The Implementation

The resulting Python package has a clean interface over the full complexity underneath:

client = NFCU(username="member123", password="s3cur3pass")
client.login()          # runs preauth → authn → tfa → esi
otp = input("OTP: ")
client.submit_mfa(otp)  # completes verification, exchanges to token 3

accounts = client.get_accounts()
txns = client.get_transactions(account_id=accounts[0]["id"], days=30)
cards = client.get_cards()

The package ships with EMULATOR_FINGERPRINT and EMULATOR_SENSOR_DATA constants that work for development without needing to run a live emulator session. The intercept/start.sh script documents how to refresh both when they expire.

62 unit tests, all mocked against captured response fixtures. docs/API.md has the full endpoint reference. The repo is at github.com/... — not linking publicly because the intent is personal finance automation, not a distribution channel.

What Took the Longest

Not the certificate bypass, not the fingerprint decode, not the six-step auth flow. The longest part was the Akamai sensor data.

For about two hours after getting mitmproxy working, I was getting authentic responses from the ForgeRock auth tree — username accepted, password accepted, MFA offered — and then failing at the final token exchange. The authn step was silently returning the synthetic LGN014 from Akamai's edge, but the x-acf-sensor-data header wasn't visible at that point because I was looking at the ForgeRock flow, not the BFF.

The BFF at digitalomni.navyfederal.org/api/auth/mobile/authn is what the app actually posts to. It's a proxy that adds the x-acf-sensor-data header from the Akamai SDK before forwarding to ForgeRock. I was looking at the ForgeRock responses. Once I started watching the BFF layer, the header appeared, and the path forward was clear.

The SELinux context on the APEX cert was the other invisible failure — the cert appeared to install correctly but traffic interception silently failed until the relabeling step.

Both failures looked like success until you tried the thing that depended on them working.