Mobile apps shipped to production are not black boxes. They are binaries running on hardware your users control, inspectable by anyone with the right tools and enough patience.
Flutter apps carry a reputation for being difficult to reverse engineer. That reputation is partially deserved. Dart's ahead-of-time compilation strips away symbol names, class hierarchies, and readable bytecode. BoringSSL is compiled statically into the Flutter engine, defeating most SSL interception tools. There are no obvious seams to grab.
But difficult is not the same as impossible.
This post documents a complete security research engagement against a production Flutter application on Android. Over ten phases, using Frida, Blutter, LIEF, Capstone, and Python, the app's API authentication mechanism was fully reconstructed from scratch — including extracting embedded RSA private keys, recovering the exact signing message format, and successfully calling the production API from a standalone Python client.
The goal of publishing this methodology is not to encourage unauthorized access to any system. It is to give Flutter developers, mobile security engineers, and CTOs an accurate picture of what a determined analyst can do — and to motivate the defensive practices covered in the companion post.
The Target Environment
The application was a production Android Flutter app in the logistics and supply chain sector. Its API used a custom RSA-SHA256 signing scheme to authenticate every request. All business logic, including the signing implementation, lived inside a single Dart AOT-compiled native library with full symbol obfuscation.
Key architectural details:
| Component | Detail |
|---|---|
| Framework | Flutter (Dart AOT, ARM64) |
| Native library | libapp.so — 14.4 MB, 5 exports, fully obfuscated |
| Flutter engine | libflutter.so — 27.8 MB, 47 GPU-related exports, statically-linked BoringSSL |
| Distribution | Split APK format |
| Authentication | Custom RSA-SHA256 request signing |
The research used Frida 17.10.0, Blutter, Capstone, LIEF, and Python across roughly 80 scripts developed over multiple sessions.
Phase 1: APK Extraction and Frida Gadget Injection
Before any instrumentation is possible, Frida needs a way into the process. For a standard Android app, frida-server running on a rooted device is sufficient. For a Flutter app with statically-linked BoringSSL, it is not — the standard server attaches after startup, missing the library load events that matter most.
The solution was injecting a Frida Gadget directly into the APK using LIEF.
The process:
- Extract the split APKs from the device using
adb - Unpack the ARM64 split to access
lib/arm64-v8a/ - Use LIEF to add
frida-gadget.soas a dependency oflibflutter.soby patching the ELF NEEDED entries - Configure the Gadget to listen on
0.0.0.0:27042withon_load: resumeso the app starts normally - Repack,
zipalign, and re-sign all split APKs with a debug keystore - Install with
adb install-multiple
After this, the app launches normally on device and exposes a Frida instrumentation endpoint. Any script can attach remotely:
frida -H 192.168.100.x:27042 -l script.js Gadget
LIEF handles the ELF patching cleanly. The Gadget sits in memory from process start, meaning hooks can be placed before any initialization code runs.
Phase 2: Static Analysis — Strings and Keys
With Frida attached, the loaded libapp.so memory was scanned for useful strings using Memory.scan().
API endpoint discovery was straightforward. A scan for the pattern /api/ returned over 20 endpoint paths, including the API base URL. Every endpoint the app could call was mapped in minutes.
RSA key extraction was the first major finding. A scan for the string -----BEGIN PRIVATE KEY----- returned two hits inside libapp.so at offsets 0x85bb0 and 0x9f486. Both were 2048-bit RSA private keys, stored in plaintext inside the binary. These are signing keys — the app uses them to prove request authenticity to the server.
Signing-related strings were also found at known offsets:
nonce=and×tamp=— signing message componentssha256WithRSAEncryption— the signing algorithmbuildSignature— a reference to the signing functionfwk_security— a security key reference stored in Android's secure storage
Embedding private keys in application binaries is a common pattern in mobile apps. It is also a fundamental security mistake, because anyone who can read libapp.so — which is everyone — can read those keys.
Phase 3: Dart AOT Snapshot Analysis with Blutter
Static string scanning tells you what data exists. It does not tell you how that data is used. To understand the signing function's logic, the Dart AOT snapshot needed to be parsed.
Blutter is an open-source tool that reads the metadata in a Dart AOT snapshot and recovers class names, method signatures, object pool entries, and function addresses — even from builds compiled with --obfuscate.
Running Blutter on libapp.so produced:
pp.txt(2.9 MB) — a full dump of the object pool, which contains every string constant, class reference, and closure address in the snapshotasm/(1050 files) — recovered Dart class skeletons with method addressesblutter_frida.js(489 KB) — an auto-generated Frida helper for reading Dart heap objects at runtime
The object pool was the critical artifact. At a cluster of consecutive offsets, the following strings appeared in order:
"nonce="
"×tamp="
"&method="
"&url="
"&ooap_verification="
"OOAP_SKIP_VERIFY_TOKEN"
"X-Signature"
"X-Verify"
"X-Nonce"
"X-Timestamp"
The adjacency of these strings in the pool identifies them as data dependencies of a single function — the request signing function. The exact structure of the signing message, and the HTTP headers the app sets, were both visible here.
Also recovered from the pool was the signing function's closure address. Even with all symbol names obfuscated to strings like zub, Cub, and tOg, Blutter could tell you: the function at address 0xa30cfc is the signing message builder, and the function at 0xa35394 is the header setter.
Phase 4: Android Keystore Decryption
Some secrets are not embedded in the binary. This app stored an additional security key in Android's FlutterSecureStorage, which encrypts values using the Android Keystore system.
Using Frida's Java bridge (Java.perform()), the full decryption chain was executed at runtime:
- Read the encrypted blob from
FlutterSecureStorage.xmlviaSharedPreferences - Retrieve the RSA key pair from the Android Keystore at its alias
- Decrypt the wrapped AES key using
RSA/ECB/PKCS1Padding - Decrypt the stored secret value using the recovered AES key with
AES/CBC/PKCS7Padding
The extracted value was a UUID-format security key that appears in certain signing paths.
One important limitation: Java.perform() cannot access Flutter Dart classes directly. Calling it against Dart objects crashes the app. It works only against Android framework classes — SharedPreferences, KeyStore, Cipher, etc. For Flutter-level inspection, native ARM64 hooks are required instead.
Phase 5: SSL Pinning Bypass — And Why It Failed
Three separate approaches were attempted to bypass SSL certificate pinning and route traffic through a proxy:
- Scanning
libflutter.sofor BoringSSL verification function patterns (x509,CERTIFICATE_VERIFY,SSL_VERIFY) - Targeting the BoringSSL SHA-256 K-table (a known constant) to locate
ssl_verify_peer_certby relative offset - Using Frida Stalker to trace calls from known TLS context entry points
All three failed for the same reason: Flutter statically compiles BoringSSL into libflutter.so with every symbol stripped. The 27.8 MB binary has 47 exports — all GPU-related (eglGetDisplay, glTexImage2D, etc.). No crypto function names survive. Pattern matching against function prologues was unreliable due to compiler optimization and inlining.
This is the primary reason Flutter apps are harder to intercept than typical Android apps. Standard tools like Objection and frida-ssl-bypass rely on finding SSL_CTX_set_verify or X509_verify_cert by name. When those names do not exist, those tools do nothing.
A tool called reFlutter exists specifically to address this — it patches libflutter.so to redirect TLS to a custom proxy. It was identified as viable but not pursued, because a more direct approach succeeded.
Phase 6: Network Syscall Interception
With SSL interception off the table, hooks were placed on libc syscalls to detect network activity timing:
send,sendto,sendmsg,write,writev
Flutter uses writev for TLS data transmission. By inspecting the first bytes of each call, TLS record types could be identified (0x17 for application data, 0x16 for handshake). This confirmed exactly when API requests were being sent and to which host.
The ciphertext could not be read, but the timing was enough to trigger targeted memory scans at the moment a request was in flight. Those scans found nothing — the signing data exists in memory only for the duration of the signing function's execution, not at the moment the TLS write happens. The answer was to hook the signing function directly.
Phase 7: ARM64 Disassembly with Pool Pointer Tracking
With function addresses in hand from Blutter, the signing message builder at 0xa30cfc was disassembled using Capstone.
In Dart AOT on ARM64, register x27 holds the pool pointer — a reference to the object pool recovered earlier by Blutter. Instructions that load string constants follow a predictable pattern:
add x16, x27, #0x24, lsl #12 ; x16 = PP + 0x24000
ldr x16, [x16, #0x280] ; load PP[0x24280] = "nonce="
By annotating each pool-relative load with its resolved string from pp.txt, the disassembly became readable. Key findings:
Timestamp computation: The code calls DateTime.now(), which returns microseconds since epoch, then performs two successive integer divisions by 1000 to get epoch seconds. Not milliseconds. This distinction broke every earlier brute-force attempt that used milliseconds.
Two signing paths: The function builds two separate signing messages:
- Path 1:
nonce={uuid}×tamp={epoch_s}&method={METHOD}&url={short_path} - Path 2:
nonce={uuid}×tamp={epoch_s}&ooap_verification=OOAP_SKIP_VERIFY_TOKEN
Both paths are always computed. A condition selects which to use based on the request type.
Short URL, not full path: The url component in the signing message is a short path like /cargo-tracking/{id}, not the full HTTP path like /api/shipment/cargo-tracking/{id}. This is the single most important finding from the disassembly phase.
Phase 8: Live Signing Message Capture with Frida + Blutter
This was the phase where everything came together.
The plan: hook the string concatenation function that builds the signing message and read its output as a Dart String object using Blutter's runtime helpers.
Dart compressed pointers are the main complication. On ARM64, Dart uses 32-bit compressed object references. The real address of a heap object is:
real_address = heap_base + compressed_pointer.toInt32()
The heap base is stored in register x28. Blutter's auto-generated blutter_frida.js provides init(context) to capture it on first invocation, plus decompressPointer(), getDartString(), and getTaggedObjectValue() for reading heap objects correctly.
Dart String layout:
OneByteString: [8-byte header][4-byte hash][4-byte length_smi][string bytes...]
The length is stored as a Smi (Small Integer): actual length = raw_value >> 1. String bytes start at offset 0x10.
The hook setup:
- Hook
zub.tOgat0xa30cfc— set a flag marking entry into the signing function - Hook
_StringBase._interpolateat0x388d70— capture every string concatenation result while the flag is set - Hook
innerHelperat0xa311a8— read both Path 1 and Path 2 signing messages from registersx0andx1, plus the resulting RSA signature - Hook
Cub.tOgat0xa35394— confirm header setter invocation
With this in place and the app making live requests, the signing messages were captured verbatim:
nonce=133e9866-35e1-4c6f-94a6-5bcf6bc66f66×tamp=1780594140&method=GET&url=/cargo-tracking/CONT123456
nonce=c50dc94b-e955-466f-abf9-beaa97159ac8×tamp=1780594136&method=POST&url=/system-notification/messages
The full Base64 RSA signatures were also captured for each request, enabling offline verification.
Phase 9: Brute-Force Format Testing (Retrospective)
Before live capture succeeded, over 100 signing format permutations were tested systematically:
| Dimension | Variations |
|---|---|
| Nonce/timestamp order | Both orderings |
| Timestamp format | Epoch seconds, epoch milliseconds, formatted date string |
| URL format | Full path, short path, with /api/, without scheme |
| Method case | GET, get, Get |
| RSA algorithm | PKCS1v15+SHA256, PSS+SHA256 |
| Which RSA key | Key 1, Key 2 |
| Extra headers | With/without device identifiers |
Every combination returned HTTP 417 (Expectation Failed). The two critical details that no static analysis could have revealed definitively — epoch seconds instead of milliseconds, and the short URL format — were only visible in the live capture.
Phase 10: Validation
With the exact signing format in hand, a standalone Python client was written using the cryptography library to sign requests locally with the extracted RSA key.
The captured signing message was re-signed locally with Key 1. The resulting signature matched the app's captured signature character-for-character, confirming Key 1 is the correct signing key and that the format was exactly right.
The Python client then successfully called multiple production endpoints with HTTP 200 responses.
Confirmed working format:
Signing message: nonce={uuid}×tamp={epoch_seconds}&method={METHOD}&url={short_path}
Algorithm: RSA PKCS1v15 + SHA-256
Key: Embedded RSA private key from libapp.so
Headers: X-Nonce, X-Timestamp, X-Signature
What This Research Demonstrates
Across ten phases and roughly 80 scripts, a Flutter app's complete API authentication scheme was reconstructed from a compiled binary with no source code access and full symbol obfuscation. The entire process — from a patched APK install to confirmed API responses — was completed within a research engagement timeline.
Several assumptions commonly made about Flutter security did not hold:
-
"Dart AOT obfuscation prevents analysis" — It prevents casual string extraction. It does not prevent Blutter from recovering the object pool, which contains all string constants and function addresses. The obfuscated names (
zub,tOg) are irrelevant once you have the addresses. -
"Private keys are safe inside libapp.so" — Memory scanning found two RSA private keys in plaintext within minutes of attaching Frida. Any key stored in a binary is accessible to anyone who can run the binary.
-
"Statically-linked BoringSSL defeats interception" — It defeats standard SSL bypass tools. It does not defeat application-layer function hooks. Hooking the signing function is more informative than intercepting TLS anyway — you get the plaintext data before it is signed, not after.
-
"Flutter SecureStorage is secure against a rooted device" — Android Keystore-backed storage is secure against static extraction but is fully decryptable at runtime through Frida's Java bridge on a rooted device.
The companion post covers what you can actually do to harden a Flutter app against this class of analysis.
Tools Used
| Tool | Purpose |
|---|---|
| Frida 17.10.0 | Dynamic instrumentation framework |
| Frida Gadget | In-process agent injected into APK |
| LIEF | ELF binary manipulation for gadget injection |
| Blutter | Dart AOT snapshot decompiler |
| Capstone | ARM64 disassembly engine |
| Python / cryptography | API client and RSA signing |
| adb, zipalign, apksigner | APK extraction, alignment, signing |
Understanding what is reversible in your mobile application is the first step to making better decisions about what to put there, how to protect it, and what threat model your app actually faces. The defensive side of this research is covered in How to Harden Your Flutter App Against Reverse Engineering.