Jailbreak Detection in iOS and Bypassing It with Frida
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Jailbreak Detection in iOS and Bypassing It with Frida
Intro
I’ve been learning iOS reverse engineering, and to also get comfortable with iOS development I built a jailbreak-detection crackme in Objective-C, then used Frida to bypass my own checks (just for the exercise). The code is rough — bear with me.

Setup
- A crackme app
- iOS 12.5.5
- Frida 14.0.0
Clone the project and install it on the device with Xcode.
stat: checking sensitive paths
stat checks for jailbreak-only paths like /Applications/Cydia.app and /usr/sbin/sshd. It returns 0 on success and -1 on failure. Hook stat to bypass it:
function hook_stat(is_pass){
var stat = Module.findExportByName('libSystem.B.dylib', 'stat');
Interceptor.attach(stat, {
onEnter: function(args) {
// Called when the method is invoked
// args[0] is stat's first argument, usually the file path
// args[1] is stat's second argument
console.log('stat is hooked: ');
},
onLeave: function(retval){
if (is_pass){
retval.replace(-1);
console.log(`stat retval: ${Number(retval.toString())} -> -1`);
}
}
});
}

_dyld_get_image_name: checking loaded dylibs
Jailbreaking loads special dylibs. An ipa can call _dyld_get_image_name to list every loaded image, then match against jailbreak dylibs. Analyze to find which dylib the app checks, hook _dyld_get_image_name, and return a legitimate dylib instead:
function hook_dyld_get_image_name(is_pass){
let cheek_paths = [
"/Library/MobileSubstrate/MobileSubstrate.dylib",
]
let NSString = ObjC.classes.NSString;
let true_path = NSString.stringWithString_( "/System/Library/Frameworks/Intents.framework/Intents");
let _dyld_get_image_name = Module.findExportByName(null, "_dyld_get_image_name");
Interceptor.attach(_dyld_get_image_name, {
onEnter: function(args){
console.log("_dyld_get_image_name is hooked.")
this.idx = eval(args[0]).toString(10);
},
onLeave: function(retval){
let rtnStr = retval.readCString();
if(is_pass){
for (let i=0;i<cheek_paths.length;i++){
if (cheek_paths[i] === rtnStr.toString()){
retval.replace(true_path);
console.log(`replace: (${this.idx}) ${rtnStr} => ${true_path}`)
}
}
}
}
})
}

canOpenURL: checking for jailbreak apps
Jailbreaking installs apps like Cydia. An ipa can use -[UIApplication canOpenURL:] to test whether it can launch them. Hook canOpenURL to change the return value — but note it returns a BOOL (1/0). Using retval.replace() inside Interceptor.attach for this kept crashing the app (I don’t know why — if anyone can explain, I’d appreciate it). So I used Interceptor.replace() + NativeCallback to force a return of 0 (NO). Not the cleanest solution, but it works:
function hook_canopenurl(is_pass){
let api = new ApiResolver("objc");
api.enumerateMatches("-[UIApplication canOpenURL:]").forEach((matche) => {
console.log("canOpenURL is hooked.");
if (is_pass){
Interceptor.replace(matche.address, new NativeCallback((url_obj) => {return 0;}, "int", ["pointer"]))
}
})
}

fileExistsAtPath: checking for jailbreak files and directories
Jailbreaking creates special files and directories. Apps check them with fileExistsAtPath — hook it:
// -[NSFileManager fileExistsAtPath:isDirectory:]
function hook_fileExistsAtPath(is_pass){
let api = new ApiResolver("objc");
let matches = api.enumerateMatches("-[NSFileManager fileExistsAtPath:isDirectory:]")
matches.forEach((matche) => {
console.log("fileExistsAtPath is hooked.");
if(is_pass){
Interceptor.replace(matche.address, new NativeCallback((path, is_dir) => {
console.log(ObjC.Object(path).toString(), is_dir)
return 0;
}, "int", ["pointer", "bool"]))
}
})
}

writeToFile: checking write access to private paths
A jailbroken device runs as root and can create files under /private/. If the write succeeds, it’s jailbroken. Bypass it by injecting an NSError into the error pointer the ipa checks:
function hook_writeToFile(is_pass){
let api = new ApiResolver("objc");
api.enumerateMatches("-[NSString writeToFile:atomically:encoding:error:]").forEach((matche) => {
Interceptor.attach(matche.address, {
onEnter: function(args){
this.error = args[5];
this.path = ObjC.Object(args[2]).toString();
console.log("writeToFile is hooked");
},
onLeave: function(retval){
if(is_pass){
let err = ObjC.classes.NSError.alloc();
Memory.writePointer(this.error, err);
}
}
})
})
}

lstat: checking paths and symlinks
Jailbreaking sometimes moves files that must still exist at their original path, so a symlink is created. An ipa can detect jailbreak paths by checking whether they’re symlinks. Here I only bypass the path check (the symlink check is still on my list).
The ObjC detection:
// The ObjC detection function
+ (Boolean)isLstatAtLnk{
// Check whether the path exists and whether it's a symlink
Boolean result = FALSE;
NSArray* jbPaths = @[
@"/Applications",
@"/var/stash/Library/Ringtones",
@"/var/stash/Library/Wallpaper",
@"/var/stash/usr/include",
@"/var/stash/usr/libexec",
@"/var/stash/usr/share",
@"/var/stash/usr/arm-apple-darwin9",
];
struct stat stat_info;
for(NSString* jbPath in jbPaths){
char jbPathChar[jbPath.length];
memcpy(jbPathChar, [jbPath cStringUsingEncoding:NSUTF8StringEncoding], jbPath.length);
if (lstat(jbPathChar, &stat_info)){
NSLog(@"stat_info.st_mode: %hu, S_IFLNK: %d, %d", stat_info.st_mode, S_IFLNK, stat_info.st_mode & S_IFLNK);
if(stat_info.st_mode & S_IFLNK){
result = TRUE;
NSLog(@"Is a symlink >> %@", jbPath);
}
}else{
NSLog(@"Path doesn't exist >> %@", jbPath);
result = TRUE;
}
}
return result;
}
The lstat bypass:
// Bypass lstat
function hook_lstat(is_pass){
var stat = Module.findExportByName('libSystem.B.dylib', 'lstat');
Interceptor.attach(stat, {
onEnter: function(args) {
console.log('lstat is hooked: ');
},
onLeave: function(retval){
if (is_pass){
retval.replace(1);
console.log(`lstat retval: ${Number(retval.toString())} -> 1`);
}
}
});
}
fork
Non-jailbroken devices can’t fork a child process. Hook fork:
function hook_fork(is_pass){
let fork = Module.findExportByName(null, "fork");
if (fork){
console.log("fork is hooked.");
Interceptor.attach(fork, {
onLeave: function(retval){
console.log(`fork -> pid:${retval}`);
if(is_pass){
retval.replace(-1)
}
}
})
}
}

NSClassFromString: checking for jailbreak classes
Some apps check for injected classes like HBPreferences (a common jailbreak tweak class) via NSClassFromString. Find the class names the app checks, then hook NSClassFromString:
function hook_NSClassFromString(is_pass){
let clses = ["HBPreferences"];
var foundationModule = Process.getModuleByName('Foundation');
var nsClassFromStringPtr = Module.findExportByName(foundationModule.name, 'NSClassFromString');
if (nsClassFromStringPtr){
Interceptor.attach(nsClassFromStringPtr, {
onEnter: function(args){
this.cls = ObjC.Object(args[0])
console.log("NSClassFromString is hooked");
},
onLeave: function(retval){
if (is_pass){
clses.forEach((ck_cls) => {
if (this.cls.toString().indexOf(ck_cls) !== -1){
console.log(`nsClassFromStringPtr -> ${this.cls} - ${ck_cls}`)
retval.replace(ptr(0x00))
}
})
}
}
})
}
}

getenv: checking environment variables
Apps check DYLD_INSERT_LIBRARIES via getenv to detect injection. Hook getenv:
function hook_getenv(is_pass){
let getenv = Module.findExportByName(null, "getenv");
Interceptor.attach(getenv, {
onEnter: function(args){
console.log("getenv is hook");
this.env = ObjC.Object(args[0]).toString();
},
onLeave: function(retval){
if (is_pass && this.env == "DYLD_INSERT_LIBRARIES"){
console.log(`env: ${this.env} - ${retval.readCString()}`)
retval.replace(ptr(0x0))
}
}
})
}

Full script
function hook_stat(is_pass){
var stat = Module.findExportByName('libSystem.B.dylib', 'stat');
Interceptor.attach(stat, {
onEnter: function(args) {
// Called when the method is invoked
// args[0] is stat's first argument, usually the file path
// args[1] is stat's second argument
console.log('stat is hooked: ');
},
onLeave: function(retval){
if (is_pass){
retval.replace(-1);
console.log(`stat retval: ${Number(retval.toString())} -> -1`);
}
}
});
}
function hook_dyld_get_image_name(is_pass){
let cheek_paths = [
"/Library/MobileSubstrate/MobileSubstrate.dylib",
]
let NSString = ObjC.classes.NSString;
let true_path = NSString.stringWithString_( "/System/Library/Frameworks/Intents.framework/Intents");
let _dyld_get_image_name = Module.findExportByName(null, "_dyld_get_image_name");
Interceptor.attach(_dyld_get_image_name, {
onEnter: function(args){
console.log("_dyld_get_image_name is hooked.")
this.idx = eval(args[0]).toString(10);
},
onLeave: function(retval){
let rtnStr = retval.readCString();
if(is_pass){
for (let i=0;i<cheek_paths.length;i++){
if (cheek_paths[i] === rtnStr.toString()){
retval.replace(true_path);
console.log(`replace: (${this.idx}) ${rtnStr} => ${true_path}`)
}
}
}
}
})
}
function hook_canopenurl(is_pass){
let api = new ApiResolver("objc");
api.enumerateMatches("-[UIApplication canOpenURL:]").forEach((matche) => {
console.log("canOpenURL is hooked.");
if (is_pass){
Interceptor.replace(matche.address, new NativeCallback((url_obj) => {return 0;}, "int", ["pointer"]))
}
})
}
// -[NSFileManager fileExistsAtPath:isDirectory:]
function hook_fileExistsAtPath(is_pass){
let api = new ApiResolver("objc");
let matches = api.enumerateMatches("-[NSFileManager fileExistsAtPath:isDirectory:]")
matches.forEach((matche) => {
console.log("fileExistsAtPath is hooked.");
if(is_pass){
Interceptor.replace(matche.address, new NativeCallback((path, is_dir) => {
console.log(ObjC.Object(path).toString(), is_dir)
return 0;
}, "int", ["pointer", "bool"]))
}
})
}
function hook_writeToFile(is_pass){
let api = new ApiResolver("objc");
api.enumerateMatches("-[NSString writeToFile:atomically:encoding:error:]").forEach((matche) => {
Interceptor.attach(matche.address, {
onEnter: function(args){
this.error = args[5];
this.path = ObjC.Object(args[2]).toString();
console.log("writeToFile is hooked");
},
onLeave: function(retval){
if(is_pass){
let err = ObjC.classes.NSError.alloc();
Memory.writePointer(this.error, err);
}
}
})
})
}
function hook_lstat(is_pass){
var stat = Module.findExportByName('libSystem.B.dylib', 'lstat');
Interceptor.attach(stat, {
onEnter: function(args) {
console.log('lstat is hooked: ');
},
onLeave: function(retval){
if (is_pass){
retval.replace(1);
console.log(`lstat retval: ${Number(retval.toString())} -> 1`);
}
}
});
}
function hook_fork(is_pass){
let fork = Module.findExportByName(null, "fork");
if (fork){
console.log("fork is hooked.");
Interceptor.attach(fork, {
onLeave: function(retval){
console.log(`fork -> pid:${retval}`);
if(is_pass){
retval.replace(-1)
}
}
})
}
}
function hook_NSClassFromString(is_pass){
let clses = ["HBPreferences"];
var foundationModule = Process.getModuleByName('Foundation');
var nsClassFromStringPtr = Module.findExportByName(foundationModule.name, 'NSClassFromString');
if (nsClassFromStringPtr){
Interceptor.attach(nsClassFromStringPtr, {
onEnter: function(args){
this.cls = ObjC.Object(args[0])
console.log("NSClassFromString is hooked");
},
onLeave: function(retval){
if (is_pass){
clses.forEach((ck_cls) => {
if (this.cls.toString().indexOf(ck_cls) !== -1){
console.log(`nsClassFromStringPtr -> ${this.cls} - ${ck_cls}`)
retval.replace(ptr(0x00))
}
})
}
}
})
}
}
function hook_getenv(is_pass){
let getenv = Module.findExportByName(null, "getenv");
Interceptor.attach(getenv, {
onEnter: function(args){
console.log("getenv is hook");
this.env = ObjC.Object(args[0]).toString();
},
onLeave: function(retval){
if (is_pass && this.env == "DYLD_INSERT_LIBRARIES"){
console.log(`env: ${this.env} - ${retval.readCString()}`)
retval.replace(ptr(0x0))
}
}
})
}
setImmediate(() => {
hook_stat(true);
hook_dyld_get_image_name(true)
hook_canopenurl(true);
hook_fileExistsAtPath(true);
hook_writeToFile(true);
hook_lstat(true);
hook_fork(true);
hook_NSClassFromString(true);
hook_getenv(true)
})
Wrap-up
The detection code lives in the project’s JailBreakCheck class. Bypassing each check individually is easy — just hook it. The real work in a real app is the analysis: finding where these checks are. The Frida script here is rough and needs polish before real use — and it’s worth checking out Frida’s script repository at https://codeshare.frida.re/
Later I’d like to cover other detections — Frida detection, obfuscation, hardening.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch