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 .txt payloads 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:

  1. hold key material,
  2. decrypt bytes,
  3. 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.