iOS jailbreak detection bypass — a Frida + Objection field guide
Banking apps, e-wallets, and DRM clients all run a JB check before they trust their own code. Walking through the seven detection patterns I see in the wild and the runtime hooks that disarm each one.
Scope. Authorized engagements only. Lab device, lab apps, written permission. Same disclaimer as the Android post — running this against an app you don’t own is a crime. The detection patterns are real, the bypasses work; the engagement framing is yours to handle.
iOS jailbreak detection looks straightforward on the surface — “is /Applications/Cydia.app
present? then refuse to run” — and the code that ships in 2026 banking apps is
much more layered than that. A typical hardened iOS app stacks seven different
detection vectors. Defeating one and watching the app launch is misleading; the
others fire on first network call, on first sensitive screen, or asynchronously
five seconds in.
This post is the working list of those seven vectors, the indicators of each, and the hooks that disable them. The aim is not “run the app on a JB device” — it’s to instrument the app so you can pentest the post-launch behavior.
The lab
- Device: iPhone 12, iOS 17.4, palera1n-jailbroken (rootless),
BootstrapXNUfor Substrate-style tweaks. - Frida:
frida16.5+ on the host,frida-server16.5+ in/var/jb/usr/sbin/. - Objection: 1.11+ — used as the friendly front for memory dumps, class enumeration, and the built-in iOS bypass plugin.
- Reverse engineering: Hopper or Ghidra for binary analysis,
class-dumpfor Objective-C surfaces,nm/otool -lfor symbol enumeration.
# proof-of-life
ssh root@iphone.local 'launchctl list | grep frida'
frida-ps -U | head
# spawn the target
frida -U -f com.target.banking-app --no-pause
A note on rootless palera1n: the filesystem layout differs from classic
jailbreaks. /Applications/Cydia.app doesn’t exist; the binaries live under
/var/jb/. Some of your detection-string lists need updating because of this —
I’ll flag the spots where it matters.
The seven detection vectors
I’ve seen these seven patterns ship across financial, e-wallet, and DRM client apps over the last 18 months. The list is roughly in order of how likely each is to fire, given a given target:
- File-system checks —
NSFileManager fileExistsAtPath:against a hardcoded list of suspicious paths. stat/accesssyscall checks — same idea but via the C library, harder to catch with Objective-C swizzling.fork()/popen()/system()checks — sandboxed apps can’t fork; iffork()returns successfully, you’re not sandboxed → jailbroken.- URL scheme checks —
canOpenURL:againstcydia://,sileo://,zbra://. dyldimage inspection — walking_dyld_image_count()looking for substrate, libhooker, frida-gadget.- Symlink and write-test on system paths —
lstat()on/Libraryor attempting to write to/private/jailbreak_test. sysctldebugger / Frida port detection —P_TRACEDflag + scanning127.0.0.1:27042.
Each maps to a specific hook. The trick is that a single Frida script that disables all seven works ~80% of the time, and the remaining 20% is detection chains that intentionally cross-validate (e.g. file check + dyld walk in two different threads, where one being patched is itself a signal).
Vector 1 — file-system checks via NSFileManager
The Objective-C method that >90% of pinned apps still use:
- (BOOL)isJailbroken {
NSFileManager *fm = [NSFileManager defaultManager];
NSArray *paths = @[
@"/Applications/Cydia.app",
@"/Library/MobileSubstrate/MobileSubstrate.dylib",
@"/bin/bash", @"/usr/sbin/sshd", @"/etc/apt",
@"/var/lib/cydia", @"/private/var/lib/apt/"
];
for (NSString *p in paths) {
if ([fm fileExistsAtPath:p]) return YES;
}
return NO;
}
The targeted Frida hook on the underlying method:
// fileExistsAtPath: bypass — return NO for any JB-related path
var FM = ObjC.classes.NSFileManager;
var blocked = [
'/Applications/Cydia', '/Library/MobileSubstrate', '/bin/bash',
'/usr/sbin/sshd', '/etc/apt', '/var/lib/cydia', '/private/var/lib/apt',
'/usr/bin/ssh', '/var/jb', '/Library/PreferenceLoader'
];
Interceptor.attach(FM['- fileExistsAtPath:'].implementation, {
onEnter: function (args) {
var path = new ObjC.Object(args[2]).toString();
this.path = path;
this.shouldBlock = blocked.some(function (b) { return path.indexOf(b) !== -1; });
},
onLeave: function (retval) {
if (this.shouldBlock) {
console.log('[+] fileExistsAtPath:' + this.path + ' — forced NO');
retval.replace(0x0);
}
}
});
Two things to watch:
- The
args[2]selector argument: Objective-C method calls passselfand_cmdas the first two args, then the actual params. So the path isargs[2], notargs[0]. - Don’t blanket-replace every path. The app reads its own bundle resources
via
fileExistsAtPath:constantly, and forcing NO across the board breaks legitimate file access. The substring match against the JB list keeps collateral damage out.
Objection’s built-in plugin (ios jailbreak disable) does roughly this. It’s
fine for first-pass triage but goes broader than I want in production engagements;
a tight allowlist hook is cheaper.
Vector 2 — stat/access syscalls
The same check, written in C:
#include <sys/stat.h>
int is_jailbroken_v2(void) {
struct stat sb;
return (stat("/Applications/Cydia.app", &sb) == 0 ||
stat("/bin/bash", &sb) == 0 ||
access("/private/var/lib/apt/", F_OK) == 0);
}
Cooked Objective-C swizzles miss this entirely because there’s no Foundation
call — stat lives in libc. Native interception:
var stat = Module.findExportByName(null, 'stat');
Interceptor.attach(stat, {
onEnter: function (args) {
var path = Memory.readUtf8String(args[0]);
this.shouldBlock = blocked.some(function (b) { return path.indexOf(b) !== -1; });
this.path = path;
},
onLeave: function (retval) {
if (this.shouldBlock) {
console.log('[+] stat(' + this.path + ') — forced -1 / ENOENT');
retval.replace(-1);
// also clear errno: optional but cleaner
}
}
});
// Same treatment for access(2)
var access = Module.findExportByName(null, 'access');
Interceptor.attach(access, {
onEnter: function (args) {
var path = Memory.readUtf8String(args[0]);
this.shouldBlock = blocked.some(function (b) { return path.indexOf(b) !== -1; });
this.path = path;
},
onLeave: function (retval) {
if (this.shouldBlock) {
console.log('[+] access(' + this.path + ') — forced -1');
retval.replace(-1);
}
}
});
// Also the lstat variant — symlink-aware checks use this
var lstat = Module.findExportByName(null, 'lstat');
if (lstat) Interceptor.attach(lstat, /* same body */);
If the app calls fopen() instead of stat() to test a path, the same
treatment applies — hook fopen and return NULL for blocked paths.
Vector 3 — fork() / popen() / system()
Sandboxed iOS apps cannot fork. If fork() returns 0 in the child or a
positive pid in the parent, the app concludes the sandbox is broken:
if (fork() != -1) return YES; // shouldn't happen on a sealed device
The bypass:
['fork', 'vfork'].forEach(function (sym) {
var p = Module.findExportByName(null, sym);
if (!p) return;
Interceptor.replace(p, new NativeCallback(function () {
console.log('[+] ' + sym + '() — forced -1');
return -1;
}, 'int', []));
});
['popen', 'system'].forEach(function (sym) {
var p = Module.findExportByName(null, sym);
if (!p) return;
Interceptor.attach(p, {
onEnter: function () { console.log('[!] ' + sym + ' called — review'); },
onLeave: function (retval) { retval.replace(0); }
});
});
vfork is a frequent gotcha — if the app calls vfork() and you only hooked
fork(), you’ve fingerprinted the bypass tool. Hook both.
Vector 4 — URL scheme check via canOpenURL:
NSURL *cydiaURL = [NSURL URLWithString:@"cydia://"];
if ([[UIApplication sharedApplication] canOpenURL:cydiaURL]) return YES;
Targeted hook:
var UIApp = ObjC.classes.UIApplication;
var blockedSchemes = ['cydia', 'sileo', 'zbra', 'filza', 'undecimus', 'palera1n'];
Interceptor.attach(UIApp['- canOpenURL:'].implementation, {
onEnter: function (args) {
var url = new ObjC.Object(args[2]).toString();
var scheme = url.split(':')[0];
this.shouldBlock = blockedSchemes.indexOf(scheme) !== -1;
this.url = url;
},
onLeave: function (retval) {
if (this.shouldBlock) {
console.log('[+] canOpenURL:' + this.url + ' — forced NO');
retval.replace(0x0);
}
}
});
iOS 9+ requires apps to declare URL schemes they query in LSApplicationQueriesSchemes.
Hardened apps sometimes dynamically construct the URL string to dodge static
analysis of the Info.plist — your hook catches this regardless because it reads
the runtime URL.
Vector 5 — dyld image walk
The thinking: if MobileSubstrate.dylib, libhooker.dylib, FridaGadget, or
SubstrateLoader.dylib are loaded into the process, the device is jailbroken.
#include <mach-o/dyld.h>
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
const char *name = _dyld_get_image_name(i);
if (strstr(name, "MobileSubstrate") || strstr(name, "FridaGadget"))
return YES;
}
There are two clean ways to defeat this:
A. Rewrite the returned strings so JB indicators look like benign system libs:
var dyld_get_image_name = Module.findExportByName(null, '_dyld_get_image_name');
var fakeName = Memory.allocUtf8String('/usr/lib/libSystem.B.dylib');
Interceptor.attach(dyld_get_image_name, {
onLeave: function (retval) {
var s = Memory.readUtf8String(retval);
if (/Substrate|FridaGadget|libhooker|SubstrateLoader/i.test(s)) {
console.log('[+] _dyld_get_image_name → masking ' + s);
retval.replace(fakeName);
}
}
});
B. Decrement the image count and shift names down so the hostile library literally doesn’t exist from the app’s perspective. More invasive, more reliable:
var orig_count = Module.findExportByName(null, '_dyld_image_count');
var orig_name = Module.findExportByName(null, '_dyld_get_image_name');
// Cache the indices we want to skip
var skipIdx = [];
var realCount = new NativeFunction(orig_count, 'uint32', [])();
for (var i = 0; i < realCount; i++) {
var n = new NativeFunction(orig_name, 'pointer', ['uint32'])(i);
var s = Memory.readUtf8String(n);
if (/Substrate|FridaGadget|libhooker/i.test(s)) skipIdx.push(i);
}
Interceptor.replace(orig_count, new NativeCallback(function () {
return realCount - skipIdx.length;
}, 'uint32', []));
Interceptor.replace(orig_name, new NativeCallback(function (idx) {
var realIdx = idx;
for (var k = 0; k < skipIdx.length; k++) {
if (realIdx >= skipIdx[k]) realIdx++;
}
return new NativeFunction(orig_name, 'pointer', ['uint32'])(realIdx);
}, 'pointer', ['uint32']));
Approach B is the more robust one. Approach A is easier to maintain and works for ~95% of cases.
Vector 6 — symlink and write-test
A clever app does this:
// If we can write here, the device is jailbroken.
NSError *err;
[@"test" writeToFile:@"/private/jailbreak.txt"
atomically:YES encoding:NSUTF8StringEncoding error:&err];
The intercept is the same NSFileManager/NSString write methods, plus the
underlying open() syscall:
var open = Module.findExportByName(null, 'open');
Interceptor.attach(open, {
onEnter: function (args) {
var path = Memory.readUtf8String(args[0]);
var flags = args[1].toInt32();
// 0x0200 = O_CREAT — opening for create on a system path is the JB test
if ((flags & 0x0200) && /^\/(private|var|System|Library|usr)\//.test(path)) {
this.shouldBlock = true;
this.path = path;
}
},
onLeave: function (retval) {
if (this.shouldBlock) {
console.log('[+] open(' + this.path + ', O_CREAT) — forced -1');
retval.replace(-1);
}
}
});
Vector 7 — sysctl debugger detection + Frida port scan
The classic anti-debug check:
struct kinfo_proc info; size_t size = sizeof(info);
int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() };
sysctl(mib, 4, &info, &size, NULL, 0);
return (info.kp_proc.p_flag & P_TRACED) != 0;
The hook clears the P_TRACED flag in the result before the app reads it:
var sysctl = Module.findExportByName(null, 'sysctl');
Interceptor.attach(sysctl, {
onEnter: function (args) {
// Capture the kinfo_proc out-pointer and size for use in onLeave
this.outPtr = args[2];
this.sizePtr = args[3];
// Detect KERN_PROC_PID call (mib[1]==1, mib[2]==1)
var mib = args[0];
this.isKernProcPid =
mib.add(0).readU32() === 1 /*CTL_KERN*/ &&
mib.add(4).readU32() === 14 /*KERN_PROC*/ &&
mib.add(8).readU32() === 1 /*KERN_PROC_PID*/;
},
onLeave: function (retval) {
if (!this.isKernProcPid) return;
// p_flag is at offset 32 within kinfo_proc.kp_proc on arm64.
// Clear bit 0x800 (P_TRACED).
var flagsPtr = this.outPtr.add(32);
var f = flagsPtr.readU32();
flagsPtr.writeU32(f & ~0x800);
console.log('[+] sysctl(KERN_PROC_PID) — cleared P_TRACED');
}
});
The Frida-port scan defends against your tool by connect()-ing to
127.0.0.1:27042 and refusing to run if it succeeds. Bypass by either:
- Starting
frida-serveron a non-default port (--listen 0.0.0.0:31337) and passing-H 127.0.0.1:31337to the host CLI. - Hooking
connect()on the loopback + 27042 tuple and forcing failure:
var connect = Module.findExportByName(null, 'connect');
Interceptor.attach(connect, {
onEnter: function (args) {
var sa = args[1];
var family = sa.readU16();
if (family !== 2 /*AF_INET*/) return;
var port = ((sa.add(2).readU8() << 8) | sa.add(3).readU8());
var ip = sa.add(4).readU32();
if (port === 27042) {
this.shouldFail = true;
}
},
onLeave: function (retval) {
if (this.shouldFail) retval.replace(-1);
}
});
The consolidated script
I keep this one in the engagement repo as ios-jb-bypass.js. It composes all
seven vectors with a single allowlist:
var blocked = [
'/Applications/Cydia', '/Applications/Sileo', '/Applications/Zebra',
'/Library/MobileSubstrate', '/Library/PreferenceLoader',
'/bin/bash', '/usr/sbin/sshd', '/etc/apt',
'/var/lib/cydia', '/private/var/lib/apt',
'/usr/lib/substrate', '/usr/lib/libhooker',
'/var/jb' /* palera1n-rootless */
];
var blockedSchemes = ['cydia', 'sileo', 'zbra', 'filza', 'undecimus'];
var blockedDylibs = /Substrate|FridaGadget|libhooker|SubstrateLoader/i;
// 1, 2, 6 — file-system family
['stat', 'lstat', 'fstat', 'access', 'fopen'].forEach(function (sym) {
var p = Module.findExportByName(null, sym); if (!p) return;
Interceptor.attach(p, {
onEnter: function (args) {
this.path = Memory.readUtf8String(args[0]);
this.block = blocked.some(function (b) { return this.path.indexOf(b) !== -1; }, this);
},
onLeave: function (retval) {
if (this.block) retval.replace(sym === 'fopen' ? 0x0 : -1);
}
});
});
// 3 — fork family
['fork', 'vfork'].forEach(function (sym) {
var p = Module.findExportByName(null, sym); if (!p) return;
Interceptor.replace(p, new NativeCallback(function () { return -1; }, 'int', []));
});
// 4 — URL schemes (ObjC)
if (ObjC.available) {
var UIApp = ObjC.classes.UIApplication;
Interceptor.attach(UIApp['- canOpenURL:'].implementation, {
onEnter: function (args) {
var url = new ObjC.Object(args[2]).toString();
this.block = blockedSchemes.indexOf(url.split(':')[0]) !== -1;
},
onLeave: function (retval) { if (this.block) retval.replace(0x0); }
});
var FM = ObjC.classes.NSFileManager;
Interceptor.attach(FM['- fileExistsAtPath:'].implementation, {
onEnter: function (args) {
var p = new ObjC.Object(args[2]).toString();
this.block = blocked.some(function (b) { return p.indexOf(b) !== -1; });
},
onLeave: function (retval) { if (this.block) retval.replace(0x0); }
});
}
// 5 — dyld masquerade
var dyld_name = Module.findExportByName(null, '_dyld_get_image_name');
var fake = Memory.allocUtf8String('/usr/lib/libSystem.B.dylib');
Interceptor.attach(dyld_name, {
onLeave: function (retval) {
var s = Memory.readUtf8String(retval);
if (s && blockedDylibs.test(s)) retval.replace(fake);
}
});
// 7 — sysctl + connect (omitted here, see body of post)
When the bypass blows up
The cross-validation patterns I’ve seen burn the layered bypass:
-
Two-thread cross-check. Thread A calls
stat("/bin/bash"). Thread B waits on a semaphore expecting Thread A’s result to be -1 (via your hook). Thread B then callsstat("/usr/lib/dyld")— the real dyld, which exists. If Thread B’s call also returns -1, it concludes that someone is hookingstat()and refuses to run. Fix: substring-match more carefully — only block JB-related paths, never break legitimate calls. -
Bytecode integrity check. App computes a hash of its own
__TEXTsegment at startup and compares to a baked-in value. Frida’sInterceptor.attachdoesn’t modify code pages — it patches via stubs — so this often passes, butInterceptor.replacedoes, and apps that hash their own __text catch it. Fix: preferattachoverreplacewhenever possible. Use replace only forfork/vforkwhere attaching is awkward. -
Asynchronous re-check. App passes the JB check at startup, then calls the same checker again 30 seconds later, in a different thread, with a different code path. Single-fire hooks don’t survive. Fix: use Frida
Interceptor.attachcallbacks (not replace), so the hook stays installed for the process lifetime. -
Server-side jailbreak attestation. This is the killer. App sends a token to its backend that’s derived from device state (
DeviceCheck, custom attestation). If the backend says “this device is rooted/jailbroken,” the app refuses to talk regardless of what your local hooks say. No client-side bypass for this; you need a clean device or DeviceCheck spoofing on the server side, which is way out of scope for a typical engagement.
The defender perspective
If you’re hardening an iOS app, the realistic guidance:
- Stack the seven vectors. None of them individually is hard to bypass. Composing all seven plus cross-validation buys you operator effort.
- Move what you can to native and obfuscate. The
NSFileManagerObjective-C path is the cheapest to hook. Re-implement in C withstat()and run it through an obfuscator that flattens control flow. - Use server-side attestation as the hard gate. Apple’s DeviceCheck +
App Attest (
DCAppAttestService) is the only check an attacker can’t defeat with local hooks alone. Make all sensitive backends require a fresh attestation token. - Don’t roll your own pinner-style detection chain in random helper classes.
I’ve seen apps where a simple class rename of
JailbreakDetectortoJBDwas enough to make grep-based hooks miss it. That works against the first attempt but loses tofrida-traceimmediately.
The point. The local checks are a speed-bump, not a wall. The wall is server-side attestation. Plan accordingly — both as the operator and as the defender.
Tooling
frida16.5+ withfrida-toolson the hostobjectionfor the friendly REPL and built-in helpersHopper DisassemblerorGhidrafor static analysis of the binaryclass-dump-z/class-dump-swiftfor ObjC + Swift class enumerationfishhookwhen you need to hook lazily-bound symbols at the dyld stub layer (rare but irreplaceable)MachOViewfor inspecting LC_LOAD_DYLIB and segment hashes
Further reading
- The XNU source on
kinfo_procandsysctl—bsd/sys/proc.h. The exact byte offset ofp_flagmatters for the sysctl hook. - Apple’s App Attest documentation — required reading if you’re going up against modern attestation.
- The
palera1nsource — clean implementation of jailbreak techniques you can read and learn from.rootlessmode is especially educational because it shows how detection lists need updating for non-/Applicationslayouts. - frida.re documentation on
Interceptor.attachvsInterceptor.replace. The distinction matters under integrity checks.