com.fanduel.sportsbook在 palera1n rootless 越狱 (iOS 16 / arm64) 的 iPhone 上能通过 Frida spawn/attach 且不被越狱检测杀进程。结果:60+ 秒稳定存活;绕过方案共三件组合修复,脚本不到 50 行有效逻辑。
1.目标与环境

对照实验结论:这是FanDuel 专属的越狱检测,不是 frida/device 侧故障。
2.静态分析阶段:先看二进制里有什么
通过 IDA MCP(server_health返回module: SportsbookWrapper, imagebase: 0x100000000, hexrays_ready: true)直接在 IDA 里查询。
func_query/regex 找 JB 相关函数名命中:
+[AppsFlyerUtils isJailbrokenWithSkipAdvancedJailbreakValidation:]0x101c1efe0-[AppsFlyerLib skipAdvancedJailbreakValidation]0x101c1ce30-[AFSDKChecksum calculateV2ValueWithTimestamp:...isJailBroken:...]+[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)结论:AppsFlyer 的 JB 判定不会杀进程,只是把结果塞给归因上报。不是这次的元凶。
find(type=string)扫 JB 相关字串/Applications/Cydia.app → 1 hit (AppsFlyer 列表内)/usr/sbin/sshd → 1 hitMobileSubstrate → 3 hitsJailbreak/Jailbroken → 8 hits 总计交叉引用回去:
0x102a8e340 / 0x102a8e5c0 / 0x102a8e5000x10218c27e "MobileSubstrate"sub_1017FC3A0(Sentry KSCrash 设备信息收集器)里,同样只是日志字段不杀。frida, Frida, FRIDA → 0 hitscynject, libhooker, libsubstitute → 0 hitsDYLD_INSERT_LIBRARIES → 0 hitsPT_DENY_ATTACH → 0 hitsGeoComply → 30+ hits ✓Sift → 60+ hits ✓IncdOnboarding (Incognia) → 有 ✓PredictsFraudMonitorPlugin → 1 hit ✓ (FanDuel 自建)发现的反欺诈栈:GeoComply(地理围栏 + RASP)、Sift Science(行为反欺诈)、Incognia(设备指纹)、PredictsFraudMonitor(自建)。静态字串都不像会直接abort,而是做数据上报。
exit / _exit / abort / ptraceimports: _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 ✗xrefs 追_exit的 3 个 code 调用点全在 Firebase Crashlytics(mach exception server / signal handler)内部 —— 都是崩溃处理走的路径,非主动杀。
svc #0x80)find_bytes pattern=01 10 00 D4 → 6 matches6 条 match 全部不是 4 字节对齐,都落在__gcc_except_tab等数据段的字节序巧合 —— 假阳性。二进制里没有直接绕 libc 的 syscall 调用。
__init_offsets(iOS 16 新版 init 段)用py_eval在 IDA 里读 segment,0x102066a00..0x102066bc8,一共 114 个初始化函数指针。逐个检查最前的几个:
sub_10000400C: objc_opt_class(&BetTracker); +[RNCasinoGameInfoViewContainerManager load]_0(...)sub_1000040A0: objc_opt_class(&TimestampModuleBridge); ...// 所有 entry 都是这种 RN module 注册 stub,没有 RASP 检测阶段结论:静态看不到明显的"调
_exit的 JB 判定"。真正的杀必然在三方 SDK(GeoComply / Incognia / Sift)内部,或者走非常规路径。得上动态。
3. 第一次动态绕过尝试:全面但翻车
覆盖所有经典 JB 检测面:
NSFileManager fileExistsAtPath:/UIApplication canOpenURL:/ AppsFlyer JB 接口重写stat / lstat / access / open / openat / fopen / ... / statfsgetenv("DYLD_INSERT_LIBRARIES")、dlopen/dlsym过滤、_dyld_image_count+_dyld_get_image_namereplace 重写ptraceno-op、sysctl KERN_PROC_PID清P_TRACEDGC*/Solus*前缀的类全 hook)isBeingTraced改 NO启动命令:
frida -H 127.0.0.1 -f com.fanduel.sportsbook -l bypass.js --runtime=v8Connectedto127.0.0.1 (id=socket@127.0.0.1)Failedtoloadscript: theconnectionisclosed脚本根本没装上,frida-agent 的 IPC 就断了。排查两个嫌疑:
--runtime=v8_dyld_image_count做了Interceptor.replace,replacement 里又调new NativeFunction(imgCount, ...)调回原符号 ——这是自递归,因为replace之后该地址已经指向我们的 trampoline。栈溢出直接让 agent 死。Interceptor.attach在onLeave里把被挑出的 image name 指针就地替换为/usr/lib/system/libsystem_pthread.dylib这种无害字符串。4. 故障定位:script 根本没加载成功
写最小脚本test_min.js:
console.log('[MIN] script loaded');console.log('[MIN] process : ' + Process.id + ' ' + Process.arch);console.log('[MIN] main : ' + Process.mainModule.name + ' base=' + Process.mainModule.base);用默认 runtime 跑:
[MIN] script loaded[MIN] process : 51969 arm64[MIN] main : SportsbookWrapper base=0x10029c000[MIN] objc : true[MIN] runtime : QJSSpawned `com.fanduel.sportsbook`. Resuming main thread!脚本能装。所以之前是 v8 的问题。弃用--runtime=v8。
把脚本分成 1→2→...→N 个 section,每段跑完打ok(...)标志。
[JB-BYPASS] + NSFileManager hooks installed[JB-BYPASS] + UIApplication canOpenURL hook installed[JB-BYPASS] + +[AppsFlyerUtils isJailbrokenWith...] -> NOFailed to load script: the connection is closed ← 死在这里下一段是:
const inst = lib.shared(); // 在 spawn-gated 状态下主动调用 +[AppsFlyerLib shared]inst.setSkipAdvancedJailbreakValidation_(1);Spawn-gated 期间主动调 OC 方法触发 AppsFlyer 内部 init 副作用(可能要 dispatch 到主线程,但主线程还冻着),直接死锁/崩溃。
修复:永远不在 bypass 阶段"主动调"OC,只"被动 hook"。把 setter 调用换成 hook-[AppsFlyerLib skipAdvancedJailbreakValidation]的 getter,永远返回 YES:
Interceptor.attach(lib['- skipAdvancedJailbreakValidation'].implementation, { onLeave(r) { r.replace(ptr(1)); } });然后继续,下一段hookGeoComply()里Object.keys(ObjC.classes)全扫(2 万+ 类),太慢,同样把 agent 拖超时。改成setTimeout(hookGeoComply, 400)延后到 resume 后再扫。
5. 走对路径后的第一次"看见进程启动"
此时 hook 能全部装上:
[JB-BYPASS] + bypass ready[HB] heartbeat armedSpawned `com.fanduel.sportsbook`. Resuming main thread!但用 Python 宿主run.py跑监控循环,结果:
[!] session detached: process-terminated crash=None[=] alive for 0.0s (dead=True)进程在 resume 后立刻死,仍然是 0 秒。且crash=None表示是"干净终止"而不是崩溃。
6. 误判与纠偏:进程并不是被"外部杀"的
假设:既然 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。
spawn_only.py只device.spawn后device.resume,不 attach 任何 session → 进程 0.6s 死。objc_msgSend,全是 Foundation + Apple Vision framework 初始化;没一次落到 FanDuel 自己的+load。Lamoda / Winpot Casino / App Store / Safari同设备同 frida spawn 全部正常。这一串证据把我往"外部 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 没关系。但我当时没反应过来。
7. 用户关键提醒:"frida 先执行"
用户贴了 terminal 给我:
frida-H127.0.0.1-fcom.fanduel.sportsbook-l .\empty.js-olog.txt...Connectedto127.0.0.1 (id=socket@127.0.0.1)Spawning `com.fanduel.sportsbook`...[EMPTY]nohooksinstalled ← 脚本在 resume 之前就输出了Spawned `com.fanduel.sportsbook`. Resumingmainthread![Remote::com.fanduel.sportsbook ]-> Processterminated并断言"这里 frida 先执行,是可以绕过检测的"。
这句提醒是整个会话的转折点。它意味着:
Spawned ... Resuming main thread!之前就 print 出来了 —— 说明 spawn-gated 时机 OK,hook 确实能在 app 任何指令之前装好;noexit.js之所以失败不是因为"外部 SIGKILL",而是我用错了Interceptor.replace处理 noreturn 函数。于是回到 Frida 正途。
8. 再次对照:发现真正的 kill 通道是 libc abort()
exception_only.js纯粹只装Process.setExceptionHandler,不装任何 hook。看看纯净状态下啥异常会触发。
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只有 2 次 abort,地址0x1f98c7198是 libc 的abort函数入口。之前 noexit 实验里看到的0x2a0184080这种 "illegal-instruction" 都不存在于 baseline —— 全部是我们自己Interceptor.replace引起的副作用(Frida Gum 的半成品 trampoline)。
不用Interceptor.replace改写 abort 代码页,而是Interceptor.attach+onEnter里Thread.sleep(永远):
Interceptor.attach(abort_addr, {onEnter(args) {log('BLOCKED abort; bt: ...');while (true) Thread.sleep(3600); // 调用线程永久 park }});重跑:
[JB]blockaccess /cores/.safe_mode[JB]blockaccess /var/jb/usr/lib/TweakLoader.dylib[JB]blockaccess /var/jb/usr/lib/TweakInject.dylib[JB]BLOCKEDabortfrom: +[_NSPredicateUtilities _predicateSecurityAction] ...[=]DIEDat21.43s解读:
access(path, W_OK)探测 3 条路径。前两条(/var/jb/usr/lib/Tweak*.dylib)是palera1n rootless 的 tweak 注入器,存在 = 越狱;accesshook 把它们 block 成 -1 / ENOENT,RASP 看起来没检测到越狱;abort,调用点在 AppleFoundation!+[_NSPredicateUtilities _predicateSecurityAction];trapAndPark接住 → 线程 park → dyld 初始化永远完不成 → 20s 后 launchd watchdog 杀进程("DIED at 21.43s")。9. 抓栈:看见_predicateSecurityAction的真面目
backtrace 指向非常明确的调用链:
0x1b891190c Foundation!+[_NSPredicateUtilities _predicateSecurityAction] ← abort()0x1b8429fe4 Foundation!-[NSFunctionExpression expressionValueWithObject:context:]0x1b889fbac Foundation!-[NSKeyPathExpression expressionValueWithObject:context:]0x1b8429d90 Foundation!-[NSComparisonPredicate evaluateWithObject:substitutionVariables:]0x1b84299c0 Foundation!_filterObjectsUsingPredicate0x1b84c4a84 Foundation!-[NSArray(NSPredicateSupport) filteredArrayUsingPredicate:]0x1082c6bec ServiceCore!initialize_framework_bundles0x10373c42c dyld!dyld4::Loader::findAndRunAllInitializers0x105a50444 dyld!dyld4::Loader::runInitializersBottomUp重点:
_NSPredicateUtilities _predicateSecurityActionNSFunctionExpression或NSKeyPathExpression被要求 evaluate 一个它认为 "可能用 KVC 调危险方法" 的表达式时,它调这个方法abort()整个进程(CVE 防护机制)。ServiceCore!initialize_framework_bundles,它在 dyld 初始化阶段用NSArray filteredArrayUsingPredicate:过滤所有已加载 framework 的 bundle 元数据。这一步彻底改写了"这是什么检测"的理解:之前以为是第三方 RASP 直接 exit,实际是通过 Apple 内部机制间接 abort。
10. 又踩一个坑:Interceptor.replace半改写 Apple 代码
在第 8 步做 noexit 和前期迭代时还看到过illegal-instruction at 0x2a01...XXXX,总以为是 RASP 埋的 BRK trap。
其实:
Interceptor.replaceB跳到 Gum 生成的 trampoline;ADR);Process.setExceptionHandler把 PC+4 跳过以后确实能继续走几条,但之后很快又撞另一个异常,循环几十秒直到 launchd watchdog 把进程杀了。规律总结:
Interceptor.attachInterceptor.replaceattach就不要replace。对 exit 家族全换用 attach +Thread.sleep(∞)后,illegal-instruction再没出现过。
另一个踩坑:我用isRaspProbe()路径前缀过滤时把:
/private/preboot//cores/.dSYM/Contents/Resources/DWARF/dyld让 CFBundle 找 debug symbol)拦了以后 NSBundle 一走到这两类路径就 bug,又触发_predicateSecurityActionabort(更隐晦的版本)。修复:
/private/preboot/前缀;/cores/.safe_mode这一条,不整块拦/cores/。11. ObjC 私有类的定位手艺
定位到 kill 在+[_NSPredicateUtilities _predicateSecurityAction]。要 hook 它,踩了 4 种方法的坑才找到对路的:
ObjC.classes._NSPredicateUtilitiesObjC.classes._NSPredicateUtilities// 报错或 undefinedObject.keys(ObjC.classes).includes('_NSPredicateUtilities') // falseFrida 的ObjC.classes是 Proxy,下划线前缀的私有类不被枚举。直接 get 也容易拿到 undefined(对 JS Proxy 来说,cls === undefined时继续访问.$ownMethods直接 TypeError)。
Module.findModuleByName('Foundation').enumerateSymbols()扫 Foundation 的 47060 个符号,没有一个包含_predicateSecurityAction。因为它是 ObjC class-method IMP,不是通过 nlist 导出的 C 符号。
DebugSymbol.fromName('+[_NSPredicateUtilities _predicateSecurityAction]')Frida 能在 backtrace 里解析出这个符号名,所以理论上DebugSymbol.fromName应该也能。但fromName会全盘扫所有已加载模块的符号(47k Foundation + 所有其它模块),加上 ObjC runtime 反查,足够久让 agent load 超时,直接TransportError: connection closed。
最后用最基础的 ObjC runtime C API:
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 ← 取不到+方法要从 metaclass 查,但这个类的_predicateSecurityAction很可能并没有注册成标准 class method(或被隐藏)。
既然目标 IMP 找不到,那就 hook调它的那个人:-[NSComparisonPredicate evaluateWithObject:substitutionVariables:]。这个方法是公开的 instance method,用同样的 runtime API 立刻拿到:
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']));强制 predicate evaluate 返回 NO →_filterObjectsUsingPredicate拿到空数组 →NSKeyPathExpression/NSFunctionExpression根本不被求值→_predicateSecurityAction从源头就到不了。
12. 最终突破:短路 NSComparisonPredicate
跑起来:
[JB]terminationprimitivestrapped[JB]RASPpathprobesblocked[JB]-[NSComparisonPredicate evaluateWithObject:substitutionVariables:]-> NO @ 0x1b8429c88[JB]FINALbypassarmed[*]resumed[JB]blockaccess /cores/.safe_mode[JB]blockaccess /var/jb/usr/lib/TweakLoader.dylib[JB]blockaccess /var/jb/usr/lib/TweakInject.dylib[=]ALIVEafter60.0s ---BYPASSSUCCEEDED60 秒稳定存活,BLOCKED abort一次也没触发。搞定。
13. 完整绕过方案(生产脚本)
bypass.js核心三件事,总共 3 个代码块,约 50 行有效逻辑:
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 } });});-[NSComparisonPredicate evaluate...]const lookUp = new NativeFunction(Module.findExportByName(null, 'objc_lookUpClass'), 'pointer', ['pointer']);const selReg = new NativeFunction(Module.findExportByName(null, 'sel_registerName'), 'pointer', ['pointer']);const classGetIM = new NativeFunction(Module.findExportByName(null, 'class_getInstanceMethod'), 'pointer', ['pointer','pointer']);const methGetImp = new NativeFunction(Module.findExportByName(null, 'method_getImplementation'), 'pointer', ['pointer']);const cls = lookUp(Memory.allocUtf8String('NSComparisonPredicate'));const sel = selReg(Memory.allocUtf8String('evaluateWithObject:substitutionVariables:'));const imp = methGetImp(classGetIM(cls, sel));Interceptor.replace(imp, new NativeCallback(function (self, _sel, obj, vars) {return 0; // 永远 NO}, 'bool', ['pointer', 'pointer', 'pointer', 'pointer']));frida -H 127.0.0.1 -f com.fanduel.sportsbook -l bypass.js -o log.txt14. 技术教训与总结

/var/jb/usr/lib/TweakLoader.dylib和TweakInject.dylib。大多数 RASP 会先access(W_OK)看这几个。exit(),而是检测到越狱后构造一个会让iOS 自己的 NSPredicate KVC 安全校验触发abort的对象(塞到 framework bundle 元数据里)。从崩溃日志上看像 Apple 原生机制崩,反欺诈厂商有一定卸责性 + 抗分析价值。ServiceCore!initialize_framework_bundles在所有__init_offsets之前跑,通过 NSPredicate 过滤所有已加载 framework。这是 iOS 16 新增的预处理步骤,它的副作用(+ app 特定数据)形成了这次的 kill 路径。_NSPredicateUtilities _predicateSecurityActionProcess.setExceptionHandler是神器trapAndPark里Thread.backtrace+DebugSymbol.fromAddress把每层调用都解析出来,关键信息一行给出Foundation!+[_NSPredicateUtilities _predicateSecurityAction]就破案。/cores/.safe_mode拦掉再看进程能不能多活一点。每一步都缩小搜索空间。objc_lookUpClass / class_getInstanceMethod / method_getImplementation组合能搞定 95% 的"ObjC.classes 拿不到"问题。-[NSComparisonPredicate evaluateWithObject:substitutionVariables:]被全局改成return NO,所有NSPredicate filter 都会返回空。对 bypass 启动足够,但 app 里任何依赖 predicate 的业务路径(搜索、筛选、缓存命中)都会失效。
生产级收敛建议:
ServiceCore!initialize_framework_bundles的 enter / leave(用Module.findExportByName('ServiceCore', ...)查,加载晚但在我们需要前能完成),设一个全局 flagg_in_init_bundles。NSComparisonPredicate evaluate...if (g_in_init_bundles) return 0; else return original(...)。Interceptor.replaceFast拿到原函数指针(Frida 16 新增),或手动 save 原 IMP 再调。这部分本次没做(够用就行),但要上生产得把它加上。
fanduel_bypass/├── bypass.js 最终生产脚本(3 fix 组合,~50 行核心)├── check_alive.py Python 宿主:spawn → attach → load → 监控存活├── minimal.js 迭代版本,保留完整注释,便于对比├── exception_only.js 对照组:只装异常处理器├── diag.js / ... 早期诊断脚本(全量 msgSend 追踪、file probe 追踪)├── run.py / gate_launch.py / spawn_only.py 多种启动姿势├── log.txt 典型成功运行日志├── README.md 简要说明└── ANALYSIS.md 本文档frida -H 127.0.0.1 -f com.fanduel.sportsbook -l bypass.js -o log.txtpython check_alive.py bypass.js→ 看到BYPASS SUCCEEDED即通过。
看雪ID:zhuzhu_biu
https://bbs.kanxue.com/user-home-878476.htm

# 往期推荐


球分享

球点赞

球在看

点击阅读原文查看更多