June 29, 2026
I Got Tired of the Same VAPT Findings. So I Built an Open-Source Fix.
Every mobile security audit flagged the same gaps. Here’s how I addressed them in a single React Native package.

By Mahesh Bhusanoor
6 min read
Every mobile security audit flagged the same gaps. Here's how I addressed them in a single React Native package.
If you've shipped a React Native app to production and put it through a security audit, you've probably seen some version of these findings:
- "Application does not detect jailbroken/rooted devices"
- "Frida instrumentation framework can attach to the application at runtime"
- "No debugger detection mechanism found"
- "Runtime hooking frameworks (Xposed, Substrate) are not detected"
These aren't edge cases. They show up on almost every VAPT report for mobile apps that handle payments, user authentication, or any form of sensitive data. And for a long time, the standard response was either "buy an enterprise SDK" or "add a half-working open-source library that checks two file paths and hopes for the best."
I got tired of that answer.
After seeing the same findings across multiple projects at scale — including enterprise retail apps serving millions of users across the Middle East — I decided to build something properly. The result is @noobdigital/react-native-shieldscan: a free, open-source React Native security module that covers the full threat surface in a single native call, with proper New Architecture (Turbo Modules) support.
This post explains the threat model, how the detection works under the hood, and how to integrate it. By the end, you'll understand not just what to check, but why each check matters and what an attacker actually does with each of these techniques.
Understanding the Threat Model
Before writing a single line of detection code, it helps to understand what you're actually defending against and what the attacker's workflow looks like.
The attacker's typical workflow
A motivated attacker targeting a React Native app usually follows this sequence:
- Obtain the APK or IPA — trivial for Android (Play Store or direct install), slightly harder for iOS but achievable
- Set up a jailbroken/rooted device or emulator — provides full filesystem access and the ability to run privileged tools
- Attach Frida — hooks JavaScript and native functions at runtime, intercepts network calls, disables SSL pinning
- Explore the app's internals — reads the JS bundle, maps native modules, identifies authentication and payment flows
- Modify runtime behavior — patches return values, bypasses checks, extracts secrets
Your app's first line of defence is detecting steps 2 and 3 before the attacker gets any further.
What detection actually buys you
Runtime security checks are not a silver bullet. A sufficiently motivated attacker with enough time can bypass most detection mechanisms — that's a fact worth acknowledging upfront.
What detection does is raise the cost. It forces the attacker to invest time bypassing checks rather than attacking your actual business logic. It gives you telemetry on how many users are running compromised environments. And for most threat models — opportunistic attackers, automated scripts, script kiddies — it's enough to make your app an unattractive target.
The Six Checks
1. Jailbreak and Root Detection
What it is: iOS jailbreaking and Android rooting both involve breaking the operating system's security model to gain privileged access. On iOS, this typically means bypassing Apple's code signing and installing Cydia. On Android, it means gaining root (su) access, often via Magisk.
Why it matters: On a jailbroken or rooted device, your app's sandbox is no longer isolated. Any other process with root access can read your app's private files, keychain values, and in-memory secrets. The assumption that "my data is safe in the keychain" breaks entirely.
How the package detects it:
On iOS, three layers of checks run:
// Layer 1: Known jailbreak artifact paths
let jailbreakPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
// ...
]
// Layer 2: Sandbox escape test
// Jailbroken apps can write outside their container
let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
// If this succeeds → jailbroken
// Layer 3: Symbolic link check
// /Applications is symlinked on jailbroken devices
FileManager.default.destinationOfSymbolicLink(atPath: "/Applications")// Layer 1: Known jailbreak artifact paths
let jailbreakPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
// ...
]
// Layer 2: Sandbox escape test
// Jailbroken apps can write outside their container
let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
// If this succeeds → jailbroken
// Layer 3: Symbolic link check
// /Applications is symlinked on jailbroken devices
FileManager.default.destinationOfSymbolicLink(atPath: "/Applications")The sandbox escape test is particularly valuable because it cannot be bypassed by simply hiding the Cydia app or deleting the standard artifact files. A jailbroken OS is a jailbroken OS, and it will always allow writes outside the sandbox.
On Android, the package delegates to RootBeer 0.1.1, which covers su binary detection, dangerous system properties, writable system paths, and known root management apps including Magisk.
2. File-Based Root Detection
This is a separate, complementary check that looks specifically for tool artifacts on disk — Frida agent files, Magisk modules, Xposed Bridge JARs, and similar. It's distinct from the primary root check because some tools leave file traces even after the device owner attempts to hide the root.
On iOS:
let suspiciousPaths = [
"/usr/lib/frida/frida-agent.dylib",
"/usr/lib/frida/frida-gadget.dylib",
]let suspiciousPaths = [
"/usr/lib/frida/frida-agent.dylib",
"/usr/lib/frida/frida-gadget.dylib",
]On Android this expands to cover Xposed Bridge, Magisk artifacts, and SuperSU installation paths.
3. Frida Detection
Frida deserves special attention because it's the most capable and most commonly used tool for attacking React Native apps specifically. The JavaScript runtime makes React Native apps particularly interesting targets — an attacker who successfully attaches Frida can read and modify your entire JS bundle at runtime.
The package probes three independent vectors:
Vector 1: Dynamic library injection
// If frida-agent is injected into the process, dlopen will succeed
if dlopen("frida-agent.dylib", RTLD_NOW) != nil { return true }
if dlopen("FridaGadget.dylib", RTLD_NOW) != nil { return true }// If frida-agent is injected into the process, dlopen will succeed
if dlopen("frida-agent.dylib", RTLD_NOW) != nil { return true }
if dlopen("FridaGadget.dylib", RTLD_NOW) != nil { return true }Vector 2: Default server port Frida server listens on TCP port 27042 by default. A socket connection probe takes ~50ms on a clean device (connection refused) but succeeds immediately if Frida server is running:
private func isFridaPortOpen(port: Int32) -> Bool {
let sock = Darwin.socket(AF_INET, SOCK_STREAM, 0)
// ... connect to 127.0.0.1:27042
// returns true if connection succeeds
}private func isFridaPortOpen(port: Int32) -> Bool {
let sock = Darwin.socket(AF_INET, SOCK_STREAM, 0)
// ... connect to 127.0.0.1:27042
// returns true if connection succeeds
}Vector 3: Environment variable
if ProcessInfo.processInfo.environment["FRIDA_DEBUG"] != nil { return true }if ProcessInfo.processInfo.environment["FRIDA_DEBUG"] != nil { return true }The multi-vector approach means that even if an attacker hides the Frida agent dylib or changes the default port, the remaining vectors still have a chance of detecting the presence.
4. Debugger Detection
A debugger attached to your production app is an unambiguous security event. On iOS, the kernel exposes this via the P_TRACED flag in the process information structure:
var info = kinfo_proc()
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.stride
sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
return (info.kp_proc.p_flag & P_TRACED) != 0var info = kinfo_proc()
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.stride
sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
return (info.kp_proc.p_flag & P_TRACED) != 0On Android:
return android.os.Debug.isDebuggerConnected()return android.os.Debug.isDebuggerConnected()Important: This check will return true during your own Xcode and Android Studio debug sessions. Always guard hard blocks with !__DEV__ or build variant checks.
5. Emulator / Simulator Detection
Emulators are used both by legitimate QA teams and by attackers who want to run your app in a controlled, reversible environment. The check is intentionally separated from isDeviceCompromised() because many teams run automated tests on emulators — you don't want to block your own CI pipeline.
On iOS, this is a compile-time check with zero runtime cost:
#if targetEnvironment(simulator)
return true
#else
return false
#endif#if targetEnvironment(simulator)
return true
#else
return false
#endifOn Android, Build.FINGERPRINT, Build.MANUFACTURER, and hardware sensor availability are used as heuristics.
6. Runtime Hooking Framework Detection
Xposed, LSPosed, EdXposed, Cydia Substrate, Substitute, LibHooker — these frameworks are designed to modify the behavior of apps at runtime without touching the binary. They're the tool of choice for bypassing in-app purchase validation, root detection, SSL pinning, and any other runtime check your app performs.
On iOS, the package scans all loaded dynamic libraries via the dyld API:
let imageCount = _dyld_image_count()
for i in 0..<imageCount {
if let cName = _dyld_get_image_name(i) {
let name = String(cString: cName)
// Check for Substrate, MobileSubstrate, Substitute,
// TweakInject, LibHooker...
}
}let imageCount = _dyld_image_count()
for i in 0..<imageCount {
if let cName = _dyld_get_image_name(i) {
let name = String(cString: cName)
// Check for Substrate, MobileSubstrate, Substitute,
// TweakInject, LibHooker...
}
}On Android, /proc/self/maps exposes every memory-mapped file for the current process, including injected libraries:
// Scan /proc/self/maps for known hooking framework paths
// Xposed, LSPosed, EdXposed, SandHook, Epic, Frida gadget// Scan /proc/self/maps for known hooking framework paths
// Xposed, LSPosed, EdXposed, SandHook, Epic, Frida gadgetIntegration
yarn add @noobdigital/react-native-shieldscan
cd ios && pod install
import { runSecurityChecks, isDeviceCompromised } from '@noobdigital/react-native-shieldscan';
// Full result — log everything, act on specific signals
const result = await runSecurityChecks();
// {
// rooted: false, fileBasedRoot: false, fridaDetected: false,
// debugger: false, emulator: false, hooksDetected: false
// }
// Or a single boolean gate
const compromised = await isDeviceCompromised();yarn add @noobdigital/react-native-shieldscan
cd ios && pod install
import { runSecurityChecks, isDeviceCompromised } from '@noobdigital/react-native-shieldscan';
// Full result — log everything, act on specific signals
const result = await runSecurityChecks();
// {
// rooted: false, fileBasedRoot: false, fridaDetected: false,
// debugger: false, emulator: false, hooksDetected: false
// }
// Or a single boolean gate
const compromised = await isDeviceCompromised();The recommended startup pattern:
async function enforceDeviceSecurity() {
const result = await runSecurityChecks();
// Always log to your security backend — even the clean results
// A sudden spike in fridaDetected: true is a signal worth seeing
analytics.track('device_security_check', result);
// Hard block on critical threats
if (result.rooted || result.fridaDetected || result.hooksDetected) {
// Show error, exit app, revoke session token
throw new Error('COMPROMISED_DEVICE');
}
// Soft warn on emulator in production
if (result.emulator && !__DEV__) {
console.warn('[ShieldScan] Running on emulator in production');
}
}async function enforceDeviceSecurity() {
const result = await runSecurityChecks();
// Always log to your security backend — even the clean results
// A sudden spike in fridaDetected: true is a signal worth seeing
analytics.track('device_security_check', result);
// Hard block on critical threats
if (result.rooted || result.fridaDetected || result.hooksDetected) {
// Show error, exit app, revoke session token
throw new Error('COMPROMISED_DEVICE');
}
// Soft warn on emulator in production
if (result.emulator && !__DEV__) {
console.warn('[ShieldScan] Running on emulator in production');
}
}Call this once at app startup, before any sensitive operations. The total execution time is under 100ms on a clean device — the most expensive check is the Frida port probe (~50ms connection refused).
New Architecture Support
The package ships a full TurboModule spec that drives JSI codegen:
// src/NativeShieldScan.ts
export interface SecurityScanResult {
rooted: boolean;
fileBasedRoot: boolean;
fridaDetected: boolean;
debugger: boolean;
emulator: boolean;
hooksDetected: boolean;
}// src/NativeShieldScan.ts
export interface SecurityScanResult {
rooted: boolean;
fileBasedRoot: boolean;
fridaDetected: boolean;
debugger: boolean;
emulator: boolean;
hooksDetected: boolean;
}On New Architecture (newArchEnabled=true), calls go directly through JSI with zero bridge serialisation overhead. On Old Architecture, it falls back to NativeModules.ShieldScan automatically.
VAPT Compliance Mapping
If you're working against an OWASP Mobile Top 10 audit:
OWASP Finding ShieldScan Signal M8 — Security Misconfiguration emulator, debugger M9 — Insecure Data Storage rooted, fileBasedRoot M10 — Insufficient Cryptography fridaDetected, hooksDetected
What This Doesn't Do
It's worth being explicit about the limits.
This package detects threats — it doesn't prevent them. A sufficiently motivated attacker can patch the detection logic itself (which is why pairing this with code obfuscation and certificate pinning matters). It also doesn't cover network-layer attacks, binary tampering detection, or integrity verification of the JS bundle.
Think of it as the detection layer in a defence-in-depth strategy, not the only line of defence.
Links
- npm: https://www.npmjs.com/package/@noobdigital/react-native-shieldscan
- GitHub: https://github.com/NoobDigital/react-native-shieldscan
If you're integrating this into a VAPT remediation effort and hit a false positive or a missed detection on a specific device or OS version, please open an issue with the details. Real-world device data is what makes detection more accurate for everyone.
Built by noobdigital.com — open source tools for React Native developers.