Flutter App Reverse Engineering Explained: When the Crypto Is Just Fancy Wrapping Paper
Imagine you buy a locked box, but the key is taped to the underside of the lid. That is a surprising number of mobile banking apps. The traffic looks encrypted, the APK looks opaque, and yet the entire protocol is sitting in a native library waiting for anyone patient enough to read it.
This article walks through a realistic scenario: a Flutter Android banking client that talks to its backend over a custom encrypted envelope. We will pull the APK apart, recover the Dart logic from the AOT snapshot, rebuild the exact request format, authenticate, and show how a missing ownership check on the server still lets you act as another account. No magic, no "just run Frida and pray." Static analysis first.
What Are We Actually Looking At?
A modern Flutter release APK is not a pile of readable Java. The UI and business logic live in Dart, compiled ahead of time into a native shared object:
lib/arm64-v8a/libapp.so # your app logic (Dart AOT snapshot)
lib/arm64-v8a/libflutter.so # the Flutter engine
Disassembling libapp.so with a normal ARM64 decompiler mostly shows Dart VM helpers. You need a Dart-aware tool that understands the snapshot format for that exact Dart version.
The tool of choice here is blutter. It detects the Dart version embedded in the Flutter engine, builds a matching Dart VM, and dumps:
- annotated ARM64 assembly with class and method names restored
- object pool strings (endpoints, header names, field names)
- a Frida template if you still want dynamic hooks later
Typical flow:
# extract native libs
unzip app.apk -d extracted
# or: apktool d -s app.apk -o extracted
# run blutter against arm64-v8a
python3 blutter.py extracted/lib/arm64-v8a/ ./out/
# useful outputs
ls out/asm/ # per-package annotated assembly
# out/pp.txt # object pool (strings, constants)
# out/objs.txt # nested object dump
Once that finishes, you stop guessing string names and start reading real method names like EncryptionHelper::encryptAESParamsWithRSA and ApiService::_sendRequest.
Why Should You Care?
- Encrypted traffic is not a black box. If the client can encrypt, the client contains the algorithm, the key material policy, and the field names.
-
Obfuscation is not authorization.
A beautiful RSA+AES envelope does not stop a server from trusting a
from_accountfield the client sent. - Static RE scales better than dynamic. Emulators, Frida, and cert pinning fights burn time. A clean dump of the Dart snapshot often answers the wire-format question in one pass.
- Banking and fintech apps love custom crypto. You will see the same pattern again: hybrid encryption, custom headers, JWT in the body, "we encrypt so we are secure."
Worked Example: Recovering a Banking API From Scratch
Scenario
You have an Android banking APK. Network traffic is HTTPS with a custom Host header. Bodies look like base64 noise. Headers include names like KEY, IV, SALT, SIGNATURE. The backend rejects plaintext JSON with empty 400 responses. Goal: speak the protocol correctly, register, log in, and exercise the transfer endpoint.
Step 1: Map the surface without running the app
From the blutter object pool and api_service assembly you recover paths such as:
-
user/register -
login -
user/me -
transaction/transfer -
transaction/history
And a required host value used in every request (the app hardcodes it; the server validates it).
You also find a PEM public key embedded in the snapshot. That is the server's RSA public key used to wrap symmetric material.
Step 2: Reconstruct the encryption pipeline
The interesting functions, restored by name, look like this in spirit:
-
generateAESParams()- 32 random bytes β AES key
- 16 random bytes β IV
- 16 random bytes β salt
-
jsonEncode(body)with compact separators (no spaces). -
generateSHA256Signature(json)β hex digest of the UTF-8 JSON. That value becomes theSIGNATUREheader. -
encryptAES(json, key, iv)using AES-CBC with PKCS7-style padding, result base64-encoded. That is the HTTP body. -
encryptAESParamsWithRSA(key, iv, salt):- base64-encode each of the three raw byte arrays first
- RSA-OAEP-encrypt each base64 string (as UTF-8) with the server public key
-
OAEP hash:
SHA-256
(this matches the
fast_rsastyle default; SHA-1 will fail silently) -
return a map:
encryptedKey,encryptedIv,encryptedSalt
- Build headers:
| Header | Value |
|---|---|
| Content-Type | text/plain |
| KEY | RSA-OAEP result for the key |
| IV | RSA-OAEP result for the iv |
| SALT | RSA-OAEP result for the salt |
| SIGNATURE | hex SHA-256 of the plaintext JSON |
| Host | the hardcoded banking host |
Body: the base64 AES ciphertext only. No JSON wrapper.
The mistake that costs people hours: RSA-encrypting the raw key bytes instead of base64(key). The client base64-encodes first, then OAEP-encrypts that string. Copy that exactly or every request returns 400 with an empty body.
Step 3: Match request schemas from toJson
From the request classes in the dump:
Register roughly needs:
{
"device_id": "...",
"email": "...",
"first_name": "...",
"last_name": "...",
"middle_name": "",
"password": "...",
"username": "..."
}
Login:
{
"email": "...",
"password": "..."
}
Transfer:
{
"amount": 1.01,
"auth": { "token": "<jwt>" },
"from_account": 12345678,
"remark": "optional note",
"to_account": 87654321
}
History / account details often send a flat:
{ "token": "<jwt>" }
Notice the inconsistency: transfer nests the JWT under auth.token, history uses a top-level token. The server is picky. Mirror the client.
Step 4: Decrypt the responses
The server reuses the same AES key and IV you just sent (via the RSA-wrapped headers) to encrypt the response body the same way. Keep key and iv for that request, base64-decode the response, AES-CBC decrypt, strip PKCS7 padding, parse JSON.
Login response shape (illustrative):
{
"token": "eyJ...",
"pin": "AA...."
}
The PIN is stored client-side after an XOR/base64 dance for local unlock UI. It is not required for every API call once you have the JWT, but decoding it teaches you how the app protects local secrets (weakly).
Step 5: Discover your own account number
/user/me may return unauthorized depending on how auth is validated. History, however, often works with { "token": "..." } and returns a list of transactions. A new user typically has a system "welcome bonus" transfer into their account. That transfer's to_account field is your account number.
Step 6: The authorization hole
The transfer endpoint accepts from_account from the client and does not verify that the authenticated user owns that account. If you set from_account to a well-known system or treasury account and to_account to your own account, with an amount the server accepts (here: greater than 1), the transfer succeeds.
The response includes a remark field controlled or filled by the server for that system account's outgoing transfer. In a real incident that might be internal memo text, a payout reference, or other sensitive metadata. In a lab it is often the objective string you are chasing.
That is the punchline: after all the RSA, AES, signatures, and Flutter obfuscation, the bug was a missing ownership check on one integer field.
Vulnerable Code Examples
Server-side (illustrative, generic)
Vulnerable
@app.post("/api/v1/transaction/transfer")
def transfer(req: TransferRequest, user=Depends(auth_user)):
# Trusts client-supplied from_account
debit(req.from_account, req.amount)
credit(req.to_account, req.amount)
return record_transfer(req)
Client can set from_account to any existing account.
Patched
@app.post("/api/v1/transaction/transfer")
def transfer(req: TransferRequest, user=Depends(auth_user)):
if req.from_account not in user.owned_accounts:
raise Forbidden("from_account not owned by caller")
if req.amount <= 1:
raise BadRequest("amount too small")
debit(req.from_account, req.amount)
credit(req.to_account, req.amount)
return record_transfer(req)
Ownership is enforced server-side. Client fields are untrusted by default.
Client crypto (pattern only)
Dangerous assumption in a Python reimplementation
# Wrong: OAEP on raw key bytes
enc_key = rsa_oaep(aes_key)
Matching the real client
# Right: OAEP on base64(key) as UTF-8, SHA-256
enc_key = rsa_oaep(base64.b64encode(aes_key))
One line difference. Entire protocol green or red.
Defense / How to Fix
- Never trust client-supplied identity fields for authorization. Account IDs, roles, and balances come from the server session, not the JSON body.
- Encrypting the channel is not access control. TLS or custom envelopes stop eavesdroppers, not confused deputies.
- Validate amounts and state transitions with clear error messages that do not crash the process (empty HTTP status lines are a gift to attackers and a nightmare for ops).
- Avoid shipping long-lived server public keys if you can use standard mTLS or short-lived session keys, but even then, assume the client is fully hostile.
- Do not invent crypto formats unless you have a review process. Prefer well-tested stacks (TLS + standard auth). Custom hybrid schemes are where silent 400s and SHA-1 vs SHA-256 footguns live.
- Pinning and obfuscation raise the cost of RE; they do not remove the need for server-side checks.
Final Thoughts
Flutter AOT looks scary until you treat libapp.so as a first-class artifact and use tools that speak Dart. The protocol recovery path is mechanical: dump symbols, list headers, list toJson fields, match the crypto defaults of the library the app actually uses, then implement a thin client.
The ironic part is always the same. Teams spend weeks on envelopes and PIN XOR theater, then leave from_account as a free-form integer. The box was locked. The key was under the lid. And the teller never checked whose name was on the withdrawal slip.
If you build mobile APIs, assume every field in the body is attacker-controlled, even when it arrives inside three layers of RSA and AES.