I've been working on a React Native security library for the past few months. Not because there wasn't anything out there, but because most existing solutions were either outdated, not maintained, or didn't support the New Architecture.
So I built react-native-root-jail-detect from scratch — native Swift for iOS, Kotlin + C++ for Android, full TurboModules and Fabric support.
Here's what I learned building it.
The Problem With Simple File Checks
Most jailbreak detection libraries do something like this:
let jailbreakPaths = ["/Applications/Cydia.app", "/bin/bash"]
return jailbreakPaths.contains { FileManager.default.fileExists(atPath: $0) }
This works — until it doesn't. /bin/sh exists on stock iOS. /var/log/syslog exists on non-jailbroken devices. And on the simulator, the entire macOS filesystem is exposed, so paths like /usr/bin/ssh and /etc/ssh/sshd_config all exist on a perfectly clean machine.
The fix isn't just a better list of paths. It's knowing which paths are safe to check, where, and when.
For the simulator I gate real-device-only checks behind:
#if targetEnvironment(simulator)
return false
#else
// actual checks
#endif
And paths like /bin/bash only go into a realDeviceOnlyPaths list that never runs on simulator.
Debugger Detection Is Trickier Than It Looks
There are four common approaches:
- sysctl P_TRACED — most reliable
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.size
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
let ret = sysctl(&name, 4, &info, &size, nil, 0)
guard ret == 0 else { return false }
return (info.kp_proc.p_flag & P_TRACED) != 0
- Mach exception ports — catches LLDB specifically
// If EXC_MASK_BREAKPOINT has a valid port registered,
// something is listening for breakpoints
let result = task_get_exception_ports(
mach_task_self_,
exception_mask_t(EXC_MASK_BREAKPOINT),
masks, &count, ports, behaviors, flavors
)
// Validate ports are not MACH_PORT_NULL — avoids false positives
- ptrace TRACEME
if ptrace(PT_TRACE_ME, 0, nil, 0) == -1 {
return errno == EPERM // already being traced
}
ptrace(PT_DETACH, 0, nil, 0)
return false
- TracerPid on Android
File("/proc/self/status").forEachLine { line ->
if (line.startsWith("TracerPid:")) {
return line.substringAfter(":").trim().toInt() > 0
}
}
The gotcha I hit: when you run your app via Xcode on a real device, Xcode automatically attaches LLDB. No breakpoints needed — just running from Xcode is enough to trigger all three iOS checks. The fix is gating these behind #if DEBUG or a custom build flag like SECURITY_CHECKS_ENABLED that you only set on Release and Staging schemes.
Frida Detection
Frida is the main tool attackers use to hook into running apps and bypass security checks. Detecting it has a few layers:
Loaded image scan:
for i in 0..<_dyld_image_count() {
let name = String(cString: _dyld_get_image_name(i)).lowercased()
if name.contains("frida") || name.contains("gum-js") {
return true
}
}
Symbol lookup:
let suspiciousSymbols = ["gum_js_loop", "frida_agent_main", "gum_script_backend_create"]
for sym in suspiciousSymbols {
if dlsym(UnsafeMutableRawPointer(bitPattern: -2), sym) != nil {
return true
}
}
On Android — memory map scan:
std::ifstream maps("/proc/self/maps");
std::string line;
while (std::getline(maps, line)) {
if (line.find("frida") != std::string::npos ||
line.find("gum-js") != std::string::npos) {
return true;
}
}
These only catch unobfuscated Frida. A determined attacker can rename the agent. But most script-kiddie level attacks use default Frida, and this catches those reliably.
The Runtime Watchdog
This was the most interesting part to build. File path checks and debugger checks at launch are good, but they miss a common attack pattern:
- App launches — device looks clean
- Attacker attaches Frida or LLDB after launch
- App never re-checks — attacker is in
The watchdog solves this by running checks continuously on a background thread with randomized timing (±40% jitter to make it harder to predict and bypass):
private static func randomDelay(base: TimeInterval) -> TimeInterval {
let jitter = base * 0.4
return base + TimeInterval.random(in: -jitter...jitter)
}
It also checks for timing gaps — if the loop stalls for more than interval * 4, it's a signal that the process was paused (e.g. by a debugger hitting a breakpoint):
if now - lastRun > interval * 4 {
handleThreat(detector: detector)
}
Three response modes depending on how aggressive you want to be:
enum ProtectionMode {
case logOnly // log and continue
case throwException // crash with reason
case terminate // kill(getpid(), SIGKILL)
}
For most apps I'd recommend logOnly in staging and terminate in production.
Method Swizzling Detection — What Didn't Work
My first approach was checking if UIViewController.viewDidLoad's IMP pointed back to UIKit:
// ❌ This doesn't work
let imagePath = String(cString: fname).lowercased()
return !imagePath.contains("uikit")
Problem: viewDidLoad is a Swift method. Its IMP can point to a Swift-to-ObjC thunk or a dispatch stub — neither resolves to a UIKit path via dladdr. Every app that overrides viewDidLoad would trigger this.
The fix is checking pure ObjC methods with stable, well-known IMPs instead:
// ✅ This works
let checks: [(AnyClass, Selector, String)] = [
(NSObject.self, #selector(NSObject.description), "libobjc"),
(NSObject.self, NSSelectorFromString("respondsToSelector:"), "libobjc"),
(NSArray.self, #selector(getter: NSArray.count), "corefounda"),
]
// Verify each IMP resolves back to its expected binary via dladdr
Usage
npm install react-native-root-jail-detect
import RootJailDetect, { SecurityWatchdog, ProtectionMode } from 'react-native-root-jail-detect';
// One-shot check
const isCompromised = await RootJailDetect.isDeviceRooted();
const reasons = await RootJailDetect.getDetectionReasons();
// Continuous monitoring
SecurityWatchdog.start({
intervalMs: 3000,
mode: ProtectionMode.TERMINATE,
});
// Stop monitoring
SecurityWatchdog.stop();
What's Next
- Certificate pinning integration
- Integrity check for the JS bundle itself
- Anti-tampering for the native binary
If you're building anything in fintech, banking, or enterprise on React Native and want a lightweight security layer without pulling in a large SDK — give it a try.
Feedback welcome, especially if you hit false positives on specific devices.
Built with Swift, Kotlin, and C++. No external dependencies.
Links:
npm Package
GitHub Repository
Full Documentation
Issue Tracker
Found this helpful? Drop a ❤️ on the article and ⭐ on GitHub!
Questions or suggestions? Drop them in the comments below!
Feel free to reach out to me if you have any questions or need assistance.
LinkedIn: https://www.linkedin.com/in/rushikesh-pandit-646834100/
GitHub: https://github.com/rushikeshpandit
Portfolio: https://www.rushikeshpandit.in
#ReactNative #TypeScript #MobileDevelopment #SoftwareEngineering #DevCommunity #root-detection #jailbreak-detection #mobile-security
#device-integrity
Top comments (0)