找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5303

积分

0

好友

721

主题
发表于 1 小时前 | 查看: 3| 回复: 0

本文记录了一次在 iOS 16 palera1n rootless 越狱环境下,利用 Frida 动态调试与静态逆向分析成功绕过 FanDuel Sportsbook 越狱检测的完整过程。核心方案仅三处组合修复,脚本约 50 行有效逻辑,最终 App 稳定存活 60 秒以上。本文由 云栈社区 提供。

1. 目标与环境

设备与越狱环境详细信息表格

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

2. 静态分析阶段:先看二进制里有什么

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

2.1 用 func_query/regex 找 JB 相关函数名

命中:

  • +[AppsFlyerUtils isJailbrokenWithSkipAdvancedJailbreakValidation:] @ 0x101c1efe0
  • -[AppsFlyerLib skipAdvancedJailbreakValidation] @ 0x101c1ce30
  • -[AFSDKChecksum calculateV2ValueWithTimestamp:...isJailBroken:...]

2.2 反编译 AppsFlyer 的 JB 判定

+[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 判定不会杀进程,只是把结果塞给归因上报。不是这次的元凶。

2.3 用 find(type=string) 扫 JB 相关字串

/Applications/Cydia.app   → 1 hit (AppsFlyer 列表内)
/usr/sbin/sshd            → 1 hit
MobileSubstrate           → 3 hits
Jailbreak/Jailbroken      → 8 hits 总计

交叉引用回去:

  • 0x102a8e340 / 0x102a8e5c0 / 0x102a8e500 都被 AppsFlyer 引用;
  • 0x10218c27e "MobileSubstrate"sub_1017FC3A0(Sentry KSCrash 设备信息收集器)里,同样只是日志字段不杀。

2.4 扫更激进的 RASP 关键字

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 自建)

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

2.5 扫 exit / _exit / abort / ptrace

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            ✗

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

2.6 找直接 syscall(svc #0x80

find_bytes pattern=01 10 00 D4     → 6 matches

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

2.7 扫 __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. 第一次动态绕过尝试:全面但翻车

3.1 最初脚本(v1)

覆盖所有经典 JB 检测面:

  • Objective-C:NSFileManager fileExistsAtPath: / UIApplication canOpenURL: / AppsFlyer JB 接口重写
  • libc:stat / lstat / access / open / openat / fopen / ... / statfs
  • dyld 隐身:getenv("DYLD_INSERT_LIBRARIES")dlopen / dlsym 过滤、_dyld_image_count + _dyld_get_image_name replace 重写
  • 反反调试:ptrace no-op、sysctl KERN_PROC_PIDP_TRACED
  • GeoComply 启发式扫描(GC* / Solus* 前缀的类全 hook)
  • Crashlytics / Sentry 的 isBeingTraced 改 NO

启动命令:

frida -H 127.0.0.1 -f com.fanduel.sportsbook -l bypass.js --runtime=v8

3.2 结果

Connected to 127.0.0.1 (id=socket@127.0.0.1)
Failed to load script: the connection is closed

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

  • --runtime=v8 在 iOS 16 + palera1n 上可能 JIT 失败。
  • 我对 _dyld_image_count 做了 Interceptor.replace,replacement 里又调 new NativeFunction(imgCount, ...) 调回原符号 —— 这是自递归,因为 replace 之后该地址已经指向我们的 trampoline。栈溢出直接让 agent 死。

3.3 修复

  • 换成默认 QJS runtime;
  • 不 replace 整个 dyld API,改成 Interceptor.attachonLeave 里把被挑出的 image name 指针就地替换/usr/lib/system/libsystem_pthread.dylib 这种无害字符串。

4. 故障定位:script 根本没加载成功

4.1 先证明“chain 本身没坏”

写最小脚本 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 : QJS
Spawned `com.fanduel.sportsbook`. Resuming main thread!

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

4.2 逐段加 hook,找出是哪段把 agent 搞崩

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

[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);

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 armed
Spawned `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. 误判与纠偏:进程并不是被“外部杀”的

6.1 noexit 实验(错误版)

假设:既然 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。

6.2 进一步对照证据(加深了错判)

  • 对照组spawn_only.pydevice.spawndevice.resume,不 attach 任何 session → 进程 0.6s 死。
  • msgSend 全量追踪 能抓到 1633 次 objc_msgSend,全是 Foundation + Apple Vision framework 初始化;没一次落到 FanDuel 自己的 +load
  • 其他 app 对照 Lamoda / Winpot Casino / App Store / Safari 同设备同 frida spawn 全部正常。

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

6.3 关键误判点

后来才明白: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 -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

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

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

  • 脚本Spawned ... Resuming main thread! 之前就 print 出来了 —— 说明 spawn-gated 时机 OK,hook 确实能在 app 任何指令之前装好;
  • 之前的 noexit.js 之所以失败不是因为“外部 SIGKILL”,而是我用错了 Interceptor.replace 处理 noreturn 函数

于是回到 Frida 正途。

8. 再次对照:发现真正的 kill 通道是 libc abort()

8.1 重写为最小异常处理器 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)。

    8.2 修复 abort 拦截(正确版)

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

    Interceptor.attach(abort_addr, {
        onEnter(args) {
            log('BLOCKED abort; bt: ...');
            while (true) Thread.sleep(3600);   // 调用线程永久 park
        }
    });
    • 不改写原函数头部 不会污染 Apple 任何通过函数指针调 abort 的路径;
    • onEnter 永不返回 等同于 abort 从未被调用;
    • 原函数体根本进不去,抽象效果同“彻底 no-op”。

    8.3 立刻看到 RASP 探测

    重跑:

    [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

    解读:

    • FanDuel 早期用 access(path, W_OK) 探测 3 条路径。前两条(/var/jb/usr/lib/Tweak*.dylib)是palera1n rootless 的 tweak 注入器,存在 = 越狱;
    • 我们的 access hook 把它们 block 成 -1 / ENOENT,RASP 看起来没检测到越狱
    • 但进程仍然走到了 abort,调用点在 Apple Foundation!+[_NSPredicateUtilities _predicateSecurityAction]
    • abort 被我们的 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!_filterObjectsUsingPredicate
    0x1b84c4a84  Foundation!-[NSArray(NSPredicateSupport) filteredArrayUsingPredicate:]
    0x1082c6bec  ServiceCore!initialize_framework_bundles
    0x10373c42c  dyld!dyld4::Loader::findAndRunAllInitializers
    0x105a50444  dyld!dyld4::Loader::runInitializersBottomUp

    重点

    • _NSPredicateUtilities _predicateSecurityActionApple 在 iOS 16 引入的 NSPredicate 安全动作。当 NSFunctionExpressionNSKeyPathExpression 被要求 evaluate 一个它认为 “可能用 KVC 调危险方法” 的表达式时,它调这个方法 abort() 整个进程(CVE 防护机制)。
    • 调用上下文是 ServiceCore!initialize_framework_bundles,它在 dyld 初始化阶段用 NSArray filteredArrayUsingPredicate: 过滤所有已加载 framework 的 bundle 元数据。
    • 这条 abort 不是 FanDuel 自己发起的,是 FanDuel 的 RASP 发现越狱后,把“看起来越狱”的信号塞到某个 framework bundle 的元数据里,让 Apple 自家的 predicate evaluation 命中 security-action 分支 —— “借刀杀人”式越狱检测,从调用栈看像 Apple 原生行为,非常隐蔽。

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

    10. 又踩一个坑:Interceptor.replace 半改写 Apple 代码

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

    其实:

    • Interceptor.replace 会在目标函数首几条指令写一个 B 跳到 Gum 生成的 trampoline;
    • trampoline 里为了“调用原函数再 hook”,会复制原函数的头部 + 修正 PC 相对引用(比如 ADR);
    • 对 arm64 PAC / 共享库里经过代码签名校验 / 其他 app 缓存了函数指针这种 corner case,这个复制可能半成品化,表现为运行到 trampoline 时撞上非法编码;
    • 用户态 Process.setExceptionHandler 把 PC+4 跳过以后确实能继续走几条,但之后很快又撞另一个异常,循环几十秒直到 launchd watchdog 把进程杀了。

    规律总结

    • Interceptor.attach = 软 hook,不改写目标指令,只在 prologue 插入桥跳到我们 cb,cb 返回后原函数正常执行。安全。
    • Interceptor.replace = 硬替换,重写目标。对于 exit / abort / __cxa_throw / __stack_chk_fail 这类编译器假设不会 return 的 noreturn 函数,用起来会让 caller 跑到不保留合法指令的 “dead code”;对于 Apple 缓存了函数指针的 libc / CoreFoundation 路径,也可能落到半成品 trampoline。attach 就不要 replace

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

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

    • /private/preboot/ 当成 JB 路径(实际是 iOS 16 的 Cryptex 系统库路径,里面有 Safari / WebKit / AuthenticationServices 等)
    • /cores/ 前缀(实际里面有 .dSYM/Contents/Resources/DWARF/dyld 让 CFBundle 找 debug symbol)

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

    • 删掉 /private/preboot/ 前缀;
    • 精确 match /cores/.safe_mode 这一条,不整块拦 /cores/

    11. ObjC 私有类的定位手艺

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

    11.1 ObjC.classes._NSPredicateUtilities

    ObjC.classes._NSPredicateUtilities   // 报错或 undefined
    Object.keys(ObjC.classes).includes('_NSPredicateUtilities')   // false

    Frida 的 ObjC.classes 是 Proxy,下划线前缀的私有类不被枚举。直接 get 也容易拿到 undefined。

    11.2 Module.findModuleByName('Foundation').enumerateSymbols()

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

    11.3 DebugSymbol.fromName('+[_NSPredicateUtilities _predicateSecurityAction]')

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

    11.4 ✓ Runtime C API 直调

    最后用最基础的 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(或被隐藏)。

    11.5 ✓✓ 终极方案:上移一层,hook caller

    既然目标 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] 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
  • 60 秒稳定存活BLOCKED abort 一次也没触发。搞定。

    13. 完整绕过方案(生产脚本)

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

    13.1 Trap abort 家族(保险丝)

    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);

    13.2 Block 三条 RASP 路径探测

    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
                }
            });
        });

    13.3 短路 -[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']));

    13.4 启动

    frida -H 127.0.0.1 -f com.fanduel.sportsbook -l bypass.js -o log.txt

    14. 技术教训与总结

    14.1 Frida 用法层面

    Frida 绕过过程中遇到的坑与结论表格

    14.2 iOS 16 反越狱检测手法层面

    • 路径探测依然是基础
      palera1n rootless 最脆弱的暴露点是 /var/jb/usr/lib/TweakLoader.dylibTweakInject.dylib。大多数 RASP 会先 access(W_OK) 看这几个。
    • “借刀杀人”式检测
      不直接调 exit(),而是检测到越狱后构造一个会让 iOS 自己的 NSPredicate KVC 安全校验 触发 abort 的对象(塞到 framework bundle 元数据里)。从崩溃日志上看像 Apple 原生机制崩,反欺诈厂商有一定卸责性 + 抗分析价值。
    • dyld 初始化阶段是关键窗口
      ServiceCore!initialize_framework_bundles 在所有 __init_offsets 之前跑,通过 NSPredicate 过滤所有已加载 framework。这是 iOS 16 新增的预处理步骤,它的副作用形成了这次的 kill 路径。
    • _NSPredicateUtilities _predicateSecurityAction 是 iOS 16+ 的内置安全动作,遇到任何它认为“不受信任的 KVC 表达式”就无条件 abort。对反欺诈来说是天然的“借力点”。

    14.3 调试方法论

    • 永远先跑对照组(空脚本 / 只装异常处理器)。这次要是一开始就 baseline 比较,至少能省 50% 的时间。
    • Process.setExceptionHandler 是神器,无论 BRK / UDF / SIGSEGV / SIGILL,都能捕获 + 拿 PC 和上下文。
    • 抓 backtrace 就对了 trapAndParkThread.backtrace + DebugSymbol.fromAddress 把每层调用都解析出来,关键信息一行给出 Foundation!+[_NSPredicateUtilities _predicateSecurityAction] 就破案。
    • 观察时序 + 排除法 先证明“其他 app 活”再证明“本 app 专属”;先证明“exit hooks 没触发”再思考是不是 mach 级;先把 /cores/.safe_mode 拦掉再看进程能不能多活一点。每一步都缩小搜索空间。
    • 遇到 Frida API 疑难坑,fallback 到 C runtime objc_lookUpClass / class_getInstanceMethod / method_getImplementation 组合能搞定 95% 的 “ObjC.classes 拿不到” 问题。

    14.4 已知副作用

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

    生产级收敛 建议:

    • Hook ServiceCore!initialize_framework_bundles 的 enter / leave(用 Module.findExportByName('ServiceCore', ...) 查),设一个全局 flag g_in_init_bundles
    • NSComparisonPredicate evaluate... 的 replacement 里读 flag:if (g_in_init_bundles) return 0; else return original(...)
    • 调用原 IMP 可以通过 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.txt
    • 自动化验证
    
    python check_alive.py bypass.js   → 看到 BYPASS SUCCEEDED 即通过。



    上一篇:Agent时代,为什么HTML还替代不了Markdown?(文件格式选择比较)
    下一篇:VMmark 4.1发布:新增能效基准测试,精准量化虚拟化每瓦性能
    您需要登录后才可以回帖 登录 | 立即注册

    手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

    GMT+8, 2026-5-11 20:37 , Processed in 0.653682 second(s), 41 queries , Gzip On.

    Powered by Discuz! X3.5

    © 2025-2026 云栈社区.

    快速回复 返回顶部 返回列表