@psync/anti-jailbreak
v0.14.1
Published
A lightweight and reliable React Native Nitro Module to detect rooted Android devices and jailbroken iOS devices using C++, Kotlin, and Swift. Built for security-focused mobile applications.
Maintainers
Keywords
Readme
@psync/anti-jailbreak
A React Native Nitro Module (New Architecture only) for detecting rooted (Android) and jailbroken (iOS) devices, emulators/simulators, attached debuggers, and runtime instrumentation frameworks (Frida, Zygisk, LSPosed, Riru). Features a scored risk API, stable signal catalog, and optional periodic security watchdog.
Security Note: Client-side detection is a defense-in-depth heuristic, not a guarantee. Determined attackers can hook or bypass checks. Never use client booleans as sole authorization for sensitive actions—pair with backend Play Integrity / App Attest verification.
Store-origin note: The library exposes a best-effort
getInstallOrigin()provenance getter, but it does not fire detection signals on iOS and the Android finding is purely informational. Android reads the installer record (Google Play vs. explicitly non-Play vs. unknown for ADB/system installs). iOS uses StoreKit 2AppTransactionon iOS 16+, where StoreKit cryptographically verifies the transaction before it is surfaced as.verified(.unverifiedand errors never claim an origin), and falls back to the legacy App Store receipt on iOS 15 (app_storeforStoreKit/receipt,testflightforStoreKit/sandboxReceipt,unknownfor everything else). The iOS 15 fallback is existence-only and spoofable, and absence is legitimate in many benign states (Xcode/dev, simulator, sideloaded, enterprise, transiently-missing), so it is never treated as a finding — pair with DeviceCheck / App Attest for cryptographic install verification.
Screenshots
| Legacy boolean checks | Scored result, watchdog & diagnostics | | --- | --- | | | |
The demo app in example/ exercising both the legacy boolean API and the scored checkDetailed() API on an iOS simulator.
Installation
# npm
npm install @psync/anti-jailbreak react-native-nitro-modules
# bun (recommended for development)
bun add @psync/anti-jailbreak react-native-nitro-modules(Requires React Native 0.83+ New Architecture and react-native-nitro-modules >=0.35.10 <0.37.0 — see the compatibility matrix below)
Compatibility
@psync/anti-jailbreak is built on react-native-nitro-modules HybridObjects implemented in shared C++. The C++ must override NitroModules HybridObject virtuals with exact signatures, so compatibility is pinned to the NitroModules C++ ABI.
| react-native-nitro-modules | Status | Notes |
| --- | --- | --- |
| < 0.35.0 | ❌ Not supported | HybridObject virtuals differ; outside the peer range. |
| 0.35.0 – 0.35.x | ✅ Supported | HybridObject::getExternalMemorySize() is the override target; do not use the legacy getMemorySize() name. |
| 0.36.x | ✅ Supported | Same HybridObject::getExternalMemorySize() API; verified against 0.36.1. No code changes required. |
| 0.37.x and above | ⚠️ Unverified | Not yet validated. Re-run bun run specs and a clean native build (iOS pod build + Android Gradle clean) before adopting, since NitroModules may rename or remove HybridObject virtuals again. |
Why this matters: NitroModules once exposed
HybridObject::getMemorySize()and later renamed it togetExternalMemorySize(). Because iOS consumes NitroModules as a source-built CocoaPod (live headers) while Android consumes it via a prefab/prebuilt header snapshot, an API rename can fail the iOS build while a cached Android build still passes. Whenever you bump NitroModules, clear both caches and rebuild natively.
iOS & Expo
- iOS: Run
cd ios && pod install - Expo: Custom dev client or EAS Build required (cannot run in Expo Go). The package ships an Expo config plugin (
app.plugin.js) that adds narrowly scoped<queries>entries for known root-manager apps on Android; it is wired automatically, but can be referenced explicitly withplugins: ["@psync/anti-jailbreak"]inapp.jsonif package plugins are not auto-resolved.@expo/config-pluginsis an optional peer (listed inpeerDependenciesand marked optional viapeerDependenciesMeta, so npm surfaces it without requiring installation); Expo prebuild always provides it as a transitive dependency ofexpo.
Quick Start
import {
checkDetailed,
isDeviceCompromised,
startSecurityWatchdog,
} from '@psync/anti-jailbreak';
// 1. Primary scored API — full structured assessment
const result = await checkDetailed();
console.log(result.score, result.compromised, result.confidence);
// 2. Legacy boolean helper (derives from checkDetailed)
if (await isDeviceCompromised()) {
console.warn('Device compromised!');
}
// 3. Optional periodic background watchdog
startSecurityWatchdog({ intervalMs: 5000, protectionMode: 'LOG_ONLY' });Usage examples
App-startup security gate
Run a single structured pass at startup, derive every value you need from the one result, and avoid calling multiple wrappers (each wrapper would re-run the native pass):
import { useEffect } from 'react';
import { Alert } from 'react-native';
import { checkDetailed, getDetectionReasons } from '@psync/anti-jailbreak';
function useStartupSecurityGate() {
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await checkDetailed();
if (cancelled) return;
// One pass — derive every boolean locally instead of calling
// isDeviceCompromised()/isEmulator()/isDebuggerAttached() again.
const isEmu = result.signals.some(
(s) => s.id.startsWith('android.emulator') || s.id.startsWith('ios.simulator'),
);
if (result.compromised) {
const reasons = await getDetectionReasons();
Alert.alert(
'Security warning',
`Device score ${Math.round(result.score)}/100.\n${reasons.join('\n')}`,
);
}
console.log({
compromised: result.compromised,
score: result.score,
confidence: result.confidence,
emulator: isEmu,
debugger: result.debuggerDetected,
partial: result.partial,
});
} catch (error) {
// checkDetailed() propagates native errors — decide your own policy.
console.error('Security check failed:', error);
}
})();
return () => {
cancelled = true;
};
}, []);
}Working with the scored API
checkDetailed() (or its alias assessRisk()) returns a CompromiseAssessment. Iterate signals for analytics, gating, or telemetry. Always filter out unavailable signals — they mean "the check couldn't run", not "evidence of compromise":
import { checkDetailed } from '@psync/anti-jailbreak';
const result = await checkDetailed();
// Positive findings only — skip unavailable rows.
const positiveSignals = result.signals.filter(
(s) => s.unavailable !== true && s.detected,
);
for (const signal of positiveSignals) {
console.log({
id: signal.id, // stable, e.g. 'android.mount.magisk'
platform: signal.platform, // 'android' | 'ios'
category: signal.category, // 'mount' | 'injection' | ... (see enum below)
severity: signal.severity, // 'low' | 'medium' | 'high'
score: signal.score, // weight this signal contributed
reliability: signal.reliability, // 0..1 — backend policy hint
evidence: signal.evidence, // present only if includeEvidence is enabled
});
}
// Partial results: the timeoutMs budget ran out before every check could run.
// Treat as non-authoritative and re-check before deciding.
if (result.partial) {
console.warn('Detection pass was partial; some checks did not complete.');
}
// Confidence reflects how complete and convergent the evidence is.
// 'extreme' is reserved for passes with multiple high-severity signals
// from independent categories with a score near 100.
if (result.confidence === 'extreme' || result.score >= 80) {
// Strong local indicator — still cross-check server-side.
}Configuring thresholds and behavior
configure() updates the native HybridObject in place; subsequent checkDetailed() passes and watchdog ticks observe the new values. Pass undefined for any field you want to leave untouched:
import { configure } from '@psync/anti-jailbreak';
configure({
// `compromised` becomes true at or above this score (default 40).
// Lower → stricter (more devices flagged); higher → looser.
minScore: 50,
// Total wall-clock budget per pass in ms (default 600).
// Overrun checks return `unavailable` signals and `partial: true`.
timeoutMs: 600,
// Redacted per-signal evidence hints. Development/debug only — the native
// core forces this off in release (NDEBUG) builds regardless.
includeEvidence: __DEV__,
// Off by default. Fold debugger attachment into `compromised` in addition
// to surfacing it on `debuggerDetected`. A debugger alone is not an attack.
treatDebuggerAsCompromise: false,
});iOS URL-scheme probing
iOS URL-scheme checks use UIApplication.canOpenURL, which only sees schemes declared in the host app's LSApplicationQueriesSchemes — an undeclared scheme always returns false, silently, whether or not a handler is installed. The cap is shared with the host app's own queries: apps linked on iOS 15+ may declare at most 50 schemes; apps linked on iOS 27+ are limited to 25 (where canOpenURL is also deprecated, though still functional). Keep the list minimal and configurable.
import { configure } from '@psync/anti-jailbreak';
configure({
urlSchemes: {
// Defaults are the four most common jailbreak-store schemes.
schemes: ['cydia', 'sileo', 'zbra', 'filza'],
// Set to [] to disable URL-scheme probing entirely.
// schemes: [],
// Additionally emit one informational `ios.urlscheme.<scheme>` signal
// per responding scheme. The aggregate `ios.urlscheme.jailbreak_store`
// signal (and its score contribution) is emitted in both modes.
perSchemeSignals: true,
},
});For bare React Native, declare the schemes in ios/<App>/Info.plist:
<key>LSApplicationQueriesSchemes</key>
<array>
<string>cydia</string>
<string>sileo</string>
<string>zbra</string>
<string>filza</string>
</array>Undeclared schemes always return false — the probe fails closed (never a false positive), but a missing declaration silently disables that scheme's check.
For Expo, the config plugin's own urlSchemes prop (separate from configure()) merges schemes into Info.plist during prebuild and enforces the entry cap (50 by default; pass schemeCap: 25 if your app links on iOS 27+):
{
"expo": {
"plugins": [
["@psync/anti-jailbreak", { "urlSchemes": ["cydia", "sileo"], "schemeCap": 25 }]
]
}
}Pass urlSchemes: [] to skip adding any schemes, or omit the prop to use the defaults. The plugin never requests QUERY_ALL_PACKAGES and only adds narrowly scoped <queries> entries for known root-manager apps on Android.
Background watchdog
The watchdog consumes checkDetailed() on its own background thread using the configured threshold. It does not duplicate detection logic. Use LOG_ONLY for safe testing — never TERMINATE in automated tests:
import {
startSecurityWatchdog,
stopSecurityWatchdog,
type LegacySecurityWatchdogOptions,
} from '@psync/anti-jailbreak';
const options: LegacySecurityWatchdogOptions = {
intervalMs: 5000, // milliseconds between ticks (legacy alias: `interval`)
protectionMode: 'LOG_ONLY', // 'LOG_ONLY' | 'THROW_EXCEPTION' | 'TERMINATE'
};
startSecurityWatchdog(options);
// Later (e.g. on logout, or once your server has re-attested the device):
stopSecurityWatchdog();Notes on protection modes:
LOG_ONLY— Safe everywhere. Use for testing and when JS-side policy is the source of truth.THROW_EXCEPTION— Demoted to a logged warning on the background thread (it cannot throw into the JS runtime). Retained for API completeness; pollcheckDetailed()from JS to actually react.TERMINATE— Ends the process viastd::terminate(). Destructive; do not exercise in tests.
Error handling per wrapper
The wrappers preserve v1 error semantics for backwards compatibility. isDeviceCompromised() is the only one that rethrows — the others return safe fallbacks so a faulty probe can never crash your app:
import {
isDeviceCompromised,
isEmulator,
isDebuggerAttached,
getDetectionReasons,
checkDetailed,
} from '@psync/anti-jailbreak';
// checkDetailed() — propagates native errors directly.
try {
const result = await checkDetailed();
} catch (error) {
// Handle or surface to the user.
}
// isDeviceCompromised() — logs and RETHROWS. Always wrap in try/catch.
try {
if (await isDeviceCompromised()) {
/* … */
}
} catch (error) {
/* Native failure — decide policy (fail open or closed). */
}
// isEmulator() / isDebuggerAttached() / getDetectionReasons() — log and
// return safe fallbacks (false / false / []). They never throw.
const isEmu = await isEmulator(); // never throws
const reasons = await getDetectionReasons(); // never throwsRecommended pattern: pair with backend attestation
Client heuristics are bypassable. For sensitive decisions, send the score and signal ids to your backend as a hint, and combine with hardware-backed attestation that the server verifies:
import { checkDetailed } from '@psync/anti-jailbreak';
async function fetchSessionToken() {
const assessment = await checkDetailed();
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Hints only — never the sole authorization factor.
clientRiskScore: assessment.score,
clientConfidence: assessment.confidence,
clientPartial: assessment.partial,
clientSignalIds: assessment.signals
.filter((s) => s.unavailable !== true && s.detected)
.map((s) => s.id),
// Authoritative signal (acquire separately via Play Integrity / App
// Attest and verify server-side). This library does not issue tokens.
// integrityToken: await getPlayIntegrityToken(),
}),
});
return response.json();
}API Summary
checkDetailed(): Promise<CompromiseAssessment>— Runs all enabled checks within a timeout budget (default600ms). Overrun checks returnunavailable: truewithpartial: truerather than throwing.assessRisk(): Promise<CompromiseAssessment>— Alias forcheckDetailed(); prefer whichever name reads better in your codebase.configure(options: RootJailDetectOptions): void— ConfigureminScore(default40),timeoutMs,includeEvidence,treatDebuggerAsCompromise, and iOSurlSchemes.isDeviceCompromised(): Promise<boolean>— Returnstrueifscore >= minScore. Rethrows native errors.isEmulator(): Promise<boolean>— Returnstrueif running in an emulator/simulator. Returnsfalseon error.isDebuggerAttached(): Promise<boolean>— Returnstrueif a debugger is attached. Returnsfalseon error.getDetectionReasons(): Promise<string[]>— Returns human-readable reasons for fired signals. Returns[]on error.getInstallOrigin(): Promise<InstallOrigin>— Best-effort install provenance. Returns'google_play' | 'other' | 'unknown'on Android (PackageManager installer record) and'app_store' | 'testflight' | 'unknown'on iOS (StoreKit 2AppTransactionverification on iOS 16+, legacy App Store receipt on iOS 15). Returns'unknown'on error. Informational only — never affects the scoredCompromiseAssessmentand cannot independently setcompromised. Use DeviceCheck / App Attest for cryptographic install verification.setDetectionCallback(cb): void— Register a callback invoked after eachcheckDetailed()/assessRisk()pass with theCompromiseAssessmentand{ platform, timestampMs }metadata. Passundefinedto deregister. Callback exceptions are logged, never thrown.startSecurityWatchdog(options): void— Periodically runs checks in background.protectionModeis'LOG_ONLY' | 'THROW_EXCEPTION' | 'TERMINATE'. Note:THROW_EXCEPTIONis demoted to a logged warning on the background thread (it cannot throw into the JS runtime);TERMINATEends the process. To react in app code, pollcheckDetailed()/isDeviceCompromised()from JS.stopSecurityWatchdog(): void— Stops the security watchdog thread.
Result & option shapes
checkDetailed() returns a CompromiseAssessment (DeviceRiskResult is kept as a deprecated alias):
interface CompromiseAssessment {
platform: 'android' | 'ios';
compromised: boolean; // score >= minScore
score: number; // 0–100, clamped
confidence: 'low' | 'medium' | 'high' | 'extreme';
signals: DetectionSignal[]; // all fired signals, including `unavailable: true` ones
debuggerDetected: boolean; // informational; folds into `compromised` only if configured
elapsedMs: number; // total detection pass time
partial: boolean; // true when the timeoutMs budget ran out before all checks finished
}
interface DetectionSignal {
id: string; // platform-prefixed, e.g. `android.mount.magisk`
platform: 'android' | 'ios';
category: SignalCategory; // see enum below
severity: 'low' | 'medium' | 'high';
score: number; // weight contributed to the aggregated score
detected: boolean; // true for a positive finding; false when returning a clean row
reliability: number; // 0..1 stable estimate of how reliable this signal is
evidence?: string; // only when RootJailDetectOptions.includeEvidence = true
unavailable?: boolean; // true when the check could not complete; never evidence of compromise
}
// Source of truth: src/specs/SignalCategory.ts. Closed enum.
type SignalCategory =
| 'filesystem' // root-manager dirs, su binaries, jailbreak artifact paths
| 'sandbox' // TrollStore persistence, URL-scheme canOpenURL hits, sandbox write probes
| 'mount' // Magisk overlays, hidden bind-mounts in app namespace
| 'process' // cmdline tokens, local sockets, loopback SSH/ADB listeners
| 'injection' // Frida/Zygisk/Riru maps artifacts, loopback Frida port
| 'hook' // LSPosed/Xposed/MobileSubstrate/Substitute/libhooker/ellekit
| 'property' // ro.debuggable, service.adb.root, ro.secure, SELinux state
| 'package' // PackageManager enumeration of root-manager apps
| 'signature' // bootloader unlocked, test-keys, verified boot, emulator
| 'debugger'; // TracerPid, sysctl P_TRACED, *.check.* availability markersconfigure() accepts a RootJailDetectOptions partial:
interface RootJailDetectOptions {
minScore?: number; // default 40 — `compromised` becomes true at or above this
timeoutMs?: number; // default 600 — total wall-clock budget per pass
includeEvidence?: boolean; // default false — see "Evidence redaction" below
treatDebuggerAsCompromise?: boolean; // default false
enablePlayIntegrity?: boolean; // default false — server-attested; not yet wired
urlSchemes?: {
schemes?: string[]; // default ['cydia', 'sileo', 'zbra', 'filza']; [] disables
perSchemeSignals?: boolean; // default false — adds per-scheme detail signals (score unchanged)
};
}Error semantics
Wrapper behavior is preserved from v1 for backwards compatibility:
isDeviceCompromised()logs and rethrows native errors. Callers who call it directly must catch and decide policy.isEmulator(),isDebuggerAttached(), andgetDetectionReasons()log the error and return safe fallbacks (false,false,[]) — they never throw.checkDetailed()andconfigure()propagate native errors directly without swallowing them.startSecurityWatchdog()/stopSecurityWatchdog()keep the legacy synchronous signature by firing the async native methods without awaiting; Promise rejections are logged, not rethrown.
Tests in src/__tests__/index.test.tsx pin these semantics; treat any change as a breaking change.
Evidence redaction
DetectionSignal.evidence carries redacted, human-readable hints about what was observed (e.g. "known-jailbreak-artifact", "selinux=enforce:0"). It is off by default and is additionally forced off in release builds:
#if defined(NDEBUG)
const bool includeEvidence = false; // release builds: evidence is never attached
#else
const bool includeEvidence = options.includeEvidence; // debug builds: opt in via configure()
#endifLeave includeEvidence disabled (the default) in production. The redacted hints are still meaningful to an attacker monitoring logcat or console output; treat them as development-only.
Signal Catalog
| Severity | Signal ID | Weight | Description |
| --- | --- | ---: | --- |
| high | android.mount.magisk | 35 | Magisk / KernelSU / APatch mount overlay |
| high | android.maps.zygisk | 30 | Zygisk library mapped in process memory |
| high | android.maps.lsposed | 30 | LSPosed / Xposed library mapped in memory |
| high | android.maps.frida | 30 | Frida agent / artifact mapped in memory |
| high | android.maps.riru | 30 | Riru framework library mapped in memory |
| high | android.selinux.permissive | 25 | SELinux permissive mode on production device |
| high | android.cmdline.instrumentation | 30 | Instrumentation token in process command line |
| high | android.socket.instrumentation | 30 | Instrumentation token in local sockets |
| high | android.network.frida | 30 | Frida server responding on loopback 27042 |
| high | android.network.ssh | 30 | SSH server responding on loopback |
| low | android.network.adb | 10 | ADB daemon on loopback (emulator / rare TCP-mode adbd) |
| medium | android.root_manager.dir | 20 | Root-manager directory accessible |
| medium | android.bootloader.unlocked | 20 | Unlocked bootloader / verified boot orange |
| medium | android.emulator | 20 | Android emulator indicators |
| low | android.su.binary | 10 | su binary at conventional location |
| low | android.build.test_keys | 10 | ro.build.tags reports test-keys |
| low | android.build.debuggable | 5 | ro.debuggable set (dev build or Shamiko) |
| low | android.build.adb_root | 5 | service.adb.root set (dev build or Shamiko) |
| low | android.build.ro_secure_zero | 5 | ro.secure is 0 (dev build or Shamiko) |
| low | android.mount.overlay | 10 | Hidden mount overlay in app namespace |
| low | android.install.origin.other | 10 | Explicit non-Google-Play installer; hypothesis signal |
| high | android.sandbox.write | 30 | Sandbox write to /data/local/tmp succeeded (weak corroboration) |
| high | android.package_manager.root | 25 | Known root-management package installed (Magisk, SuperSU, KingRoot, etc.) |
| medium | android.package_manager.hma | 15 | Hiding or hooking-related package visible to PackageManager |
| low | android.package_manager.risky | 5 | Risky patching or piracy-related package (informational) |
| low | android.modules.magisk | 10 | Readable Magisk module tree with module metadata (candidate corroboration) |
| low | android.modules.hiding | 10 | Hiding-oriented module manifest found (candidate corroboration) |
| low | android.modules.spoofing | 10 | Integrity/property-spoofing module manifest found (candidate corroboration) |
| medium | android.addon_d.magisk | 20 | Magisk persistence script under /system/addon.d |
| low | android.install_recovery | 5 | Conventional install-recovery script present (weak, stock-compatible marker) |
| low | android.hosts.writable | 5 | System hosts file is writable by the app process |
| low | android.custom_rom | 5 | Custom-ROM property marker (provenance, not root proof) |
| low | android.lineage | 5 | LineageOS property marker (provenance, not root proof) |
| low | android.lsposed.cache | 10 | Accessible LSPosed cache/module marker (candidate corroboration) |
| low | android.maps.anon_injection | 10 | Cluster of ≥2 unnamed executable mappings (hypothesis, fixture-gated; named ART/JIT regions excluded) |
| low | android.props.inconsistent_debuggable | 5 | Debuggable/build-type or secure-property inconsistency (hypothesis) |
| low | android.props.inconsistent_verifiedboot | 5 | Verified-boot and vbmeta state inconsistency (hypothesis) |
| low | android.props.inconsistent_fingerprint | 5 | Fingerprint/build tags/type inconsistency (hypothesis) |
| low | android.magisk.disable_prop | 10 | Magisk-specific property visible (corroboration) |
| low | android.zygisk.variant.official | 5 | Candidate official Zygisk property marker |
| low | android.zygisk.variant.assistant | 5 | Candidate Zygisk Assistant property marker |
| low | android.zygisk.variant.next | 5 | Candidate Zygisk Next property marker |
| low | android.zygisk.variant.rezygisk | 5 | Candidate ReZygisk property marker |
| high | android.sandbox.write.system_dir | 30 | Write to an immutable system directory succeeded |
| low | android.cmdline.su_exec | 10 | su executable present in the process PATH |
| low | android.cmdline.magisk_exec | 10 | magisk executable present in the process PATH |
| low | android.env.path_magisk | 5 | Process PATH contains a candidate injected directory |
| low | android.mount.magisk_chain | 5 | Layered suspicious mount candidate (hypothesis) |
| low | android.mount.denylist_unmount | 5 | ≥ 2 distinct canonical system partitions mounted as tmpfs — structural residue of unmount-style root hiding (hypothesis; see limitations below) |
| medium | android.mount.overlayfs | 10 | overlay/overlayfs at an exact system-partition root, not stock-OEM-backed (adb remount, GSI/DSU, systemless-overlay root; see limitations below) |
| informational | android.debugger.tracerpid | 0 | TracerPid non-zero (diagnostic) |
| high | ios.dyld.hook | 30 | Suspicious injection framework loaded (Frida, MobileSubstrate, Substitute, libhooker, ellekit, rosalie, renamed gadgets) |
| high | ios.network.frida | 30 | Frida server responding on loopback 27042 |
| high | ios.network.ssh | 30 | SSH server responding on loopback (22 or 44) |
| medium | ios.jailbreak.artifact | 20 | Classic jailbreak file or directory accessible |
| medium | ios.jailbreak.rootless | 20 | Rootless bootstrap symlink present (/var/jb or /private/jb; a dangling link counts — bootstrap laid down, jailbreak off) |
| medium | ios.jailbreak.dopamine | 20 | Dopamine profile marker — probe parked pending verified observables (Dopamine is caught by the rootless signal) |
| medium | ios.jailbreak.palera1n | 20 | palera1n profile marker — probe parked pending verified observables (palera1n is caught by the rootless signal) |
| low | ios.sideload.trollstore | 5 | TrollStore indicator — parked at hypothesis weight with no active probe (no sandboxed-app observable exists; see Threat Model) |
| medium | ios.urlscheme.jailbreak_store | 15 | Jailbreak-store URL scheme responded to canOpenURL |
| high | ios.sandbox.write | 30 | A write outside the app sandbox succeeded — the process sandbox is absent or escaped (unsandboxing tweak, escaped entitlements, or active tampering). High precision, high FN: ordinary rootless jailbreaks keep apps sandboxed |
| medium | ios.simulator | 20 | iOS simulator environment |
| informational | ios.debugger.sysctl | 0 | sysctl reports P_TRACED (diagnostic) |
| informational | *.check.* | 0 | Check timed out / unavailable (not compromise) |
Signal ids are part of the public contract — they are never renamed or reused for a different meaning once published. Tuning a weight or severity is allowed; repurposing an id is a breaking change. The added Android module, anonymous-map, property-consistency, and Zygisk-variant checks are intentionally low-weight hypotheses until clean-device fixtures establish their false-positive profile.
The Android PackageManager lists are subject to Android package visibility and to hiding tools such as Hide My Applist. A package that is not returned is not proof that it is absent. Similarly, /data/adb/modules is commonly unreadable to ordinary app UIDs; that state is reported as unavailable rather than clean. The custom-ROM and LineageOS signals identify build provenance and are not, by themselves, proof of root.
Threat Model & Policy
- Client heuristics are non-authoritative: Always bind sensitive decisions to short-lived server sessions with backend attestation (Play Integrity / App Attest).
- Legitimate custom ROMs & devs: Unlocked bootloaders,
test-keys, and permissive SELinux can occur on legitimate developer devices. TuneminScoreappropriately. - iOS jailbreak coverage map (2026): rootless jailbreaks (Dopamine 2/3, palera1n rootless — iOS 15+ through 26.0.x) share the
/var/jbsymlink convention and are detected (including dangling symlinks). Rootful layouts (palera1n rootful, classic tools) write to rootfs paths and are detected via the classic artifact list. roothide-class environments are a documented false-negative ceiling: the bootstrap lives at a randomized/var/containers/Bundle/.../.jbroot-<id>path and is invisible to path checks — only loaded roothide tweak images are caught (dyld provenance rule). TrollStore is not reliably detectable from a sandboxed app: it installs apps into normal containers and hijacks the systemapple-magnifier://scheme specifically to defeat scheme probes; its signal is parked at hypothesis weight with no active probe. - Hybrid rootless + rootful devices report both artifact classes (40 combined weight) — two independent evidence classes, deliberately not collapsed.
- Simulator runs are structurally non-representative: the iOS simulator branch emits
ios.simulatorand skips all device-only checks (artifacts, dyld, sandbox, schemes); a clean simulator result says nothing about device behavior. - Rootless jailbreaks and TrollStore: iOS rootless jailbreaks (Dopamine, palera1n) deliberately avoid classic paths and use the
/var/jbsymlink convention. TrollStore is a sideloading tool, not a jailbreak, and its signal (ios.sideload.trollstore) is parked — see the coverage map above. - Renamed Frida gadgets: Memory-map and
_dyldscans include common rename patterns (libgadget,gadget.dylib, etc.), but a determined attacker can rename further. Treat these as defensive signals, not proof. - iOS URL schemes: The default probe list (
cydia,sileo,zbra,filza) respects theLSApplicationQueriesSchemescap shared with the host app (50 entries for apps linked on iOS 15+, 25 for iOS 27+). ConfigureRootJailDetectOptions.urlSchemes.schemesto change or disable the list. Undeclared schemes always returnfalse— never a false positive, but a missingLSApplicationQueriesSchemesdeclaration silently disables that scheme's check. - Confidence levels:
low/medium/highreflect how complete and convergent the pass was.extremeis reserved by the aggregator for combinations of multiple high-severity, independent-category signals that together push the score very high (≈ 80). - Modern Magisk DenyList caveat: a correctly functioning Magisk v24+ DenyList (
revert_unmount) removes every framework mount — including its own tmpfs — from the denied app's namespace, soandroid.mount.denylist_unmountis an expected no-fire there. The signal catches legacy MagiskHide, Magisk forks (e.g. Kitsune), third-party unmount modules, and partial cleanups (EBUSY). It is not DenyList-proof and ships at low hypothesis weight pending on-device measurement. - Stock OEM overlay caveat: stock Xiaomi HyperOS/MIUI devices ship
overlaymounts as part of their OEM resource layering (backed by/mnt/vendor/mi_extand/product/pangu, typically at subpaths such as/system/app). Theandroid.mount.overlayfsscanner only matches exact partition roots and suppresses fully OEM-backed overlays; the suppression list is deliberately small and grows only with real-device evidence. Other OEMs layering overlays at partition roots in the future would be reported — report such devices so the corpus can grow.
Roadmap
The scored baseline plus the additive Android static/runtime probes described above is shipped. Remaining work is optional / future:
- Native C++ unit tests in CI — the local
bun run native-testcommand covers pure parser fixtures; CI integration for the full native test matrix remains future work. Jest covers the TypeScript wrapper layer today. - OEM / benign allowlist — small, documented table to suppress specific low-severity
test-keys/ SELinux signals on legitimate preview/OEM builds. High-severity memory/mount signals are never allowlisted. - Mount-namespace reshape — the
android.mount.overlaynamespace-only check is effectively dead code today because/proc/1/mountinfois unreadable by untrusted apps on stock Android (see comment incpp/ProcParsers.cpp). A future reshape would usestatx(2)withSTATX_ATTR_MOUNT_ROOTand self-namespace path/content diffs. - Play Integrity / App Attest — optional client token acquisition behind
enablePlayIntegrity, paired with a server verifier (see "Recommended pattern: pair with backend attestation"). This is server-side attestation work, not local detection. meta.tcp.*advanced probes — richer loopback banner/fingerprinting beyond basic connect probes (P3).
The library never claims root/jailbreak detection is foolproof. Treat client heuristics as hints and bind sensitive decisions to short-lived server sessions with hardware-backed attestation.
License
MIT © Psync
