How I Decrypted Obfuscated Mobile API Payloads
I’ll tell this exactly how it happened.
I started with an Android app whose network endpoints returned text files that looked like gibberish. At first glance, they looked Base64-ish, but decoding them still produced high-entropy binary. No JSON. No obvious compression headers. No quick win.
Then static analysis hit a familiar wall.
Step 1: Static analysis looked promising… until it didn’t
I decompiled the APK and quickly found signs of a shell/packer layer (StubApp, native loader libs). That usually means the interesting logic is not where JADX wants it to be.
What I could confirm statically:
- The app fetched
.txtpayloads from HTTP endpoints. - The responses were not plaintext data structures.
- The app likely performed in-memory decryption before parsing.
At that point, trying to brute-force static deobfuscation would waste time. So I switched tactics.
Step 2: Runtime instrumentation over static guessing
The key mindset shift was simple:
If the app can decrypt it, plaintext and key material must exist in memory at some point.
So instead of digging through packed dex forever, I hooked runtime boundaries:
- Base64 decode
- Cipher init
- Cipher doFinal
- (plus native crypto fallbacks)
Step 3: First failure — spawn mode crashed hard
I initially used Frida spawn mode (-f). The process repeatedly crashed with protection failures around ART verifier behavior. That’s a common anti-instrumentation pattern in protected apps.
This was the turning point:
- Spawn path = unstable and easy to detect.
- Attach path (
-n, app already running) = significantly more stable.
So I stopped spawning and only attached to a live process.
Step 4: Attach-only + classloader-aware hooks = breakthrough
With attach mode and broader classloader coverage, the signal appeared.
I captured runtime crypto events showing:
Cipher.getInstance("AES/CBC/PKCS5PADDING")- Base64 decode of endpoint payloads
Cipher.doFinal(...)outputting valid JSON
That was the definitive pipeline:
Base64 ciphertext -> AES-CBC/PKCS5 decrypt -> JSON
No speculation anymore.
Step 5: Extracting keys from live crypto init
The Cipher.init(...) hook exposed key bytes in hex. Converting those bytes to ASCII gave two 16-byte AES keys observed in use.
This also explained why decryption looked inconsistent at first: different payload classes were using different key/IV combos.
Step 6: Recovering IV when it wasn’t logged directly
I didn’t always get a clean IV printout, so I recovered it using CBC math + known plaintext.
For CBC block 1:
P1 = D_k(C1) XOR IV- so
IV = D_k(C1) XOR P1
Using a known JSON prefix ([{"id":...) as P1, I derived candidate IV values, then validated by:
- correct PKCS5/PKCS7 unpadding,
- UTF-8 decode,
- valid JSON parse.
If all three pass, you’re not guessing anymore—you’re done.
Step 7: Operationalizing into an offline decryptor
Once the live path was proven, I built a script to automate decryption outside the app:
- Base64 normalization
- AES-CBC decrypt
- PKCS unpadding
- JSON sanity checks
- key-ring + IV handling
- bulk mode for URL/file lists
That turned a one-off reverse engineering result into repeatable tooling.
Step 8: Why this worked
The app can obfuscate code, pack dex, and complicate static analysis—but eventually it must:
- hold key material,
- decrypt bytes,
- parse plaintext.
Runtime instrumentation targets exactly that unavoidable moment.
Defensive takeaway
Client-side encryption around content APIs is often obscurity, not a trust boundary.
If content must be decrypted on a client you don’t control, assume a skilled analyst can recover:
- algorithm/mode,
- key/IV lifecycle,
- plaintext payloads.
Command examples and responses
1) Base64 probe
python3 - <<'PY'
import base64
s = "U2FtcGxlRW5jcnlwdGVkUGF5bG9hZA=="
raw = base64.b64decode(s)
print(f"len={len(raw)} bytes")
print(raw[:16].hex())
PY
Example response:
len=22 bytes
53616d706c65456e6372797074656450
Interpretation: decoded data is still binary/high-entropy, so parsing directly as JSON is unlikely to work.
2) Runtime crypto hook signal (Frida attach mode)
frida -U -n com.target.app -l hook_crypto.js
Example response:
[Cipher.getInstance] AES/CBC/PKCS5PADDING
[Base64.decode] input_len=684
[Cipher.init] mode=DECRYPT key=4d794b65793132333435363738393021
[Cipher.doFinal] out_preview={"id":112,"title":"..."}
Interpretation: this confirms algorithm/mode and proves plaintext appears at runtime.
3) Offline AES-CBC validation
python3 decrypt_payload.py \
--b64 payload.txt \
--key-hex 4d794b65793132333435363738393021 \
--iv-hex 31323334353637383930616263646566
Example response:
[ok] base64 decoded: 512 bytes
[ok] AES-CBC decrypt: 496 bytes
[ok] PKCS#7 unpad: 487 bytes
[ok] UTF-8 decode
[ok] JSON parse
{"id":112,"name":"...","items":[...]}
Interpretation: successful unpadding + UTF-8 + JSON parse is a strong correctness check for key/IV selection.
4) Bulk mode for multiple payload files
python3 decrypt_payload.py --bulk samples/*.txt --keyring keys.json
Example response:
[1/20] sample_001.txt -> ok (route=A)
[2/20] sample_002.txt -> ok (route=B)
...
[20/20] sample_020.txt -> ok (route=A)
summary: success=20 fail=0
Interpretation: once key/IV routing is known, decryption can be repeatable at scale.
Ethics note: This workflow should only be used for authorized security research, defensive assessments, and systems you have explicit permission to test.