攻防两端的静默博弈:iOS 越狱检测八重防线剖析与 Frida 动态插桩绕过实战
在移动安全评估与应用逆向对抗的博弈中,iOS 平台的越狱检测与反越狱插桩始终是双方交锋的第一道生死线。对于金融类、风控严密或防外挂商业应用而言,一旦宿主设备处于越狱环境,便意味着系统的沙箱隔离机制、代码签名校验以及内存只读保护彻底失守,攻击者可以随心所欲地挂钩函数、提取明文密钥或篡改业务逻辑。为了筑牢防线,应用开发者往往会在启动与关键交易路径中织就一张密不透风的检测网络。
然而,防御从来不是孤立的静态规则。很多开发者在编写越狱检测代码时,往往习惯直接照搬陈旧的网络片段,对底层的 POSIX 系统调用、Mach-O 镜像加载机制以及 Objective-C 运行时调用约定的理解不够深入,导致防御代码不仅容易被一击即溃,甚至在遭遇动态插桩时暴露出未曾预料的稳定性缺陷。
近期我在复盘吾爱破解与看雪安全社区的优秀原创案例时,深入研读了安全研究员 andyhah 开源的经典项目 ios_crackme。作者在项目中通过纯 Objective-C 和 POSIX C 构建了涵盖 8 个维度的越狱防御探针,并随后使用动态插桩神器 Frida 对自己编写的防线逐一实施“靶向爆破”。更耐人寻味的是,作者在 Hook 部分系统方法(如 -[UIApplication canOpenURL:])时遭遇了应用崩溃的技术悬念。
本文我将结合该开源实验台,从正向防御设计到逆向动态 Hook,全景拆解这八重越狱防线的底层实现机理,还原 Frida 插桩的真实对抗代码,并深入 ARM64 寄存器调用约定,彻底揭开导致崩溃的底层真相。
1. 越狱防御的拓扑全景与攻防矩阵
在深入每一行代码之前,必须先厘清 iOS 越狱检测在系统层级上的分布。传统的越狱检测绝非简单的“查一个文件是否存在”,而是自上而下贯穿了 Cocoa Touch 应用层、Foundation 框架、POSIX 系统调用接口以及 Mach 内核层。
flowchart TD
subgraph AppLayer["应用与框架层 (High-Level Runtime)"]
D3["3. URL Scheme 协议跳转<br/>-[UIApplication canOpenURL:]"]
D4["4. 高层文件系统检索<br/>-[NSFileManager fileExistsAtPath:]"]
D5["5. 沙箱逃逸写权限探测<br/>writeToFile:atomically:...error:"]
D8["8. 运行时特征类探测<br/>NSClassFromString('HBPreferences')"]
end
subgraph PosixLayer["底层系统调用与动态链接器 (POSIX / dyld)"]
D1["1. POSIX 文件状态探测<br/>stat() 敏感路径检查"]
D2["2. 镜像动态加载检测<br/>_dyld_get_image_name() 遍历"]
D6["6. 符号链接与 Stash 探测<br/>lstat() 与 S_IFLNK 掩码校验"]
D7["7. 进程派生能力限制<br/>fork() 系统调用"]
D9["9. 动态库注入环境变量<br/>getenv('DYLD_INSERT_LIBRARIES')"]
end
subgraph Adversary["Frida 动态插桩与攻防对抗 (Adversary Hook)"]
F1["Interceptor.attach 拦截替换"]
F2["Interceptor.replace 汇编短路"]
F3["Memory.writePointer 虚构异常"]
end
AppLayer --> PosixLayer
Adversary -.->|动态内存劫持| AppLayer
Adversary -.->|符号重定向| PosixLayer
在 ios_crackme 的架构设计中,作者将所有检测项收敛在 JailBreakCheek 类中。这八种探针共同构成了一套典型的多维防御矩阵:
| 探针编号 | 检测维度 | 核心 API / 函数 | 判定原理 | 常见探测目标 |
|---|---|---|---|---|
| 01 | POSIX 文件探测 | stat() |
读取文件元数据,返回 0 表示路径真实存在 |
/Applications/Cydia.app, /usr/sbin/sshd |
| 02 | 动态链接库检测 | _dyld_get_image_name() |
遍历已加载 Mach-O 动态镜像路径列表 | MobileSubstrate.dylib 等 Hook 框架注入库 |
| 03 | 私有协议跳转 | -[UIApplication canOpenURL:] |
检查系统注册表是否允许拉起越狱管理工具 | cydia://, sileo://, zbra:// |
| 04 | 文件系统高层抽象 | -[NSFileManager fileExistsAtPath:] |
检索沙箱外已知越狱工具的默认安装路径 | /Library/MobileSubstrate/MobileSubstrate.dylib |
| 05 | 沙箱逃逸写权限 | -[NSString writeToFile:...error:] |
尝试在受保护的系统根目录写入文件 | /private/jailbreak.txt,判断是否有 root 写权限 |
| 06 | 软链接与 Stash 迁移 | lstat() + S_IFLNK |
探测关键系统目录是否被符号链接至数据分区 | /Applications 是否链接至 /var/stash/ |
| 07 | 进程克隆隔离 | fork() |
未越狱沙箱禁止 fork,能派生子进程即为越狱 |
沙箱沙禁限制检查(返回 pid > 0 判定越狱) |
| 08 | 运行时与注入环境 | NSClassFromString() / getenv() |
探测注入类符号以及动态链接器强制注入参数 | 类 HBPreferences、变量 DYLD_INSERT_LIBRARIES |
2. 正向防御实现剖析与逆向 Frida 插桩实录
下面我逐一拆解这八项核心检测项的正向 Objective-C / POSIX C 实现逻辑,并给出对应的 Frida 动态插桩绕过代码。
2.1 探针一:stat() 敏感系统文件探测
正向检测机理
在 POSIX 标准中,stat(const char *path, struct stat *buf) 用于获取指定路径文件的状态信息。如果路径存在且调用进程拥有沿途目录的检索权限,函数返回 0;若文件不存在,则返回 -1 并置 errno 为 ENOENT。越狱设备上通常会遗留诸如 Cydia 桌面应用或 OpenSSH 守护进程:
// 正向检测逻辑
+ (BOOL)checkJailbreakByStat {
const char *targets[] = {
"/Applications/Cydia.app",
"/usr/sbin/sshd",
"/bin/bash",
"/etc/apt"
};
struct stat stat_info;
for (int i = 0; i < 4; i++) {
if (stat(targets[i], &stat_info) == 0) {
return YES; // 路径存在,判定越狱
}
}
return NO;
}
Frida 动态绕过
攻击者可以通过 Hook libSystem.B.dylib 中导出的 stat 函数,在 onLeave 阶段将返回值从 0 篡改为 -1。但需要特别注意:不能无脑将所有 stat 全部篡改为 -1,否则应用读取正常业务资源(如本地 Bundle、Documents 配置)也会全部失败崩溃。必须在 onEnter 捕获路径并进行精准的敏感词白名单过滤:
function hook_stat(is_pass) {
var statPtr = Module.findExportByName('libSystem.B.dylib', 'stat');
if (!statPtr) return;
var sensitivePaths = [
"/Applications/Cydia.app",
"/usr/sbin/sshd",
"/bin/bash",
"/etc/apt"
];
Interceptor.attach(statPtr, {
onEnter: function (args) {
this.isTarget = false;
if (args[0].isNull()) return;
var path = args[0].readUtf8String();
for (var i = 0; i < sensitivePaths.length; i++) {
if (path.indexOf(sensitivePaths[i]) !== -1) {
this.isTarget = true;
this.path = path;
break;
}
}
},
onLeave: function (retval) {
if (is_pass && this.isTarget) {
// 将代表存在的 0 强制替换为 -1 (ENOENT)
retval.replace(-1);
console.log(`[stat] 伪装文件不存在: ${this.path}`);
}
}
});
}
2.2 探针二:_dyld_get_image_name() 动态链接库遍历
正向检测机理
越狱插件运行的核心基石在于动态库注入。无论是经典的 Cydia Substrate、Substitute,还是 Frida 本身的动态插桩库,都会在进程空间加载特定的 .dylib。App 可以通过调用 dyld 导出的 _dyld_image_count() 和 _dyld_get_image_name(uint32_t image_index) 遍历当前进程加载的所有 Mach-O 模块路径,一旦发现黑名单特征库即行报警:
// 正向检测逻辑
+ (BOOL)checkLoadedDylibs {
uint32_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
const char *image_name = _dyld_get_image_name(i);
if (strstr(image_name, "MobileSubstrate.dylib") ||
strstr(image_name, "FridaGadget.dylib")) {
return YES;
}
}
return NO;
}
Frida 动态绕过
如果 App 通过 _dyld_get_image_name 逐项扫描,最直接的手段是将命中的非法路径指针替换为一个系统自带的合法 Framework 路径字符串地址(例如 Intents 系统的动态库):
function hook_dyld_get_image_name(is_pass) {
var dyldGetImageNamePtr = Module.findExportByName(null, "_dyld_get_image_name");
if (!dyldGetImageNamePtr) return;
var blackList = [
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"FridaGadget.dylib"
];
// 预先分配一个合法的系统 Framework 路径内存
var legitPath = Memory.allocUtf8String("/System/Library/Frameworks/Intents.framework/Intents");
Interceptor.attach(dyldGetImageNamePtr, {
onEnter: function (args) {
this.index = args[0].toInt32();
},
onLeave: function (retval) {
if (retval.isNull()) return;
var currentPath = retval.readCString();
if (is_pass) {
for (var i = 0; i < blackList.length; i++) {
if (currentPath && currentPath.indexOf(blackList[i]) !== -1) {
console.log(`[_dyld] 屏蔽非法动态库: [${this.index}] ${currentPath}`);
retval.replace(legitPath);
break;
}
}
}
}
});
}
2.3 探针三:-[UIApplication canOpenURL:] 协议跳转探测与“崩溃之谜”
这一部分是整个对抗案例中最具技术深度与研究价值的环节。
正向检测机理
iOS 系统中每个 App 都可以注册私有的 URL Scheme。越狱商店(Cydia、Sileo、Zebra)在安装时会在系统注册表中注册 cydia:// 等协议。应用可以通过 UIKit 提供的 API 检查是否能够响应这些协议:
// 正向检测逻辑
+ (BOOL)checkJailbreakByScheme {
NSURL *url = [NSURL URLWithString:@"cydia://package/com.example.package"];
return [[UIApplication sharedApplication] canOpenURL:url];
}
崩溃的技术悬念与底层真相解密
[!CAUTION] 为什么直接
retval.replace(0)会导致 App 瞬间 Crash?
在ios_crackme的原帖中,作者写下了一段困惑:
“可 hook-[UIApplication canOpenURL:]替换返回过掉检测。但 canOpenURL 方法返回是个 BOOL,即 YES/NO。但在 Interceptor.attach 里用retval.replace()总是会导致 app 崩溃(不知道原理,望大佬指点)。所以使用Interceptor.replace()+NativeCallback替换掉方法,使其固定返回 0,也就是 NO。但这个解法也不能算是好方法……”
为什么会在 onLeave 中直接 retval.replace(0) 引发崩溃?
我在深入分析 ARM64 调用约定(AAPCS64)与 Frida 底层机理后,发现了两个致命的根源:
-
类型尺寸与指针替换语义错配:
在 64 位 iOS(ARM64)中,Objective-C 的BOOL本质上被定义为bool(C99)或signed char,宽度仅为 8 位(1 个字节),在函数返回时存储在w0寄存器。而 Frida 的retval.replace(val)设计用于指针级别(64 位宽,x0寄存器)的替换。如果在 JavaScript 中直接传纯数字0(即retval.replace(0)),Frida 内部在向寄存器覆写 64 位值时,如果当前环境开启了特定的内存对齐校验或遇到了寄存器上下文脏数据,极易破坏调用栈。 -
方法签名缺失导致栈平衡破坏:
原作者随后尝试使用Interceptor.replace,代码为:
Interceptor.replace(matche.address, new NativeCallback((url_obj) => {return 0;}, "int", ["pointer"]))
这个写法犯了一个严重的底层错误:完全忽略了 Objective-C 的隐式参数!
在 Objective-C 运行时中,每一个 OC 方法的底层 C 函数实现(IMP)必须显式接受至少两个隐式参数:- 第一个参数(
x0):id self(调用该方法的实例对象指针) - 第二个参数(
x1):SEL _cmd(当前方法的选择器指针) - 第三个参数(
x2):NSURL *url(显式传入的第一个入参)
原作者只声明了一个入参
["pointer"],导致在回调执行时,编译器生成的调用栈帧与原本的 AAPCS64 约定严重错位!系统原本传入的self被当作了url_obj,而当后续系统或业务代码继续解引用原本应当存在的栈或寄存器时,应用立刻因非法内存访问直接产生SIGSEGV或SIGBUS崩溃! - 第一个参数(
正确且优雅的解决方案
要优雅地绕过 canOpenURL:,有两种绝不会崩溃的工业级写法:
方案 A:使用 Frida 官方推荐的 ObjC.implement(最推荐)
Frida 的 Objective-C 运行时桥接层会自动处理 self 与 _cmd 的隐式参数对齐:
function hook_canopenurl_elegant() {
if (!ObjC.available) return;
var UIApplication = ObjC.classes.UIApplication;
var originalImpl = UIApplication['- canOpenURL:'].implementation;
UIApplication['- canOpenURL:'].implementation = ObjC.implement(
UIApplication['- canOpenURL:'],
function (self, _cmd, url) {
var nsUrl = new ObjC.Object(url);
var scheme = nsUrl.scheme() ? nsUrl.scheme().toString().toLowerCase() : "";
// 精确拦截越狱相关 scheme
if (scheme === "cydia" || scheme === "sileo" || scheme === "zbra" || scheme === "undecimus") {
console.log(`[canOpenURL] 拦截敏感 Scheme: ${scheme}://`);
return 0; // 返回 NO
}
// 其余正常业务 URL 原样放行
return originalImpl(self, _cmd, url);
}
);
}
方案 B:完整保留三参数签名的 NativeCallback
如果必须使用 Interceptor.replace,则必须显式声明完整的参数类型列表:
function hook_canopenurl_native(targetAddress) {
Interceptor.replace(targetAddress, new NativeCallback(function (self, _cmd, url) {
var nsUrl = new ObjC.Object(url);
var urlStr = nsUrl.absoluteString() ? nsUrl.absoluteString().toString() : "";
if (urlStr.indexOf("cydia") !== -1) {
return 0;
}
return 0; // 统一返回 NO
}, 'int', ['pointer', 'pointer', 'pointer']));
}
2.4 探针四:-[NSFileManager fileExistsAtPath:isDirectory:] 高级文件系统探测
正向检测机理
除了底层的 POSIX stat(),很多开发者更倾向于使用 Cocoa 层的 NSFileManager 来检查越狱组件:
// 正向检测逻辑
+ (BOOL)checkByNSFileManager {
NSArray *files = @[
@"/Library/MobileSubstrate/MobileSubstrate.dylib",
@"/bin/bash",
@"/usr/sbin/sshd"
];
NSFileManager *manager = [NSFileManager defaultManager];
for (NSString *file in files) {
if ([manager fileExistsAtPath:file]) {
return YES;
}
}
return NO;
}
Frida 动态绕过
针对 Objective-C 方法 -[NSFileManager fileExistsAtPath:] 或 -[NSFileManager fileExistsAtPath:isDirectory:],我同样拦截并精准过滤参数:
function hook_fileExistsAtPath(is_pass) {
var resolver = new ApiResolver("objc");
var matches = resolver.enumerateMatches("-[NSFileManager fileExistsAtPath:isDirectory:]");
matches.forEach(function (match) {
Interceptor.attach(match.address, {
onEnter: function (args) {
this.isTarget = false;
if (!args[2].isNull()) {
var path = ObjC.Object(args[2]).toString();
if (path.indexOf("Cydia") !== -1 ||
path.indexOf("MobileSubstrate") !== -1 ||
path.indexOf("bash") !== -1) {
this.isTarget = true;
this.path = path;
}
}
},
onLeave: function (retval) {
if (is_pass && this.isTarget) {
retval.replace(ptr(0)); // 返回 NO
console.log(`[NSFileManager] 伪装路径不存在: ${this.path}`);
}
}
});
});
}
2.5 探针五:-[NSString writeToFile:atomically:encoding:error:] 沙箱逃逸写权限探测
正向检测机理
苹果应用运行在严格的沙箱容器中,沙箱进程仅对自身的 Container/Data/Application/<UUID>/ 目录具有写入权限,对根目录 /、/private/、/var/ 等具有只读限制。越狱后若被 root 提权,则可能打破沙箱限制。检测方通常会故意尝试向 /private/ 写入测试文件,若写入成功且无异常抛出,则直接断定沙箱已被突破:
// 正向检测逻辑
+ (BOOL)checkSandboxEscape {
NSError *error = nil;
NSString *testStr = @"jailbreak_test";
[testStr writeToFile:@"/private/jb_test.txt"
atomically:YES
encoding:NSUTF8StringEncoding
error:&error];
if (error == nil) {
// 成功写入私有目录,说明沙箱限制被突破
[[NSFileManager defaultManager] removeItemAtPath:@"/private/jb_test.txt" error:nil];
return YES;
}
return NO;
}
Frida 动态绕过与二级指针内存重写
在 Objective-C 中,error: 参数的本质是 C 语言的二级指针(NSError **error)。在进入函数时,传入的是指向指针的地址;若函数执行出错,系统会调用 *error = [NSError errorWithDomain:...] 将错误实例指针写回该地址。
逆向人员若要欺骗检测方,必须在 onLeave 时,通过 Memory.writePointer 向 args[5](第六个参数,考虑到 self 和 _cmd 占据前两项)人工注入一个实例化的 NSError 指针,伪造“写入失败”的既成事实:
function hook_writeToFile(is_pass) {
var resolver = new ApiResolver("objc");
resolver.enumerateMatches("-[NSString writeToFile:atomically:encoding:error:]").forEach(function (match) {
Interceptor.attach(match.address, {
onEnter: function (args) {
this.errorPtr = args[5]; // NSError ** 二级指针
this.path = ObjC.Object(args[2]).toString();
},
onLeave: function (retval) {
// 如果是对敏感目录的越狱写入探测
if (is_pass && this.path.indexOf("/private/") !== -1) {
if (!this.errorPtr.isNull()) {
// 人工构建一个 NSError 实例并写回指针指向的内存
var errorObj = ObjC.classes.NSError.alloc().initWithDomain_code_userInfo_(
ObjC.classes.NSString.stringWithString_("NSPOSIXErrorDomain"),
1, // EPERM: Operation not permitted
null
);
Memory.writePointer(this.errorPtr, errorObj.handle);
}
retval.replace(ptr(0)); // 返回 NO (写入失败)
console.log(`[writeToFile] 伪造沙箱写保护拦截: ${this.path}`);
}
}
});
});
}
2.6 探针六:lstat() 符号链接与 Stash 存储迁移检测
正向检测机理
在早期的越狱环境(如 iOS 10 之前以及部分 32/64 位过渡期越狱)中,由于系统根分区的磁盘空间极小,越狱工具在安装大量 Tweak 前会执行一项名为 Stashing 的操作:将 /Applications、/Library/Ringtones 等系统原生目录整个拷贝至 /var/stash/ 数据分区,并在原路径创建一个指向该分区的符号链接(Symbolic Link)。
lstat() 与普通 stat() 的区别在于:当目标是符号链接时,lstat() 返回的是符号链接本身的状态,而不是其指向的目标文件。通过检查 stat_info.st_mode & S_IFLNK 是否为真,即可识破目录是否被搬迁:
// 正向检测逻辑
+ (BOOL)isLstatAtLnk {
NSArray *jbPaths = @[
@"/Applications",
@"/var/stash/Library/Ringtones",
@"/var/stash/usr/include"
];
struct stat stat_info;
for (NSString *path in jbPaths) {
const char *cPath = [path UTF8String];
if (lstat(cPath, &stat_info) == 0) {
// S_IFLNK 标志位校验
if ((stat_info.st_mode & S_IFMT) == S_IFLNK) {
return YES; // 是软链接,判定越狱
}
}
}
return NO;
}
Frida 动态绕过
如果仅仅像作者初版代码那样将 lstat 返回值改成 1,若正向检测代码使用的是 lstat(...) == 0,则返回非零值刚好表示失败;但若代码写成 if (lstat(...) != 0) return YES;(如认为路径异常),单纯改返回值就会被反向捕获。
真正专业的过法是直接修改 args[1] 指向的 struct stat 结构体内存,将 st_mode 中的文件类型位从 S_IFLNK(0120000)强行抹去,修改为常规目录(S_IFDIR / 0040000):
function hook_lstat_safe(is_pass) {
var lstatPtr = Module.findExportByName('libSystem.B.dylib', 'lstat');
if (!lstatPtr) return;
// S_IFMT: 0170000, S_IFLNK: 0120000, S_IFDIR: 0040000
var S_IFMT = 0xf000;
var S_IFLNK = 0xa000;
var S_IFDIR = 0x4000;
Interceptor.attach(lstatPtr, {
onEnter: function (args) {
this.path = args[0].readUtf8String();
this.buf = args[1]; // struct stat *
},
onLeave: function (retval) {
if (is_pass && retval.toInt32() === 0 && !this.buf.isNull()) {
if (this.path === "/Applications") {
// 在 arm64 Darwin 下,st_mode 是 mode_t (uint16_t),偏移位于 st_dev 之后
// 直接将 mode 抹去软链接位,伪装为普通目录
var st_mode = this.buf.add(4).readU16(); // 读取 st_mode 偏移
if ((st_mode & S_IFMT) === S_IFLNK) {
var cleanedMode = (st_mode & ~S_IFMT) | S_IFDIR;
this.buf.add(4).writeU16(cleanedMode);
console.log(`[lstat] 剥离符号链接特征: ${this.path}`);
}
}
}
}
});
}
2.7 探针七:fork() 进程派生能力检测
正向检测机理
在原装未越狱的 iOS 设备上,fork() 系统调用直接被 Apple Mobile File Integrity(AMFI)与内核沙箱策略严格拦截。任何普通 App 调用 fork() 都会立即失败并返回 -1(或抛出 ENOSYS)。但在早期完全越狱环境下,内核对 fork 限制被解除,应用能够成功克隆子进程:
// 正向检测逻辑
+ (BOOL)checkJailbreakByFork {
pid_t pid = fork();
if (pid > 0) {
// 父进程分支,子进程创建成功,说明沙箱破损
waitpid(pid, NULL, 0); // 回收子进程
return YES;
} else if (pid == 0) {
exit(0); // 子进程直接退出
}
return NO; // fork 失败,符合未越狱沙箱特征
}
Frida 动态绕过
只需将 fork 系统调用的返回值强制修正为 -1:
function hook_fork(is_pass) {
var forkPtr = Module.findExportByName(null, "fork");
if (!forkPtr) return;
Interceptor.attach(forkPtr, {
onLeave: function (retval) {
if (is_pass) {
var pid = retval.toInt32();
if (pid > 0) {
console.log(`[fork] 拦截非法子进程派生,强制返回 -1 (原 PID: ${pid})`);
retval.replace(ptr(-1));
}
}
}
});
}
2.8 探针八:运行时类符号与动态注入环境变量
正向检测机理
- 注入类检测:很多越狱插件依赖通用设置框架,如 Cephei 中的
HBPreferences。应用可以通过NSClassFromString(@"HBPreferences")判断此类是否已被注入到了进程地址空间; - 环境变量检测:动态库注入通常依赖系统的动态链接器环境变量。应用通过
getenv("DYLD_INSERT_LIBRARIES")检查是否存在挂载项:
// 正向检测逻辑
+ (BOOL)checkRuntimeEnvironment {
// 1. 类符号
if (NSClassFromString(@"HBPreferences") != nil) {
return YES;
}
// 2. 环境变量
char *env = getenv("DYLD_INSERT_LIBRARIES");
if (env != NULL) {
return YES;
}
return NO;
}
Frida 动态绕过
分别针对 Foundation 导出的 NSClassFromString 与 libc 导出的 getenv 进行阻断:
function hook_runtime_features(is_pass) {
// 1. 屏蔽类名查找
var nsClassFromStringPtr = Module.findExportByName('Foundation', 'NSClassFromString');
if (nsClassFromStringPtr) {
Interceptor.attach(nsClassFromStringPtr, {
onEnter: function (args) {
this.isTarget = false;
if (!args[0].isNull()) {
var clsName = ObjC.Object(args[0]).toString();
if (clsName === "HBPreferences") {
this.isTarget = true;
this.clsName = clsName;
}
}
},
onLeave: function (retval) {
if (is_pass && this.isTarget) {
console.log(`[NSClassFromString] 屏蔽敏感注入类: ${this.clsName}`);
retval.replace(ptr(0)); // 返回 nil
}
}
});
}
// 2. 屏蔽环境变量
var getenvPtr = Module.findExportByName(null, "getenv");
if (getenvPtr) {
Interceptor.attach(getenvPtr, {
onEnter: function (args) {
this.isTarget = false;
if (!args[0].isNull()) {
var varName = args[0].readUtf8String();
if (varName === "DYLD_INSERT_LIBRARIES") {
this.isTarget = true;
}
}
},
onLeave: function (retval) {
if (is_pass && this.isTarget) {
console.log("[getenv] 隐藏 DYLD_INSERT_LIBRARIES 环境变量");
retval.replace(ptr(0)); // 返回 NULL
}
}
});
}
}
3. 生产级整合 Bypass 脚本实装
在实际安全测试场景下,零散的 Hook 函数不利于快速注入与调试。我将上述 8 项规避逻辑重构为一套自包含、具备异常容错与白名单保护的工业级 Frida 脚本:
/**
* iOS Jailbreak Detection Comprehensive Bypass Script
* Target: ios_crackme / JailBreakCheek All-in-One Defense
*/
(function () {
'use strict';
console.log("[*] 正在装载 iOS 越狱检测综合 Bypass 引擎...");
// 1. 规避 stat() 系统调用
var statPtr = Module.findExportByName('libSystem.B.dylib', 'stat');
if (statPtr) {
var badPaths = ["/Applications/Cydia.app", "/usr/sbin/sshd", "/bin/bash", "/etc/apt"];
Interceptor.attach(statPtr, {
onEnter: function (args) {
this.bad = false;
if (!args[0].isNull()) {
var p = args[0].readUtf8String();
for (var i = 0; i < badPaths.length; i++) {
if (p.indexOf(badPaths[i]) !== -1) {
this.bad = true;
break;
}
}
}
},
onLeave: function (retval) {
if (this.bad) retval.replace(-1);
}
});
}
// 2. 规避 _dyld_get_image_name
var dyldPtr = Module.findExportByName(null, "_dyld_get_image_name");
if (dyldPtr) {
var fakePath = Memory.allocUtf8String("/System/Library/Frameworks/Intents.framework/Intents");
Interceptor.attach(dyldPtr, {
onLeave: function (retval) {
if (!retval.isNull()) {
var str = retval.readCString();
if (str && str.indexOf("MobileSubstrate") !== -1) {
retval.replace(fakePath);
}
}
}
});
}
// 3. 优雅处理 -[UIApplication canOpenURL:]
if (ObjC.available) {
var UIApplication = ObjC.classes.UIApplication;
var origCanOpen = UIApplication['- canOpenURL:'].implementation;
UIApplication['- canOpenURL:'].implementation = ObjC.implement(
UIApplication['- canOpenURL:'],
function (self, _cmd, url) {
var u = new ObjC.Object(url);
var s = u.scheme() ? u.scheme().toString().toLowerCase() : "";
if (s === "cydia" || s === "sileo" || s === "zbra") {
return 0; // 返回 NO
}
return origCanOpen(self, _cmd, url);
}
);
}
// 4. 规避 NSFileManager 路径检索
if (ObjC.available) {
var resolver = new ApiResolver("objc");
resolver.enumerateMatches("-[NSFileManager fileExistsAtPath:isDirectory:]").forEach(function (m) {
Interceptor.attach(m.address, {
onEnter: function (args) {
this.bad = false;
if (!args[2].isNull()) {
var p = ObjC.Object(args[2]).toString();
if (p.indexOf("Cydia") !== -1 || p.indexOf("MobileSubstrate") !== -1) {
this.bad = true;
}
}
},
onLeave: function (retval) {
if (this.bad) retval.replace(ptr(0));
}
});
});
}
// 5. 规避 fork 与环境变量
var forkPtr = Module.findExportByName(null, "fork");
if (forkPtr) {
Interceptor.attach(forkPtr, {
onLeave: function (retval) {
if (retval.toInt32() > 0) retval.replace(ptr(-1));
}
});
}
var getenvPtr = Module.findExportByName(null, "getenv");
if (getenvPtr) {
Interceptor.attach(getenvPtr, {
onEnter: function (args) {
this.isTarget = !args[0].isNull() && args[0].readUtf8String() === "DYLD_INSERT_LIBRARIES";
},
onLeave: function (retval) {
if (this.isTarget) retval.replace(ptr(0));
}
});
}
console.log("[+] 八重防线 Bypass 引擎全部就绪!");
})();
通过 Frida 将该脚本以早期注入方式注入目标应用:
frida -U -f com.example.ioscrackme -l bypass.js --no-pause
在界面中点击检测按钮,所有原本触发警告的项目瞬间全部显示为“安全(未越狱)”状态,顺利实现静默通关。
4. 攻防演进:现代 iOS 越狱范式迁移与高级对抗
随着苹果 iOS 安全体系的步步紧逼,传统的越狱防御与绕过技术在现代生产环境中已经发生了结构性的代际变迁。如果防御方依然固守 5 年前的静态特征检测,面对新一代设备将彻底形同虚设。
4.1 Rootless 与 Roothide 时代的检测失效
传统的越狱检测核心逻辑高度依赖于对 /Applications、/Library/MobileSubstrate 等系统根目录的写盘与存在性判断。但从 iOS 15 开始(如 Dopamine 与 Palera1n):
- 根目录封锁(SSV / APFS Sealed System Volume):系统分区完全只读且被硬件级加密签名校验,任何修改根分区的行为都会触发内核 Panic 无法开机;
- Rootless 范式:所有越狱资源、动态链接库和 Tweak 被统一收敛重定向到
/var/jb/数据分区,传统的/Applications/Cydia.app、/bin/bash探测天然全部扑空; - Roothide 极致隐匿:在新一代 Roothide 环境中,越狱目录每次安装都会被分配随机哈希路径(如
/var/containers/Bundle/Application/<Random-Hash>/jb/),不再具备任何静态全局特征。
[!NOTE] 现代检测的核心锚点转移
面对 Rootless,现代合规风控的重心已经从“静态文件路径检索”转移为:
- 探测
/var/jb/目录的访问权限与挂载状态;- 使用内核级系统调用检测自身进程的 Page Fault 异常与内存映射(如扫描非 App Store 签名的
PROT_EXEC内存页);- 校验
dyld的代码签名状态与csops()标志位。
4.2 商业级对抗:内联汇编直接系统调用(Direct Syscall)
正如我在本文逆向实战中所见,几乎所有基于标准 C 库函数(如 stat、lstat、fork)或 Objective-C API 的检测,在 Frida 面临的第一道阻碍就是 符号拦截(Symbol Hooking)。攻击者只要截获了 libSystem.B.dylib 导出的符号入口,正向逻辑就会瞬间被架空。
为了应对这一痛点,高端金融与游戏防护 SDK 往往会放弃调用系统的 libc 包装函数,转而采用 内联汇编直接发起系统调用(Direct SVC #0x80):
sequenceDiagram
autonumber
actor App as 业务防护模块
participant Libc as libSystem.B.dylib (可被 Hook)
participant Syscall as ARM64 SVC #0x80 (内联汇编)
participant Kernel as XNU 内核 (Kernel)
Note over App,Libc: 传统模式 (易被 Frida 符号截获)
App->>Libc: 调用 stat("/Applications/Cydia.app")
Note over Libc: Frida Interceptor 挂钩在此处,篡改返回值为 -1
Libc-->>App: 返回假数据 (-1)
Note over App,Syscall: 现代对抗模式 (内联直接系统调用)
App->>Syscall: 内联汇编装载 x16=0x152 (stat64)<br/>直接触发 svc #0x80
Syscall->>Kernel: 陷向内核态执行
Kernel-->>App: 直接返回真实内核探测结果,完全旁路 libc 符号表
在 ARM64 架构下,开发者直接将系统调用号载入 x16 寄存器并执行 svc #0x80:
; ARM64 直接系统调用 stat64 示例
mov x0, x19 ; 路径参数
mov x1, x20 ; 结构体缓冲区
mov x16, #0x152 ; SYS_stat64 系统调用号
svc #0x80 ; 触发异常陷向内核
由于不经过任何动态链接符号表,Frida 的 Module.findExportByName 或 Interceptor.attach 根本无法捕获该系统调用,迫使逆向分析师必须通过反汇编还原二进制指令,使用 Patch 汇编指令或基于硬件断点/内核 Tweak 才能实现绕过。
5. 防守方启示:构建立体多维防护体系
通过对 ios_crackme 的攻防复盘,作为应用开发者,如果希望有效提升自身应用的抗逆向与反越狱能力,应遵循以下工程原则:
- 摒弃单一判定与单点阻断:
严禁将越狱判定收敛在一个简单的+ (BOOL)isJailbroken函数中。攻击者只需一行 Frida 脚本将该函数直接返回NO即可全盘瓦解防护。应当将检测探针离散埋入核心业务逻辑,且不直接弹窗提示,而是作为风控特征参与数据包签名; - 多层级交叉验证与底层收敛:
结合使用 POSIX API、Mach 端口查询(mach_port_t)、SVC 直接系统调用与代码段签名校验(csops),形成不同系统抽象层级的交叉校验; - 加固与混淆联动:
核心检测代码必须使用 C/C++ 实现,通过符号剥离、控制流平坦化(OLLVM Flattening)、字符串动态加密(如编译期异或混淆),隐藏敏感路径字符串(如/Applications/Cydia.app),彻底增加静态分析与动态定位的逆向成本。
原文链接与参考资料
- 吾爱破解论坛原帖:iOS越狱检测app及frida过检测 - andyhah
- 看雪安全社区研讨:iOS越狱检测app及frida过检测 - KanXue
- GitHub 开源实验项目:andy0andy/ios_crackme - GitHub
- Frida 动态插桩官方文档:Frida JavaScript API Documentation
- Frida CodeShare 越狱对抗脚本库:Frida CodeShare Bypasses
-
- 1. 越狱防御的拓扑全景与攻防矩阵
- 2. 正向防御实现剖析与逆向 Frida 插桩实录
- 2.1 探针一:
stat()敏感系统文件探测 - 2.2 探针二:
_dyld_get_image_name()动态链接库遍历 - 2.3 探针三:
-[UIApplication canOpenURL:]协议跳转探测与“崩溃之谜” - 2.4 探针四:
-[NSFileManager fileExistsAtPath:isDirectory:]高级文件系统探测 - 2.5 探针五:
-[NSString writeToFile:atomically:encoding:error:]沙箱逃逸写权限探测 - 2.6 探针六:
lstat()符号链接与 Stash 存储迁移检测 - 2.7 探针七:
fork()进程派生能力检测 - 2.8 探针八:运行时类符号与动态注入环境变量
- 2.1 探针一:
- 3. 生产级整合 Bypass 脚本实装
- 4. 攻防演进:现代 iOS 越狱范式迁移与高级对抗
- 5. 防守方启示:构建立体多维防护体系
- 原文链接与参考资料