Mobile apps are not black boxes. They are binaries running on hardware your users control, inspectable by anyone with the right tools and enough patience. That is true regardless of the framework you build them with — but the way you get inside changes completely depending on the framework.
Our companion research on a Flutter application showed one path: Dart's ahead-of-time compilation strips symbols and statically links BoringSSL, so the analyst pivots to memory scanning and native function hooks. React Native is a different beast entirely. Business logic runs in JavaScript, which sounds like an open door — until you discover the JavaScript is compiled to Hermes bytecode that no current decompiler can read.
This post documents a complete, authorized security research engagement against a production React Native application on Android. Over seven phases, using Frida, LIEF, and Python, the app's API authentication mechanism was fully reconstructed and its member-directory API was reached from a standalone client — without ever decompiling a single line of the JavaScript bundle.
The goal of publishing this methodology is not to encourage unauthorized access to any system. It is to give React Native developers, mobile security engineers, and CTOs an accurate picture of what a determined analyst can reach — and to motivate the defensive practices covered in the companion hardening post.
The Target Environment
The application was a production Android app in the events and professional-networking sector, built on a commercial React Native events platform. Its API used a dual-layer authentication scheme: a short-lived JWT bearer token combined with a long-lived refresh-token cookie. That combination — not the JavaScript, not the TLS — turned out to be the entire puzzle.
Key architectural details:
| Component | Detail |
|---|---|
| Framework | React Native (JavaScript, Hermes AOT bytecode) |
| JS bundle | index.android.bundle — ~12 MB Hermes bytecode, version 96 |
| Native libraries | libreactnative.so, libhermes.so, and platform modules |
| Distribution | Split APK format (base + ABI + density + locale splits) |
| Networking | React Native NetworkingModule → Android CookieManager |
| Authentication | Short-lived JWT bearer token + long-lived refresh cookie |
| Login | OAuth via a Chrome Custom Tab (AppAuth) against a hosted identity provider |
The research used Frida 17.10.0, LIEF, apktool, and Python. Unlike the Flutter engagement, no disassembler was needed — every meaningful discovery happened at the framework's Java bridge or in Android's own networking layer.
Phase 1: APK Extraction and Framework Identification
The first job is always to figure out what you are actually looking at. The package was pulled from the device with adb:
adb shell pm path <package>
adb pull /data/app/.../base.apk ./base.apk
adb pull /data/app/.../split_config.arm64_v8a.apk ./split.apk
Unpacking base.apk and inspecting assets/ answered the framework question immediately:
assets/index.android.bundle ← ~12 MB Hermes bytecode (React Native)
Running file on the bundle confirmed it: Hermes JavaScript bytecode, version 96.
This is the moment the engagement's whole strategy is decided. A React Native app compiled to Hermes bytecode is not the readable-JavaScript app people imagine. Every available static tool was tried and failed:
hbctool— supports Hermes bytecode only up to version 90; the target was 96react-native-decompiler— broke on the installed Node.js runtime and never produced output
Conclusion: static analysis of the JavaScript was not viable. The bytecode version was newer than any public decompiler supported. This is a recurring reality in mobile security research — the tooling lags the runtime, and you cannot wait for it to catch up mid-engagement. The pivot to runtime hooking was made here, in phase one.
The manifest, decoded with apktool, gave several useful signals for later phases:
android:extractNativeLibs="false"—.sofiles are memory-mapped directly from the APK ZIP (this becomes critical in Phase 2)targetSdkVersion=35— user-installed CA certificates are not trusted by default- No
networkSecurityConfigand noCertificatePinnerin the code — no custom certificate pinning - An AppAuth redirect activity — login runs through a Chrome Custom Tab, not an in-app WebView
Phase 2: Injecting a Frida Gadget
The test device was not rooted, so frida-server was not an option. Instead a Frida Gadget was injected directly into the APK using LIEF — the same technique used in the Flutter engagement, pointed at a different library.
The chosen target was libreactnative.so, the React Native core, because it is reliably one of the first libraries loaded. LIEF adds the Gadget as a dependency by patching the ELF's NEEDED entries:
import lief
lib = lief.parse("lib/arm64-v8a/libreactnative.so")
lib.add_library("libfrida-gadget.so") # adds a DT_NEEDED entry
lib.write("lib/arm64-v8a/libreactnative.so")
The Gadget was configured to listen on 0.0.0.0:27042 with on_load: resume so the app boots normally.
Two failures worth documenting, because they are the exact walls most people hit repackaging a modern APK:
-
INSTALL_FAILED_INVALID_APK: Failed to extract native libraries. Because the manifest setsextractNativeLibs="false", Android memory-maps the.sofiles straight out of the ZIP, so they must be stored uncompressed. The fix was writing every.sowithZIP_STOREDinstead ofZIP_DEFLATED. -
INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match. The original app was signed with the store key; the repackaged split was debug-signed. The fix was uninstalling the original first, then installing all splits together withadb install-multiple.
One more lesson: an early attempt to decode, edit, and rebuild the base APK with apktool (to add a network-security config) crashed the app with a NullPointerException in resource handling — a known apktool resource-ID shuffling bug on complex apps. The winning move was to leave the original base APK untouched and ship only the patched ABI split. Minimize what you repackage.
After a clean install, adb logcat showed the Gadget listening, and frida-ps over a forwarded port confirmed a live instrumentation endpoint.
Phase 3: Why SSL Interception Failed
The obvious next step is to point the device at an intercepting proxy and read the traffic. It did not work — and why it did not work is more instructive than the technique itself.
A standard mitmproxy setup with the CA installed as a user certificate produced a "no internet connection" state in the app. Several rounds of Frida SSL-bypass scripts were tried against the usual suspects:
- Registering a permissive
X509TrustManager— failed on this Frida version's interface-instantiation behavior - Hooking
okhttp3.CertificatePinner.check— the class did not exist - Hooking conscrypt's
TrustManagerImpl.verifyChain— no effect
The breakthrough was enumerating the actually-loaded classes:
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("okhttp") !== -1) console.log(name);
},
onComplete: function () {}
});
// → com.android.okhttp.OkHttpClient
// → com.android.okhttp.okio.*
The app used com.android.okhttp — Android's bundled, internal OkHttp — not the third-party okhttp3. Every bypass script had been hooking classes that were never loaded. This is a classic false assumption: the library you expect is not always the library that runs.
But there was a deeper reason to abandon the proxy entirely. The app's requests used withCredentials: true, meaning authentication cookies are attached by Android's native networking layer, not by the JavaScript. Even a perfect TLS intercept would show the request without revealing where the real credential lived. The proxy was the wrong altitude. The right altitude was the JavaScript-to-native bridge.
Phase 4: A Login Flow That Cannot Be Hooked
Login ran through AppAuth — an OAuth flow rendered in a Chrome Custom Tab against a hosted identity provider, redirecting back to the app via a custom URL scheme once authentication completed.
This matters because a Chrome Custom Tab runs in a separate Chrome process. Any Frida hook placed in the app process — including a WebView SSL hook — has zero effect on it. There is no in-app WebView to instrument.
The workaround was pragmatic: log in with the proxy disabled. The OAuth exchange completes normally in Chrome, the session tokens are stored, and every subsequent API call carries them. Interception is only needed for the API traffic after login — never for the login step itself. Knowing which parts of a flow you can and cannot instrument saves hours of fighting the wrong battle.
Phase 5: Hooking the React Native Bridge
With a valid session established, the question became: how does every fetch() in the JavaScript reach the network? The answer in React Native is a single Java class that every HTTP call funnels through, regardless of the underlying HTTP client:
com.facebook.react.modules.network.NetworkingModule.sendRequest()
This is the ideal hook point. It is framework-level, so it does not care whether the app uses okhttp3 or com.android.okhttp, and it sees the request fully formed but before it hits the wire. The only trick was getting the overload signature exactly right — the timeout parameter is a double, not an int, and the wrong type silently fails to bind:
const NM = Java.use("com.facebook.react.modules.network.NetworkingModule");
NM.sendRequest.overload(
"java.lang.String", // method
"java.lang.String", // url
"double", // requestId
"com.facebook.react.bridge.ReadableArray", // headers
"com.facebook.react.bridge.ReadableMap", // data
"java.lang.String", // responseType
"boolean", // useIncrementalUpdates
"double", // timeout — double, not int
"boolean" // withCredentials
).implementation = function (method, url, requestId, headers, data,
responseType, incremental, timeout, withCredentials) {
console.log("[REQUEST] " + method + " " + url);
return this.sendRequest(method, url, requestId, headers, data,
responseType, incremental, timeout, withCredentials);
};
Instantly, every API call the app made was visible in the clear — method, URL, query parameters, and headers — including the directory endpoint, its pagination parameters, and a Bearer token in the Authorization header. The withCredentials: true flag was set on those calls, which was the tell that cookies were also in play, even though they did not appear in the headers array.
Phase 6: The Missing Credential
The captured bearer token was tried directly against the API. It failed:
{ "message": "Not logged in" }
Decoding the JWT explained why it looked plausible but did not work alone — it was an access token with a ~20-minute lifetime ("typ": "access"). The app kept working long after that window because it was sending a second credential the JavaScript never touched: a refresh-token cookie attached natively because of withCredentials.
That cookie lives in Android's WebView cookie store, reachable directly through Frida's Java bridge — no JavaScript, no TLS interception required:
Java.perform(function () {
const CookieManager = Java.use("android.webkit.CookieManager");
const cookies = CookieManager.getInstance().getCookie("https://<api-host>");
console.log(cookies);
});
// → __rt=<refresh JWT, ~7-day lifetime>; ...
The refresh token was itself a JWT, scoped to the API host, with a multi-day lifetime. The server validated both credentials: the short-lived access token and the long-lived refresh cookie. Missing the cookie — not an expired JWT — was the reason every earlier request had been rejected.
This is the single most important finding of the engagement, and it was invisible from every angle except the native cookie store. It is the React Native equivalent of the embedded private keys we found in the Flutter engagement: the credential that mattered was never where the obvious analysis looked.
Phase 7: Reconstructing the API
With both credentials in hand, the directory endpoint was called from a standalone Python client:
curl -H "authorization: Bearer <ACCESS_TOKEN>" \
-H "cookie: __rt=<REFRESH_TOKEN>" \
"https://<api-host>/api/v1/mobile/<directory-endpoint>?limit=50&offset=0"
# → HTTP 200
The response was a clean, paginated JSON list. And this is where a reverse-engineering finding becomes a real security finding: the endpoint accepted a caller-controlled offset and limit, applied no meaningful rate limiting, and returned records with no per-object authorization check beyond "you have a valid session." One authenticated user could walk the entire member directory, page by page, from a script.
We stopped at proof-of-concept — enough pages to confirm the exposure existed, not to collect data. The record counts and the personal fields returned are deliberately omitted from this write-up. The point is the class of flaw, not its contents.
What This Research Demonstrates
Several assumptions commonly made about React Native security did not hold:
-
"React Native apps are just readable JavaScript." Compiled to Hermes bytecode version 96, the bundle defeated every available decompiler. Static analysis of the JS was a dead end.
-
"If I can't read the JS, my logic is safe." The exact opposite. Because all HTTP flows through one Java bridge method,
NetworkingModule.sendRequestis a single, framework-agnostic interception point that exposes every request in plaintext — no JS decompilation required. -
"TLS interception is how you read a mobile app's traffic." Here it was a distraction. The app used Android's internal OkHttp and native cookie handling, so the decisive data lived in
CookieManager, not on the wire. -
"A short-lived access token limits exposure." It does not, when a long-lived refresh cookie sits beside it and the server honors the pair. The refresh token extended a single login into days of API access.
-
"Reaching the API is the finish line." Reaching it only revealed the real problem — a directory endpoint with client-controlled pagination, no rate limiting, and object-level authorization that trusted the session too much. That is a server-side flaw no amount of client hardening fixes.
The companion post covers what you can actually do to defend against this class of analysis — on both the client and the API.
Tools Used
| Tool | Purpose |
|---|---|
| Frida 17.10.0 | Dynamic instrumentation framework |
| Frida Gadget | In-process agent injected into the APK |
| LIEF | ELF binary manipulation for gadget injection |
| apktool | APK decoding and manifest analysis |
| Python / requests | Standalone API client |
| adb, zipalign, apksigner | APK extraction, alignment, signing |
Understanding what is reachable in your mobile application is the first step to making better decisions about what to put in the client, how to authenticate its API, and what threat model your app actually faces. Whether you build on React Native, Flutter, or native code, the lesson is the same one we drew from reverse engineering a Flutter app: the client is never the security boundary — the API is. If you are still weighing framework and build-versus-buy trade-offs for a new product, our guide on custom software versus off-the-shelf software covers the decisions that shape your security posture long before the first line of code.
If you need a professional reverse engineering assessment of your mobile application, IQCrafter's Mobile Security & Reverse Engineering team conducts end-to-end security engagements — from binary analysis to API authorization review and a hardening roadmap — so you know exactly what an attacker can reach before they do.