| name | root-jailbreak-detection |
| description | Detecting rooted Android and jailbroken iOS devices as a risk signal — not a silver bullet. Use when deciding which operations to allow on a compromised device. |
Root / Jailbreak Detection
Instructions
Root and jailbreak detection are signals, not security controls. A rooted device can hide itself from any detection you write. Design for that reality.
1. What a Rooted / Jailbroken Device Actually Means
- Platform sandbox is no longer a trust boundary for your app.
- Keystore / Keychain material is still protected if it is hardware-backed — root can't extract from StrongBox / Secure Enclave.
- TLS pinning can be bypassed (user CA installed into system store, Frida unpinning, LSPosed modules).
- Screen recorders, input injectors, and clipboard snoopers are trivial.
You may still want to run on these devices (your users include pentesters, tinkerers, and kids with unlocked bootloaders) — but with a higher risk score.
2. Android Signals (Combine Several)
fun rootSignals(ctx: Context): List<String> {
val signals = mutableListOf<String>()
val suPaths = listOf(
"/system/bin/su", "/system/xbin/su", "/sbin/su",
"/system/app/Superuser.apk", "/vendor/bin/su",
)
if (suPaths.any { File(it).exists() }) signals += "su_binary"
val rootPackages = listOf(
"com.topjohnwu.magisk", "eu.chainfire.supersu",
"com.koushikdutta.rommanager", "com.noshufou.android.su",
)
if (rootPackages.any { isInstalled(ctx, it) }) signals += "root_package"
if (Build.TAGS?.contains("test-keys") == true) signals += "test_keys"
val rwDirs = listOf("/system", "/system/bin", "/system/xbin")
if (rwDirs.any { File(it).canWrite() }) signals += "system_rw"
return signals
}
Each signal is independently bypassable. Report the set of signals, not a boolean.
3. iOS Signals
func jailbreakSignals() -> [String] {
var signals: [String] = []
let paths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash", "/usr/sbin/sshd", "/etc/apt",
"/private/var/lib/apt/",
]
if paths.contains(where: { FileManager.default.fileExists(atPath: $0) }) {
signals.append("jb_file")
}
let probe = "/private/jb_probe_\(UUID().uuidString)"
if (try? "x".write(toFile: probe, atomically: true, encoding: .utf8)) != nil {
signals.append("sandbox_rw")
try? FileManager.default.removeItem(atPath: probe)
}
if fork() >= 0 { signals.append("fork_allowed") }
if let url = URL(string: "cydia://package/com.example"),
UIApplication.shared.canOpenURL(url) { signals.append("cydia_scheme") }
return signals
}
The fork() and sandbox-write checks are the hardest to bypass without kernel patches — the file-path checks are trivial to hide.
4. Library Options
- Android:
RootBeer, dtx-free style checks. Read their source, fork, and rename before shipping — stock names are what jailbreak-hiders target.
- iOS:
IOSSecuritySuite, DTTJailbreakDetection. Same caveat.
- Don't trust a single "is rooted?" function. Combine N signals with weights.
5. Treat as Risk Score, Not Verdict
Bad pattern:
if (isRooted) { finish(); return }
Better pattern:
val signals = rootSignals(ctx)
riskScore += signals.size * 10
riskScore += if (attestationVerdict != STRONG) 20 else 0
The server may:
- Allow read-only usage.
- Block money movement / key operations / document export.
- Require step-up MFA.
- Silently flag for fraud review.
6. Business Policy First
Before writing a single check, get written agreement on policy:
- "We allow login on rooted devices, but not transactions over $X."
- "We allow all operations with warning, with extra logging."
- "We refuse to run at all on jailbroken devices."
Each of these is defensible; each has different UX and legal implications. Don't decide this in code review.
7. UX
- If you block, tell the user why in plain language ("This app requires an unmodified device for security reasons"). Don't hide behind a generic error.
- Provide a support channel for false positives.
- Never ship a hard block without a feature flag to roll back.
Checklist