首页
社区
课程
招聘
[原创][讨论] ai 绕过ios越狱检测
发表于: 4天前 1021

[原创][讨论] ai 绕过ios越狱检测

4天前
1021

目标:让 com.fanduel.sportsbook 在 palera1n rootless 越狱 (iOS 16 / arm64) 的 iPhone 上能通过 Frida spawn/attach 且不被越狱检测杀进程。

结果:60+ 秒稳定存活;绕过方案共三件组合修复,脚本不到 50 行有效逻辑。

对照实验结论:这是 FanDuel 专属 的越狱检测,不是 frida/device 侧故障。

通过 IDA MCP(server_health 返回 module: SportsbookWrapper, imagebase: 0x100000000, hexrays_ready: true)直接在 IDA 里查询。

命中:

结论:AppsFlyer 的 JB 判定不会杀进程,只是把结果塞给归因上报。不是这次的元凶。

交叉引用回去:

发现的反欺诈栈:GeoComply(地理围栏 + RASP)、Sift Science(行为反欺诈)、Incognia(设备指纹)、PredictsFraudMonitor(自建)。静态字串都不像会直接 abort,而是做数据上报。

xrefs 追 _exit 的 3 个 code 调用点全在 Firebase Crashlytics(mach exception server / signal handler)内部 —— 都是崩溃处理走的路径,非主动杀

6 条 match 全部不是 4 字节对齐,都落在 __gcc_except_tab 等数据段的字节序巧合 —— 假阳性。二进制里没有直接绕 libc 的 syscall 调用。

py_eval 在 IDA 里读 segment,0x102066a00..0x102066bc8,一共 114 个初始化函数指针。逐个检查最前的几个:

阶段结论:静态看不到明显的"调 _exit 的 JB 判定"。真正的杀必然在三方 SDK(GeoComply / Incognia / Sift)内部,或者走非常规路径。得上动态。

覆盖所有经典 JB 检测面:

启动命令:

脚本根本没装上,frida-agent 的 IPC 就断了。排查两个嫌疑:

写最小脚本 test_min.js

用默认 runtime 跑:

脚本能装。所以之前是 v8 的问题。弃用 --runtime=v8

把脚本分成 1→2→...→N 个 section,每段跑完打 ok(...) 标志。

下一段是:

Spawn-gated 期间主动调 OC 方法触发 AppsFlyer 内部 init 副作用(可能要 dispatch 到主线程,但主线程还冻着),直接死锁/崩溃。

修复:永远不在 bypass 阶段"主动调"OC,只"被动 hook"。把 setter 调用换成 hook -[AppsFlyerLib skipAdvancedJailbreakValidation] 的 getter,永远返回 YES:

然后继续,下一段 hookGeoComply()Object.keys(ObjC.classes) 全扫(2 万+ 类),太慢,同样把 agent 拖超时。改成 setTimeout(hookGeoComply, 400) 延后到 resume 后再扫

此时 hook 能全部装上:

但用 Python 宿主 run.py 跑监控循环,结果:

进程在 resume 后立刻死,仍然是 0 秒。且 crash=None 表示是"干净终止"而不是崩溃。

假设:既然 hook 全装了 JB 还被杀 → 一定是别的信号。

尝试 noexit.js:把 exit / _exit / abort / raise / kill / pthread_kill / __cxa_throw / objc_terminate / ... 一股脑用 Interceptor.replace(p, new NativeCallback(()=>0, 'int', [])) 全 no-op 化。

结果:进程还是 0 秒死,而且 [NOEXIT] sym called 一条都没打。

当时的错误结论:既然所有 exit 原语都被替换为 no-op 还立刻死,杀进程肯定走的是 mach 级(task_terminate)或者 SIGKILL。

这一串证据把我往"外部 SIGKILL / launchd entitlement 拒绝"方向带偏了,写了一大段总结放弃 Frida 路线建议用 Substrate tweak。

后来才明白:Interceptor.replace(exit_like, ()=>0) 替换一个 noreturn 函数是错的exit/abort 被编译器标记 __attribute__((noreturn)),调用点后面不保留合法返回路径(常常编译成 BL abort; UDF #0 或者直接接下一个 basic block 的其他代码)。我们的 no-op "return 0" 让执行 flow 穿透到了垃圾指令,下一步走 SIGILL,看起来就像"立刻死"。

所以当时看到的现象是我们的 hook 自己把进程搞死的,跟 RASP 没关系。但我当时没反应过来。

用户贴了 terminal 给我:

并断言"这里 frida 先执行,是可以绕过检测的"。

这句提醒是整个会话的转折点。它意味着:

于是回到 Frida 正途。

纯粹只装 Process.setExceptionHandler,不装任何 hook。看看纯净状态下啥异常会触发。

结果:

只有 2 次 abort,地址 0x1f98c7198 是 libc 的 abort 函数入口。之前 noexit 实验里看到的 0x2a0184080 这种 "illegal-instruction" 都不存在于 baseline —— 全部是我们自己 Interceptor.replace 引起的副作用(Frida Gum 的半成品 trampoline)。

不用 Interceptor.replace 改写 abort 代码页,而是 Interceptor.attach + onEnterThread.sleep(永远)

重跑:

解读:

backtrace 指向非常明确的调用链:

重点

这一步彻底改写了"这是什么检测"的理解:之前以为是第三方 RASP 直接 exit,实际是通过 Apple 内部机制间接 abort。

在第 8 步做 noexit 和前期迭代时还看到过 illegal-instruction at 0x2a01...XXXX,总以为是 RASP 埋的 BRK trap。其实:

规律总结

对 exit 家族全换用 attach + Thread.sleep(∞) 后,illegal-instruction 再没出现过。

另一个踩坑:我用 isRaspProbe() 路径前缀过滤时把:

拦了以后 NSBundle 一走到这两类路径就 bug,又触发 _predicateSecurityAction abort(更隐晦的版本)。修复:

定位到 kill 在 +[_NSPredicateUtilities _predicateSecurityAction]。要 hook 它,踩了 4 种方法的坑才找到对路的:

Frida 的 ObjC.classes 是 Proxy,下划线前缀的私有类不被枚举。直接 get 也容易拿到 undefined(对 JS Proxy 来说,cls === undefined 时继续访问 .$ownMethods 直接 TypeError)。

扫 Foundation 的 47060 个符号,没有一个包含 _predicateSecurityAction。因为它是 ObjC class-method IMP,不是通过 nlist 导出的 C 符号。

Frida 能在 backtrace 里解析出这个符号名,所以理论上 DebugSymbol.fromName 应该也能。 fromName 会全盘扫所有已加载模块的符号(47k Foundation + 所有其它模块),加上 ObjC runtime 反查,足够久让 agent load 超时,直接 TransportError: connection closed

最后用最基础的 ObjC runtime C API:

但是:

+ 方法要从 metaclass 查,但这个类的 _predicateSecurityAction 很可能并没有注册成标准 class method(或被隐藏)。

既然目标 IMP 找不到,那就 hook 调它的那个人-[NSComparisonPredicate evaluateWithObject:substitutionVariables:]。这个方法是公开的 instance method,用同样的 runtime API 立刻拿到:

强制 predicate evaluate 返回 NO → _filterObjectsUsingPredicate 拿到空数组 → NSKeyPathExpression/NSFunctionExpression 根本不被求值_predicateSecurityAction 从源头就到不了

跑起来:

60 秒稳定存活BLOCKED abort 一次也没触发。搞定。

bypass.js 核心三件事,总共 3 个代码块,约 50 行有效逻辑:

-[NSComparisonPredicate evaluateWithObject:substitutionVariables:] 被全局改成 return NO所有 NSPredicate filter 都会返回空。对 bypass 启动足够,但 app 里任何依赖 predicate 的业务路径(搜索、筛选、缓存命中)都会失效。

生产级收敛建议:

这部分本次没做(够用就行),但要上生产得把它加上。

设备 iPhone(A11 或更老), arm64(非 arm64e)
iOS 版本 16.x
越狱 palera1n(rootless,tweak loader 在 /var/jb/usr/lib/TweakLoader.dylib
安装方式 用户美区 App Store 正版下载(原生 Apple 签名)
工具 Frida 16.4.10(client + server 一致)、IDA Pro 9.1 + ida_mcp 插件、Python 3.12
症状 点图标即闪退;frida -f com.fanduel.sportsbook 出现 Spawned ... Resuming main thread! 后立刻 Process terminated;其他 app(Lamoda、Winpot Casino、Safari 等)在同机同 frida 下完全正常
结论
--runtime=v8 在 palera1n iOS 16 设备 会让 agent load 直接失败;用默认 QJS
Interceptor.replace 一个 noreturn 函数返回 int caller 后面没合法路径,立即 SIGILL;改 Interceptor.attach + onEnter 永不 return
Interceptor.replace Apple 的 libc/CoreFoundation 函数 可能导致 CFBundle / libxpc 里缓存的函数指针调用时进入半成品 Gum trampoline 撞 illegal-instruction;能 attach 就 attach
在 spawn-gated 状态主动调 OC 方法(cls.shared() 触发 SDK 侧 init 副作用,容易死锁主线程;只 hook 被动拦截,不主动发起调用
Object.keys(ObjC.classes) 全扫 两万多类,太慢,直接把 agent load 拖超时
DebugSymbol.fromName 全模块符号扫描,对大型二进制同样卡死
ObjC.classes._Xxx(下划线开头类名) 常常拿不到;用 objc_lookUpClass 直接 runtime C 调
ObjC.classes 无法枚举类私有 class method 时 上一层 hook 公开的 caller 通常更稳
+[AppsFlyerUtils isJailbrokenWithSkipAdvancedJailbreakValidation:]:
  v21[0..20] = @[
      @"/Applications/Cydia.app",
      @"/Applications/blackra1n.app", ...
      @"/Library/MobileSubstrate/DynamicLibraries/LiveClock.plist",
      @"/private/var/lib/apt", ...
      @"/usr/sbin/sshd"
  ];
  for p in v21: if [NSFileManager fileExistsAtPath:p] return YES;
  ...
  // 再做 dladdr(class_getMethodImplementation(NSFileManager, @selector(fileExistsAtPath:)))
  // 对比是否在 Foundation.framework 里(检测 IMP 有没有被 swizzle)
/Applications/Cydia.app   → 1 hit (AppsFlyer 列表内)
/usr/sbin/sshd            → 1 hit
MobileSubstrate           → 3 hits
Jailbreak/Jailbroken      → 8 hits 总计
frida, Frida, FRIDA                 → 0 hits
cynject, libhooker, libsubstitute   → 0 hits
DYLD_INSERT_LIBRARIES                → 0 hits
PT_DENY_ATTACH                       → 0 hits
GeoComply                            → 30+ hits  ✓
Sift                                 → 60+ hits  ✓
IncdOnboarding (Incognia)            → 有        ✓
PredictsFraudMonitorPlugin           → 1 hit     ✓ (FanDuel 自建)
imports:
  _exit                 → libSystem.B.dylib       ✓
  _sysctl/_sysctlbyname → libSystem.B.dylib       ✓
  _task_info            → libSystem.B.dylib       ✓
  _getppid              → libSystem.B.dylib       ✓
  __dyld_get_image_header → libSystem.B.dylib     ✓
  ptrace                → NOT imported            ✗
  csops                 → NOT imported            ✗
find_bytes pattern=01 10 00 D4     → 6 matches
sub_10000400C: objc_opt_class(&BetTracker); +[RNCasinoGameInfoViewContainerManager load]_0(...)
sub_1000040A0: objc_opt_class(&TimestampModuleBridge); ...
// 所有 entry 都是这种 RN module 注册 stub,没有 RASP 检测
frida -H 127.0.0.1 -f com.fanduel.sportsbook -l bypass.js --runtime=v8
Connected to 127.0.0.1 (id=socket@127.0.0.1)
Failed to load script: the connection is closed
console.log('[MIN] script loaded');
console.log('[MIN] process : ' + Process.id + ' ' + Process.arch);
console.log('[MIN] main    : ' + Process.mainModule.name + ' base=' + Process.mainModule.base);
[MIN] script loaded
[MIN] process : 51969 arm64
[MIN] main    : SportsbookWrapper base=0x10029c000
[MIN] objc    : true
[MIN] runtime : QJS
Spawned `com.fanduel.sportsbook`. Resuming main thread!
[JB-BYPASS] + NSFileManager hooks installed
[JB-BYPASS] + UIApplication canOpenURL hook installed
[JB-BYPASS] + +[AppsFlyerUtils isJailbrokenWith...] -> NO
Failed to load script: the connection is closed        ← 死在这里
const inst = lib.shared();   // 在 spawn-gated 状态下主动调用 +[AppsFlyerLib shared]
inst.setSkipAdvancedJailbreakValidation_(1);
Interceptor.attach(lib['- skipAdvancedJailbreakValidation'].implementation,
    { onLeave(r) { r.replace(ptr(1)); } });
[JB-BYPASS] + bypass ready
[HB] heartbeat armed
Spawned `com.fanduel.sportsbook`. Resuming main thread!
[!] session detached: process-terminated crash=None
[=] alive for 0.0s (dead=True)
frida -H 127.0.0.1 -f com.fanduel.sportsbook -l .\empty.js -o log.txt
...
Connected to 127.0.0.1 (id=socket@127.0.0.1)
Spawning `com.fanduel.sportsbook`...
[EMPTY] no hooks installed         ← 脚本在 resume 之前就输出了
Spawned `com.fanduel.sportsbook`. Resuming main thread!
[Remote::com.fanduel.sportsbook ]-> Process terminated
Process.setExceptionHandler(function (d) {
    console.log('[EXC] ' + d.type + ' at ' + d.address);
    d.context.pc = d.context.pc.add(4);   // 跳过当前指令继续跑
    return true;
});
[*] resumed
[EXC#1] abort at 0x1f98c7198
[EXC#2] abort at 0x1f98c7198
[=] DIED at 0.58s
Interceptor.attach(abort_addr, {
    onEnter(args) {
        log('BLOCKED abort; bt: ...');
        while (true) Thread.sleep(3600);   // 调用线程永久 park
    }
});
[JB] block access /cores/.safe_mode
[JB] block access /var/jb/usr/lib/TweakLoader.dylib
[JB] block access /var/jb/usr/lib/TweakInject.dylib
[JB] BLOCKED abort from: +[_NSPredicateUtilities _predicateSecurityAction] ...
[=] DIED at 21.43s
0x1b891190c  Foundation!+[_NSPredicateUtilities _predicateSecurityAction]     ← abort()
0x1b8429fe4  Foundation!-[NSFunctionExpression  expressionValueWithObject:context:]
0x1b889fbac  Foundation!-[NSKeyPathExpression   expressionValueWithObject:context:]
0x1b8429d90  Foundation!-[NSComparisonPredicate evaluateWithObject:substitutionVariables:]
0x1b84299c0  Foundation!_filterObjectsUsingPredicate
0x1b84c4a84  Foundation!-[NSArray(NSPredicateSupport) filteredArrayUsingPredicate:]
0x1082c6bec  ServiceCore!initialize_framework_bundles
0x10373c42c  dyld!dyld4::Loader::findAndRunAllInitializers
0x105a50444  dyld!dyld4::Loader::runInitializersBottomUp
ObjC.classes._NSPredicateUtilities   // 报错或 undefined
Object.keys(ObjC.classes).includes('_NSPredicateUtilities')   // false
const lookUp = new NativeFunction(
    Module.findExportByName(null, 'objc_lookUpClass'),
    'pointer', ['pointer']);
const sel_registerName = new NativeFunction(
    Module.findExportByName(null, 'sel_registerName'),
    'pointer', ['pointer']);
const class_getInstanceMethod = new NativeFunction(
    Module.findExportByName(null, 'class_getInstanceMethod'),
    'pointer', ['pointer', 'pointer']);
const method_getImplementation = new NativeFunction(
    Module.findExportByName(null, 'method_getImplementation'),
    'pointer', ['pointer']);

const cls = lookUp(Memory.allocUtf8String('_NSPredicateUtilities'));  // ✓ 找到了
const method = class_getClassMethod(cls, sel_registerName(...'_predicateSecurityAction'...));
// method.isNull() === true    ← 取不到
const cls = lookUp(Memory.allocUtf8String('NSComparisonPredicate'));
const sel = sel_registerName(Memory.allocUtf8String('evaluateWithObject:substitutionVariables:'));
const method = class_getInstanceMethod(cls, sel);
const imp = method_getImplementation(method);
Interceptor.replace(imp, new NativeCallback(function(self, _sel, obj, vars) {
    return 0;     // NO - predicate 永远不匹配
}, 'bool', ['pointer', 'pointer', 'pointer', 'pointer']));
[JB] termination primitives trapped
[JB] RASP path probes blocked
[JB] -[NSComparisonPredicate evaluateWithObject:substitutionVariables:] -> NO @ 0x1b8429c88
[JB] FINAL bypass armed
[*] resumed
[JB] block access /cores/.safe_mode
[JB] block access /var/jb/usr/lib/TweakLoader.dylib
[JB] block access /var/jb/usr/lib/TweakInject.dylib
[=] ALIVE after 60.0s  ---  BYPASS SUCCEEDED
function trapAndPark(sym) {
    const p = Module.findExportByName(null, sym);
    if (!p) return;
    Interceptor.attach(p, {
        onEnter(args) {
            log('BLOCKED ' + sym);
            // Thread.sleep 永久 park 当前线程;不 return,不改写原函数
            while (true) Thread.sleep(3600);
        }
    });
}
['exit', '_exit', '_Exit', 'abort', 'abort_with_reason', 'abort_with_payload',
 'raise', 'pthread_kill', 'pthread_exit'].forEach(trapAndPark);
const RASP_PATHS = new Set([
    '/cores/.safe_mode',
    '/var/jb/usr/lib/TweakLoader.dylib',
    '/var/jb/usr/lib/TweakInject.dylib',
]);

['access', 'faccessat', 'stat', 'lstat', 'fstatat', 'stat64', 'lstat64']
.forEach(name => {
    const p = Module.findExportByName(null, name);
    if (!p) return;
    Interceptor.attach(p, {
        onEnter(args) {
            const path = (name === 'fstatat' || name === 'faccessat'
                          ? args[1] : args[0]).readCString();
            this.blocked = path && RASP_PATHS.has(path);
        },
        onLeave(retval) {
            if (!this.blocked) return;
            retval.replace(ptr('-1'));
            __error().writeInt(2);   // errno = ENOENT
        }
    });
});

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

上传的附件:
收藏
免费 1
支持
分享
最新回复 (1)
雪    币: 104
活跃值: (8352)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
4天前
0
游客
登录 | 注册 方可回帖
返回