首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
iOS安全
发新帖
1
0
[原创] 浅谈ios的风控检测与对抗
发表于: 2天前
253
[原创] 浅谈ios的风控检测与对抗
mxystery
2
2天前
253
# 浅谈ios的风控检测与对抗 开始分析一个 iOS 应用时,最先遇到的往往不是复杂算法,而是几件很具体的事:进程能不能正常启动,Frida 能不能附加,脚本执行以后,业务还能不能继续。 接着,问题会变得有些反直觉。 越狱路径已经查不到,为什么环境判断还会异常?Frida 的名字已经藏起来,为什么一安装 Hook 就出问题?手动调用检测函数,返回值明明正常,为什么下一次请求仍然带着旧记录? 它们指向同一个关键问题:**你改变的位置,是否就是检测器观察的位置;这次改变,又有没有传到最终使用结果的地方。** iOS 风控并不只有一个“是否越狱”的开关。它可以从文件查到服务,从镜像名查到函数指针,再从运行时环境延伸到触摸记录。理解这些检测,比维护一份越来越长的 Hook 清单更有用:知道了每条查询在看什么,才能解释对抗为什么有效,以及它的作用范围在哪里。 本文以越狱环境、Frida 和反 Hook 为主线,随后讨论触摸与结果缓存;字体和资源保护放在补充部分。所有场景都按通用机制展开,不依赖某个商业应用的类名或接口。 下面会直接给出对抗代码:从精确隐藏一条路径,到截断本机协议握手,再到改变一个反 Hook 检测方法的返回。每个例子都说明它改了哪一层,以及怎样确认这次改动生效。 ## 一、先看全貌:一次风险判断要经过哪些环节 可以先把检测器拆成四个问题,而不是立即寻找一个返回布尔值的方法。 | 问题 | 典型观察 | 对抗时要定位的地方 | |---|---|---| | 环境里有什么 | 越狱路径、注入目录、配置、环境变量、服务 | 环境对象与查询视图 | | 进程里改了什么 | 加载镜像、线程、IMP、导入指针、函数代码 | 工具痕迹与实际修改位置 | | 操作是怎样发生的 | 触摸阶段、时间、传感器、业务事件 | 输入来源与记录生产过程 | | 结果被谁使用 | 本地状态、缓存、报告、请求验证 | 保存、刷新、组装与消费 | 下面是一个简化的数据流。具体实现会选择其中一部分,但每项对抗都可以放到这张图上定位。 ```mermaid flowchart LR A[设备与进程环境] --> B[系统查询或运行时观察] B --> C[检测条件] F[配置与采样时机] --> C C --> D[记录与缓存] G[触摸与业务事件] --> D D --> E[报告或请求] E --> H[验证与策略消费] classDef probe fill:#e8f1fb,stroke:#4b78a8,color:#17324d classDef state fill:#fff2d8,stroke:#ad7b24,color:#583d11 class B,C probe class D,E state ``` 隐藏路径,主要改变系统查询看到的内容;替换检测返回,改变某个调用者得到的值;修改输出字段,则已经到了链路后部。它们可能得到相似的界面表现,却有不同的覆盖范围。 还有一个贯穿全文的前提:**观察主体必须一致。** 越狱文件管理工具能读到某个对象,不代表普通沙盒应用也能读到;检测函数能执行,不代表它一定命中;界面继续运行,也不代表没有生成风险记录。 ### 1.1 示例代码怎么运行 本文的代码与运行说明全部放在本页。选取一个小节的 Frida 代码保存为 `demo.js`,使用 CLI 加载即可;不同实验分开运行。§2.2 的 Foundation 片段是同节路径规则的可选扩展,接在路径片段之后。普通 JavaScript 演示会单独注明,无需 Frida。 ```powershell frida -U -f com.example.IOSRiskDemo -l demo.js ``` 将包名换成自己的实验 App。需要先准备文件或保存 IMP 基线的例子,先启动 Demo 做准备,再使用 `frida -U -n IOSRiskDemo -l demo.js` attach;重启进程后再换下一组实验。Objective-C 代码则放进自有 App 的 `.m` 文件,在实验按钮中触发。 涉及原生函数替换的片段直接使用 `SystemFunction` 转发未匹配调用,并保留返回值与 `errno`。原生替换函数中的 `this.errno` 来自 Frida 的调用上下文[10]。如果实验进程主动拒绝附加,先按 §3.7 确认处理时机。 示例用于自有或授权环境,按 Frida 17 CLI 编写;CLI 提供本文使用的 ObjC bridge。本次只有本地代码检查,没有 iOS 真机实测,后文“预期”均是需要验证的代码行为。 ## 二、越狱检测:从一条路径,查到一个运行中的环境 ### 2.1 路径名单是入口,环境对象才是重点 越狱检测最容易想到的是文件:包管理器、注入框架、启动配置是否存在。这类检查直观,但同一个环境往往不只留下一个文件。 | 观察面 | 常见对象或入口 | 对抗后仍需观察什么 | |---|---|---| | 安装痕迹 | 包管理器、bootstrap 目录、可执行文件 | 替代布局、符号链接与其他安装路径 | | 注入文件 | tweak 目录、框架与动态库 | 已加载镜像是否仍在进程里 | | 进程环境 | 环境变量中的路径或注入配置 | 当前进程是否保留了启动时的值 | | 启动配置 | LaunchDaemon、偏好 plist | 内容是否仍指向相同程序或服务 | | URL Scheme | `canOpenURL:` | 查询声明、处理者与对照样本 | | 命名服务 | bootstrap、可用的 Mach/XPC 通道 | 服务是否仍可见、是否能够应答 | rootless 让路径问题更明显。旧清单可能只盯着 `/Library/MobileSubstrate`,新环境则使用不同的 bootstrap 布局、重定位目录和链接关系。适配工作不能机械地给每条旧路径加上 `/var/jb`。 这里的 rootless 主要描述越狱的安装布局与对系统根卷的处理方式,不代表“没有越狱”。进程以哪个 UID 运行、是否受到沙盒限制、是否获得额外 entitlement、是否允许某种代码加载,也是不同维度。只看 `getuid()` 或某个目录,无法替代对这些实际能力的判断。 Substrate、Substitute、libhooker、ElleKit 等名称有助于整理生态对象。不过,增加四个框架名,未必增加了四种检测原理:它们可能最终都经过同一条文件查询。 对抗研究应当先分清两种变化:**对象真的变了,还是观察方收到的结果变了。** 移走文件与过滤一次查询,不一定同样影响已启动服务、已加载代码和历史缓存。 ### 2.2 两个 API 结果不同,也可能是正常现象 假设应用容器里有一个符号链接,它指向的文件已经被删除。`lstat` 能看到链接本体,`stat` 跟随链接后却失败。两者不一致,完全可以发生在正常环境里。 这就是为什么文件探针不能只保存一串 `true/false`。 | 接口 | 它回答的问题 | 需要保留的上下文 | |---|---|---| | `lstat` | 路径末端对象本身是什么 | 是否为符号链接、返回值与 `errno` | | `stat` | 跟随链接后能否取得目标元数据 | 目标不存在还是访问失败 | | `open` / `fopen` | 能否按指定模式打开对象 | 打开模式与真实错误 | | `access` | 此次访问检查是否符合条件 | 与随后实际操作之间的竞态 | | `fileExistsAtPath:` | 当前接口是否报告存在 | 布尔值无法完整表达失败原因 | 把所有查询改成失败,可能连应用自己的正常文件都一起“藏”掉。只处理 `NSFileManager`,又可能漏掉直接使用 C 接口的读者。 检测方可以多路观察,但多路不等于独立:三个包装接口如果最终共用同一个被控制的下层,得到一致结果并不奇怪。应先画出调用关系,再判断增加了多少有效观察。 #### 动手:只让指定路径“消失” 最直接的对抗,是让指定路径返回“不存在”,其余查询照常执行。例如规则明确查询下面两条路径,就按完整路径匹配,避免把任何包含 `jb` 的字符串都误伤。 ```javascript const paths = new Set([ '/Applications/Cydia.app', '/var/jb/usr/sbin/frida-server' ]); const homeDirectory = new NativeFunction( Module.getGlobalExportByName('NSHomeDirectory'), 'pointer', []); const home = new ObjC.Object(homeDirectory()).toString(); paths.add(home + '/Documents/ios-risk-demo-marker'); const specs = [ ['access', 'int', ['pointer', 'int'], -1], ['stat', 'int', ['pointer', 'pointer'], -1], ['lstat', 'int', ['pointer', 'pointer'], -1], ['fopen', 'pointer', ['pointer', 'pointer'], ptr(0)] ]; for (const [name, resultType, argumentTypes, failure] of specs) { const address = Module.getGlobalExportByName(name); const original = new SystemFunction(address, resultType, argumentTypes); Interceptor.replace(address, new NativeCallback(function (...args) { let path = null; try { path = args[0].readUtf8String(); } catch (_) {} if (paths.has(path)) { this.errno = 2; // Darwin ENOENT return failure; } const result = original(...args); this.errno = result.errno; return result.value; }, resultType, argumentTypes)); } ``` 这段代码在原调用之前判断路径。尤其是 `fopen`,不能先真实打开文件,再简单把返回指针改成空,否则可能泄漏句柄,写模式还可能已经创建或截断文件。命中时直接返回失败,调用者也不应继续使用 `stat` 的输出缓冲。 同一个实验中,若还要处理 Foundation 查询,将下面代码接在前一段后面。它复用同一份 `paths`,并处理带 `isDirectory` 输出参数的形式: ```javascript for (const selector of ['- fileExistsAtPath:', '- fileExistsAtPath:isDirectory:']) { Interceptor.attach(ObjC.classes.NSFileManager[selector].implementation, { onEnter(args) { this.hide = false; this.directoryOut = ptr(0); if (args[2].isNull()) return; this.hide = paths.has(new ObjC.Object(args[2]).toString()); if (selector.endsWith('isDirectory:')) this.directoryOut = args[3]; }, onLeave(result) { if (!this.hide) return; result.replace(0); if (!this.directoryOut.isNull()) this.directoryOut.writeU8(0); } }); } ``` 在 App 中先创建 `Documents/ios-risk-demo-marker` 与另一个正常文件,确认二者都能读取;再 attach 加载脚本,重复相同查询。实验所需的文件准备与观察可以直接写在按钮里: ```objc #import <Foundation/Foundation.h> #import <unistd.h> #import <errno.h> static void PreparePathDemo(void) { for (NSString *name in @[@"ios-risk-demo-marker", @"ios-risk-demo-normal"]) { NSString *path = [[NSHomeDirectory() stringByAppendingPathComponent:@"Documents"] stringByAppendingPathComponent:name]; if ([[NSFileManager defaultManager] fileExistsAtPath:path]) continue; NSError *error = nil; if (![@"demo" writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:&error]) NSLog(@"prepare failed: %@", error); } } static void ObservePathDemo(void) { for (NSString *name in @[@"ios-risk-demo-marker", @"ios-risk-demo-normal"]) { NSString *path = [[NSHomeDirectory() stringByAppendingPathComponent:@"Documents"] stringByAppendingPathComponent:name]; errno = 0; int result = access(path.fileSystemRepresentation, F_OK); int error = result == -1 ? errno : 0; BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path]; NSLog(@"%@ access=%d errno=%d exists=%d", name, result, error, exists); } } // 第一次按钮:PreparePathDemo(); ObservePathDemo(); // 安装 Hook 后的按钮:ObservePathDemo(); ``` 其他 POSIX 接口按同样方式分别保存返回和错误码,预期如下: | 对象 | 安装前需确认 | 安装后的预期 | |---|---|---| | 指定 marker | POSIX 与 Foundation 查询都能看到 | POSIX 失败且 errno 为 2,Foundation 返回 NO | | 普通文件 | 可以正常读取 | 仍按原接口返回 | **它绕过的是这些接口上的精确路径规则。** `open/openat`、目录枚举、路径别名、其他符号变体和已加载镜像仍需分别研究。若检测器实际走的是 `openat`,给这份脚本多加十条路径也不会补上调用路径的缺口。 ### 2.3 对照探针:检查查询是否还保留基本语义 一张黑名单只能问“这些对象在不在”。加入自己掌握答案的对照,才能进一步检查查询通道。 文件实验可以准备一个刚创建并确认内容的文件,再准备一个不存在的路径。全部返回 `false` 会破坏阳性样本,全部返回 `true` 会破坏阴性样本。 Scheme 也能做类似实验:用自有接收 App 注册一个 Scheme,观察 App 正确声明并查询它;再查询一个已声明、测试环境中没有处理者的 Scheme。 | 阳性查询 | 阴性查询 | 需要检查什么 | |---|---|---| | true | false | 符合这组对照预期 | | false | false | 安装、声明或过宽的否定返回 | | true | true | 阴性样本失效或过宽的肯定返回 | | false | true | 样本、配置与查询链是否正确 | Apple 对 `canOpenURL:` 的查询声明有明确要求[2]。未声明而返回 `false`,不能直接归因于 Hook;名字叫 `dummy://` 的 Scheme,也没有永远无人注册的保证。 这组对照能暴露粗粒度改写,却未必发现只处理指定输入的修改。它把对抗问题从“返回一个期望值”,推进到“保持正常接口语义的同时影响指定查询”。 #### 动手:保留正常 Scheme,只改变目标查询 对应的代码只处理选定 Scheme,阳性与阴性对照仍走原实现: ```javascript const schemes = new Set(['cydia', 'sileo', 'riskdemo-jb']); const method = ObjC.classes.UIApplication['- canOpenURL:']; Interceptor.attach(method.implementation, { onEnter(args) { this.hide = false; if (args[2].isNull()) return; const scheme = new ObjC.Object(args[2]).scheme(); if (scheme !== null) this.hide = schemes.has(scheme.toString().toLowerCase()); }, onLeave(result) { if (this.hide) result.replace(0); } }); ``` 实验时用自有接收 App 注册 `riskdemo-jb` 与另一个正常 Scheme,观察 App 在 `LSApplicationQueriesSchemes` 中声明两者。先确认两个查询都返回 YES,再安装脚本:目标应变为 NO,正常 Scheme 应保持 YES。这样才证明选择性改写,而不是碰巧查询了一个本来不存在或没有声明的 Scheme。 这能避开“全部返回 NO”造成的简单对照异常,却不会卸载处理者,也不覆盖实际打开 URL 的路径或其他证据。 ### 2.4 文件隐藏之后,服务还在不在 这是路径检测之外很值得研究的一层:安装文件被隐藏了,已经启动的守护进程却可能继续提供服务。 观察可以拆成三个步骤:当前命名空间能否查询到名称,能否取得可用端口,使用约定协议后能否得到有效应答。 ```mermaid flowchart TB A[当前观察进程] --> B[文件查询] A --> C[读取可访问的启动配置] A --> D[命名服务查询] B --> E[路径存在、缺席或查询失败] C --> F[程序路径、参数与服务声明] D --> G[取得端口、无结果或调用失败] G --> H[权限与协议条件成立时交互] H --> I[有效应答、拒绝或无应答] ``` plist 内容能把这几个观察面连接起来。 | 字段 | 可以帮助核对什么 | |---|---| | `Label` | 配置声明的任务标识 | | `Program` / `ProgramArguments` | 启动哪个程序,传入哪些参数 | | `EnvironmentVariables` | 为该任务设置了什么环境 | | `MachServices` | 声明了哪些命名服务 | 配置声明不等于服务已经运行,服务运行也不等于任意 App 都有权访问。bootstrap 命名空间、沙盒规则、entitlements 和接口可用性,共同决定结果。macOS 上的具名 XPC 示例不能直接套成普通 iOS App 的能力。 配置读取还有一层容易遗漏:原始数据可以经过系统 plist 解析器,也可以先由文本扫描提取 XML 片段。`NSScanner` 一类路径与属性列表解析器不是同一个修改位置。只改变某个解析器返回,未必覆盖实际读者;文本扫描本身则仍要处理格式、转义和二进制 plist 等问题。 一个有价值的对抗实验,是在同一观察进程里保持服务运行,只改变文件查询,再比较服务通道。这样得到的结论比“路径已隐藏,所以环境已隐藏”具体得多。 #### 动手:让一次指定的服务查询返回未找到 若已经确认检测使用 `bootstrap_look_up` 查询某个服务,可以在这一层处理名称与输出端口。下面用自有服务名演示,按实验中实际查询的名称替换即可。 ```javascript const services = new Set(['com.example.iosriskdemo.service']); const address = Module.getGlobalExportByName('bootstrap_look_up'); const types = ['uint', 'pointer', 'pointer']; const original = new SystemFunction(address, 'int', types); Interceptor.replace(address, new NativeCallback(function (port, name, out) { let service = null; try { service = name.readUtf8String(); } catch (_) {} if (services.has(service) && !out.isNull()) { out.writeU32(0); // MACH_PORT_NULL return 1102; // BOOTSTRAP_UNKNOWN_SERVICE } const result = original(port, name, out); this.errno = result.errno; return result.value; }, 'int', types)); ``` 这与 POSIX 文件查询有个关键区别:`1102` 是 bootstrap 返回码,不能拿 `ENOENT` 填进去充数。由于命中时没有调用原函数,也就没有先获取一个 Mach send right 再丢弃的问题。 它可以改变通过该函数发起的后续名称查询,但拿不到已经缓存的端口,也不会关闭已建立的 XPC 连接。验证必须从“相同观察进程原本能查到服务”开始;受签名或命名空间限制,本来就查询失败的环境不构成成功绕过。 ### 2.5 沙箱可写性:隐藏了路径,为什么写入探针还会继续 前面的检测都在问“某个已知对象在不在”。写入探针换了一个问题:**我现在能不能创建文件、写进数据,再把它映射到内存?** 隐藏包管理器路径,没有直接改变这组操作。 这里必须分开两种场景:向当前应用本来无权写入的位置尝试创建文件,以及在自己的 `Documents` 中完成文件读写。两者可以使用相似的 API,却不能得出同一个结论。 | 探测位置与操作 | 成功说明什么 | 能否据此认定越狱 | |---|---|---| | 当前应用容器外、正常权限不应允许写入的位置 | 此次进程与签名条件下取得了额外写入能力 | 需要正常设备、权限与路径解析对照 | | 自己的 Documents 内创建、写入文件 | 正常应用存储能力可用 | 不能 | | 自己文件的 `PROT_READ \| PROT_WRITE`、`MAP_SHARED` 映射 | 此次文件映射成功 | 不能;它不是可执行映射,也不天然越过沙箱边界 | | 写入或映射失败 | 某个具体步骤失败 | 不能直接推出“沙箱安全”或“环境干净” | 沙箱外也不能简单按 `/private` 这个前缀判断,因为应用容器的规范化路径本来就可能位于 `/private/var/...`。要核对的是目标与当前容器、访问授权的关系。 一条典型的文件映射采样链如下: ```mermaid flowchart TB A[确定目标路径] --> B[创建或打开文件] B --> C[写入并刷新缓冲] C --> D[取得文件描述符] D --> E[共享读写映射] E --> F[记录成功或失败阶段] F --> G[解除映射并清理自建文件] ``` 因此,即便某个实现把“映射成功”写进了风险位,也要分开描述**程序如何编码结果**和**这个结果是否能证明越狱**。没有正常设备的对照,不能把 Documents 可写称为“最强越狱判据”或“无法绕过”。 #### 动手:先做一个能区分失败阶段的探针 以下代码可放入自有 App 的 `.m` 文件。它保留创建、写入、映射这条链;为了不覆盖旧文件,使用 `open(O_EXCL)` 创建,再交给 `fdopen` 取得文件流。只有本次创建成功的文件才会被清理。 ```objc #import <Foundation/Foundation.h> #import <sys/mman.h> #import <fcntl.h> #import <unistd.h> #import <stdio.h> #import <errno.h> // 1=映射成功;-1=创建失败;-2=文件流失败;-3=写入/刷新失败;-4=映射失败。 static int ProbeWriteMap(const char *path) { int fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0600); if (fd < 0) return -1; FILE *file = fdopen(fd, "w+"); if (file == NULL) { int saved = errno; close(fd); unlink(path); errno = saved; return -2; } const char data[] = "demo"; const size_t length = sizeof(data) - 1; int stage = -3; if (fwrite(data, 1, length, file) == length && fflush(file) == 0) { void *mapped = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (mapped == MAP_FAILED) { stage = -4; } else { stage = 1; munmap(mapped, length); } } int saved = stage < 0 ? errno : 0; fclose(file); if (unlink(path) != 0) NSLog(@"probe cleanup failed: %d", errno); errno = saved; return stage; } static void RunWriteMapDemo(void) { NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/ios-risk-demo-write-probe"]; int stage = ProbeWriteMap(path.fileSystemRepresentation); int error = errno; NSLog(@"write-map stage=%d errno=%d", stage, error); } // 在实验按钮里调用 RunWriteMapDemo(); ``` 正常容器里,这组读写映射应具有成功的条件;若失败,要保留阶段和 `errno`,例如旧文件未清理导致的 `EEXIST`,不能混成“检测到沙箱限制”。`fflush` 保证映射前的内容已经从 stdio 缓冲写到文件,避免把缓冲问题误读成安全机制。 #### 动手:只让指定探测文件的映射失败 对于通过 `mmap` 返回值决定该项记录的实现,可以在映射这一层介入。下面代码面向 64 位 iOS:根据文件描述符取得真实路径,仅处理自有 Demo 的共享读写映射。 ```javascript if (Process.pointerSize !== 8) throw new Error('This mmap example requires 64-bit iOS'); const homeDirectory = new NativeFunction( Module.getGlobalExportByName('NSHomeDirectory'), 'pointer', []); const directory = new ObjC.Object(homeDirectory()) .stringByAppendingPathComponent_('Documents') .stringByResolvingSymlinksInPath().toString(); const target = directory + '/ios-risk-demo-write-probe'; const fcntl = new NativeFunction(Module.getGlobalExportByName('fcntl'), 'int', ['int', 'int', '...', 'pointer']); const errorPointer = new NativeFunction( Module.getGlobalExportByName('__error'), 'pointer', []); const mmapAddress = Module.getGlobalExportByName('mmap'); const types = ['pointer', 'ulong', 'int', 'int', 'int', 'int64']; const original = new SystemFunction(mmapAddress, 'pointer', types); Interceptor.replace(mmapAddress, new NativeCallback( function (address, length, protection, flags, fd, offset) { const forward = () => { const result = original(address, length, protection, flags, fd, offset); this.errno = result.errno; return result.value; }; // 只处理文件支撑的共享读写映射;匿名映射、其他权限和 fd 透传。 if (fd < 0 || (flags & 1) === 0 || (flags & 0x1000) !== 0 || protection !== 3) return forward(); const previousErrno = this.errno; const buffer = Memory.alloc(1024); // Darwin MAXPATHLEN const ok = fcntl(fd, 50, buffer) === 0; // F_GETPATH const path = ok ? buffer.readUtf8String() : null; errorPointer().writeS32(previousErrno); // 不让辅助查询污染原 mmap 的错误状态 if (path !== target) return forward(); this.errno = 13; // Darwin EACCES return ptr(-1); // MAP_FAILED,不是 NULL }, 'pointer', types)); ``` 这里有三个容易写错的细节。`mmap` 失败返回 `MAP_FAILED`,不是空指针;`fcntl` 是变参函数,Frida 的参数列表中显式放了 `...`;辅助的 `F_GETPATH` 查询不能污染未匹配调用的错误状态[10]。路径也先解析符号链接,减少 `/var` 与 `/private/var` 别名带来的不匹配。 先运行探针确认成功,再加载这段脚本重新触发,预期从 `stage=1` 变为 `stage=-4 errno=13`,创建与写入阶段仍然经过原实现。这证明的是**指定映射结果被改变**,并没有改变系统沙箱权限,也没有验证任何服务端风控结果。 如果目标使用 `fopen("w+")` 起步,也可以沿用 §2.2 的精确路径过滤,在打开阶段返回空指针。但两种介入不能混为一谈:拒绝打开会跳过后续步骤,拒绝映射则保留先前的写入和清理路径。失败究竟代表“未命中”还是另一种异常,要根据具体分支决定。Foundation 的 `writeToFile:…`、`openat` 或其他调用路径也不能默认都被同一个 Hook 覆盖。 ### 2.6 环境变量:改了 getenv,另一条读取路径还在 文件路径看不到了,不代表启动时带入进程的信息也消失了。加载配置、工具工作目录、运行模式等,都可能通过环境变量提供线索。 这里有一个容易漏掉的区别:`getenv("某个名称")` 是查询函数,`environ` 是环境变量字符串表。修改查询函数的返回,并没有自动改掉表里的内容;检测器还可能在更早的时候复制过它。 | 读取位置 | 实际拿到什么 | 只 Hook getenv 的影响 | |---|---|---| | `getenv` | 指定名称的当前查询结果 | 可以改变经过该函数的调用 | | 直接遍历 `environ` | 当前环境变量表中的 `名称=值` | 不会因为 getenv 返回变化而自动改变 | | 之前保存的副本 | 某次采样时复制的字符串 | 不会自动刷新 | #### 动手:让三个读者观察同一个虚构变量 以下自建示例只使用 `IOS_DEMO_FLAG`,不对应任何实际工具或应用。把代码放进实验 App 的 `.m` 文件,所有操作由同一串行实验入口触发,避免一边遍历、一边修改环境变量表。 ```objc #import <Foundation/Foundation.h> #import <stdlib.h> #import <string.h> extern char **environ; static char *savedDemoEnvironment; static BOOL PrepareEnvironmentDemo(void) { free(savedDemoEnvironment); savedDemoEnvironment = NULL; if (setenv("IOS_DEMO_FLAG", "1", 1) != 0) return NO; const char *value = getenv("IOS_DEMO_FLAG"); if (value == NULL) return NO; savedDemoEnvironment = strdup(value); return savedDemoEnvironment != NULL; } static BOOL ReadDemoEnvironmentTable(void) { const char prefix[] = "IOS_DEMO_FLAG="; for (char **entry = environ; entry != NULL && *entry != NULL; ++entry) { if (strncmp(*entry, prefix, sizeof(prefix) - 1) == 0) return strcmp(*entry + sizeof(prefix) - 1, "1") == 0; } return NO; } static void ObserveEnvironmentDemo(void) { const char *value = getenv("IOS_DEMO_FLAG"); BOOL throughAPI = value != NULL && strcmp(value, "1") == 0; BOOL throughTable = ReadDemoEnvironmentTable(); BOOL throughCopy = savedDemoEnvironment != NULL && strcmp(savedDemoEnvironment, "1") == 0; NSLog(@"api=%d table=%d copy=%d", throughAPI, throughTable, throughCopy); } static void RemoveCurrentDemoEnvironment(void) { if (unsetenv("IOS_DEMO_FLAG") != 0) NSLog(@"unsetenv failed"); // 有意保留旧副本,用于区分当前环境与历史采样。 } static void FinishEnvironmentDemo(void) { RemoveCurrentDemoEnvironment(); free(savedDemoEnvironment); savedDemoEnvironment = NULL; } // 安装 Hook 前:if (PrepareEnvironmentDemo()) ObserveEnvironmentDemo(); // 安装 Hook 后:ObserveEnvironmentDemo(); // 再移除当前变量:RemoveCurrentDemoEnvironment(); ObserveEnvironmentDemo(); // 实验结束:FinishEnvironmentDemo(); ``` 准备完成后,三个读者的预期结果都是 1。再通过 attach 安装下面这段: ```javascript const address = Module.getGlobalExportByName('getenv'); const original = new SystemFunction(address, 'pointer', ['pointer']); Interceptor.replace(address, new NativeCallback(function (name) { let key = null; try { key = name.readUtf8String(); } catch (_) {} if (key === 'IOS_DEMO_FLAG') return ptr(0); const result = original(name); this.errno = result.errno; return result.value; }, 'pointer', ['pointer'])); ``` 这会让指定查询返回空指针,其他名称仍走原实现。然后在 App 的实验入口中移除这个虚构变量,再观察一次: | 实验步骤 | getenv | 当前环境变量表 | 已保存副本 | |---|---|---|---| | 准备完成,未安装 Hook | 1 | 1 | 1 | | 只改变 getenv 返回 | 0 | 1 | 1 | | 再用 unsetenv 移除当前变量 | 0 | 0 | 1 | 表中是代码预期,需要设备验证。`unsetenv` 改变的是当前进程的环境状态,已经复制出去的数据不会因此消失;它也不会卸载此前根据环境配置加载的动态库。 因此,对抗要根据实际读者选择位置:过滤查询适用于走该接口的检测;改变环境状态影响后续读取;旧副本则要确认正常刷新条件。不要为了隐藏一个线索无差别清空环境,也不要在运行中的其他线程可能读取它时随意重写 `environ` 数组。 ### 2.7 藏掉第一条线索,为什么反而出现了第二条 假设某次实验只记录到文件查询,隐藏路径后却突然出现了环境变量或写入探针。先别急着判断检测“升级”了:原来的函数可能命中第一项就返回,后面的代码此前根本没机会执行。 越狱检测常见两种控制流。**首项命中即返回**只报告当前顺序中最早的结果;**逐项累积**则继续执行后续探针,最后再组合。相同的路径、环境和权限检查,放进这两种控制流,实验日志会很不一样。 ```mermaid flowchart TB A[同一组环境输入] --> B[首项命中即返回] A --> C[逐项累积] B --> D{路径项命中?} D -->|是| E[返回路径项,后续未执行] D -->|否| F[继续检查环境与能力] C --> G[分别采集路径、环境与能力] G --> H[保留各项结果,再合并] ``` 以下普通 JavaScript 用两个虚构因素展示区别,可以直接放进 Node.js 或浏览器控制台。名称只表示教学模型,没有实际查询设备: ```javascript function compareProbeFlow(filePresent, environmentPresent) { const firstTrace = []; function firstMatch() { firstTrace.push('file'); if (filePresent) return 'file'; firstTrace.push('environment'); return environmentPresent ? 'environment' : 'none'; } const firstResult = firstMatch(); const allTrace = ['file', 'environment']; const allResults = { file: filePresent, environment: environmentPresent }; return { firstResult, firstTrace, allResults, allTrace }; } console.log(compareProbeFlow(true, true)); console.log(compareProbeFlow(false, true)); ``` | 变化 | 首项命中即返回 | 逐项累积 | |---|---|---| | 两条线索都存在 | 返回 file,未执行环境检查 | 两项均记录为 true | | 只隐藏文件线索 | 返回 environment,环境检查开始执行 | 文件变 false,环境仍为 true | 第二行并不意味着文件 Hook 失效。在首项返回模型里,它成功让程序越过了第一道分支;在累积模型里,它只改变了一项,而最终的“任一命中”结果仍可能为真。§6.4 还会展示合并结果不变时,为什么连新记录都可能没有。 实际分析时,至少把一次探针的状态分成 **未执行、执行失败、执行且未命中、执行且命中**。配置关闭、平台不支持和被前一项短路,都属于没有取得本项结论;不能用一个 `false` 把它们与明确未命中混在一起。 ### 2.8 写入失败以后,程序究竟往哪走 §2.5 保留了创建、写入和映射的失败阶段,因为“让 API 失败”与“让检测通过”之间,还隔着调用者的分支。 以一次文件创建为例,相同的 `-1/EACCES` 可以被不同实现解释成三件事:目标确实受到访问限制;这条探针无法取得有效结论;进入备用检测。甚至还可能直接产生一条“检查异常”的记录。错误码本身没有替调用者作出这些选择。 | 干预层 | 仍然可能发生的动作 | 对抗后要看哪条分支 | |---|---|---| | 在打开前拒绝 | 后续文件写入没有发生,清理逻辑仍可能查询路径 | 打开失败是未命中、未知,还是检查异常 | | 在映射前拒绝 | 创建与写入已经发生,文件仍需关闭和删除 | 映射失败是否被单独编码 | | 在检测返回时改值 | 探针的 I/O、回调和缓存写入可能已经完成 | 调用者是否只消费返回值 | 还要区分**成功取得映射**与**成功访问映射内容**。`mmap` 返回地址,并没有证明随后读写每个字节都安全;例如访问完全超出文件末尾的映射页,可能触发 `SIGBUS`。因此,映射探针应记录文件长度、偏移、保护位、映射类型和是否真正访问过内容。 前面的 Demo 在映射前执行 `fflush`,文件长度覆盖了申请映射的示例内容,成功后立即解除映射,没有再访问其中的字节。它展示的是取得文件映射这一层的可干预性。不能把普通共享读写映射、可执行内存权限与沙盒外写入,合并成一个“沙箱可写”结论。 这一层的对抗重点是**保留没有改变的操作语义**:目标调用失败时返回正确的失败值,未匹配调用保留原错误状态,已获得的文件句柄、端口和映射仍按真实生命周期释放。否则,第二次运行产生的残留文件或资源泄漏,可能比第一条检测更早暴露问题。 ### 2.9 模拟器、签名与越狱,为什么要分开判断 越狱检测有时先做平台或签名判断,再决定运行哪些探针。这种前置分支必须一起看:在模拟器中得到“没有越狱风险”,可能只是相关检查被跳过,并没有对真机环境作出任何验证。 | 观察对象 | 可以说明什么 | 不能直接推出什么 | |---|---|---| | `hw.machine` 等机器属性 | 当前接口报告的硬件或运行环境描述 | 仅凭 arm64 证明是真机或未越狱 | | `isiOSAppOnMac` | 当前是否以 iOS App 形式运行于 Mac | 设备越狱、恶意模拟或自动化 | | 签名中的 `get-task-allow` | 当前签名声明了相应调试权限 | 存在恶意重签或已经绕过系统沙盒 | | App receipt 的位置与状态 | 安装、测试或交易环境的线索 | 名为 sandboxReceipt 就表示设备越狱 | | 进程实际获得的权限 | 指定操作在当前条件下能否成功 | 其他应用、其他操作也具备相同权限 | Apple Silicon 上的 iOS Simulator 可以执行 arm64 代码;开发签名、TestFlight、Mac 上运行 iOS App 等也有正常用途。判断必须结合该应用预期的分发与运行方式。 对抗时,修改机器名或收据查询可能改变前置分支,让某条检测不再执行。但它也可能触发另一套平台规则。正确的观察是“哪条分支改变、哪些探针跳过、调用者怎样解释结果”,而不是把一个平台属性伪装成功当成越狱环境已经消失。 ### 2.10 再往下一层:SVC 与 Direct Syscall 前面的路径实验还有一个前提:查询确实经过了被 Hook 的函数。如果检测器自行准备参数,再用 `svc #0x80` 进入内核,替换 `access`、`open` 或 `ptrace` 的 C 包装入口就可能完全收不到这次调用。 这就是 Direct Syscall 值得单独讨论的地方。**它绕开的是某条用户态包装路径,仍然受内核的权限与沙盒规则约束。** 在 iOS 上还应确认实际的 libSystem/libsystem_kernel 入口与符号别名,不能把一个导出名当成所有调用的必经之处。 ```mermaid flowchart TB A[检测逻辑] --> B[系统库包装函数] A --> C[自有代码准备系统调用] H[只替换系统库入口] -.-> B B --> D[包装函数中的 SVC] C --> E[自有代码中的 SVC] D --> K[内核分派与权限检查] E --> K K --> R[返回结果与错误状态] ``` #### 先区分函数名、系统调用号与陷入指令 公开 XNU 源码里的 ARM64 BSD 调用路径使用 `x16` 传递调用号,参数由约定寄存器传入;`svc` 的立即数 `0x80` 表示这条陷入入口,**不是 `open` 或 `ptrace` 的编号**[12]。不能照搬 Linux AArch64 使用 `x8` 的示例,也不能把其他架构的调用号编码直接移过来。 `stat`、`stat64`、`open`、`ptrace` 等名称可以帮助找到候选,但要落到目标系统的调用表、实际调用号、参数和结构体布局。C 包装函数还可能选择不同变体,或处理取消、错误转换等细节。仅在二进制里找到一条 `svc`,并不能证明它执行了哪项检测。 | 调用方式 | 单独替换某个 C 接口能否覆盖 | 还应检查哪里 | |---|---|---| | 导入或动态解析后调用该接口 | 调用确实到达被替换入口时可以 | 别名、变体与最终执行地址 | | 调用系统库的 `syscall()` 包装 | 替换 `open` 等上层接口未必覆盖 | `syscall` 自身的入口与参数搬运 | | 自有代码直接执行 SVC | 通常不经过上述包装入口 | 陷入前的调用号、参数与陷入后的分支 | 错误返回尤其容易混淆。XNU 的 ARM64 BSD 返回路径在常规失败时设置 carry flag,并把错误号放进 `x0`;系统库包装随后才把它转换成调用者熟悉的 `-1` 与线程局部 `errno`[13]。直接执行 SVC 不会自动完成这次转换。把裸返回当作 POSIX 返回读取,或只在指令边界改 `x0` 却忽略条件标志,都可能使后续分支与输出互相矛盾。 #### 动手:同一文件,分别走包装函数与裸调用 这里继续使用 §2.2 的自有 marker 文件。下面的汇编只查询它的可访问性,不创建沙盒外文件,也不调用拒绝附加操作。 在自有 Demo 工程中加入一个 `DemoRawAccess.S`,内容全部如下。它面向 iOS 设备的 ARM64 BSD ABI;`SYS_access` 从当前 SDK 头文件取得,并应与目标系统核对。裸系统调用不属于可假定跨版本稳定的 iOS SDK 接口,本例未进行 Apple SDK 编译或设备运行。 ```asm #include <sys/syscall.h> .text .p2align 2 .globl _DemoRawAccess _DemoRawAccess: // x0 = path,x1 = mode;本例调用时 mode 为 F_OK(0)。 mov x16, #SYS_access svc #0x80 b.cc 1f neg x0, x0 // 本示例自行约定:失败返回负的错误号。 1: ret ``` 在 §2.2 的同一个 `.m` 文件中加入以下观察入口: ```objc #import <Foundation/Foundation.h> #import <unistd.h> #import <errno.h> extern long DemoRawAccess(const char *path, int mode); static void CompareAccessRoutes(void) { NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/ios-risk-demo-marker"]; errno = 0; int wrapped = access(path.fileSystemRepresentation, F_OK); int wrappedError = wrapped == -1 ? errno : 0; long raw = DemoRawAccess(path.fileSystemRepresentation, F_OK); int rawError = raw < 0 ? (int)-raw : 0; NSLog(@"wrapped=%d errno=%d raw=%ld rawError=%d", wrapped, wrappedError, raw, rawError); } // 先调用 §2.2 的 PreparePathDemo(),确认 marker 已创建。 // 安装该节路径 Hook 前后,分别调用 CompareAccessRoutes()。 ``` | 条件 | 包装函数的预期结果 | 自有裸调用的预期结果 | |---|---|---| | 文件存在且可访问,未安装 Hook | 0 | 0 | | 文件仍在,只安装 §2.2 的路径 Hook | -1,errno 为 ENOENT | 0 | 第二行解释了“Hook 明明命中,另一条检测仍然发现文件”:前一条读者看到了被修改的包装函数返回,后一条读者仍在请求内核查询真实对象。这是待设备验证的调用路径对照,不是某个商业实现的复现。 反过来,如果文件本来就不存在,两条路径都可能失败。本例将裸调用失败转换成 `-错误号`,没有设置 POSIX `errno`;必须先统一错误语义,再比较是否一致。路径、权限、采样时机和结构体布局不一致造成的差异,也不能直接归为 Hook。 #### 对抗位置下移,不等于必须修改内核 确认直接系统调用后,继续增加 `open`、`stat` 的路径关键词并不能扩大覆盖。用户态仍可以研究**承载 SVC 的实际封装函数**、它的调用者,以及返回后形成判断的分支;在授权环境中,指令级跟踪也可以用来观察调用号、实参和结果。这里要定位的是实际执行路径,而非仅扫描所有 SVC 后统一改写。§3.9 的原生函数入口示例说明了同一原则。 如果要在更下层交叉核对,内核态观测可以回答:当前进程究竟提交了哪些 BSD 系统调用、从哪里陷入、内核实际返回了什么。通过系统库和自有 SVC 发起的请求,可以在共同的内核分派路径汇合;按进程、线程、调用号、参数、返回状态和时间关联,才有机会分清用户态日志缺失与请求确实没有发生。 不过,**XNU 源码里存在跟踪或审计路径,不等于普通 iOS App 可以使用它们**。调试内核、受支持的研究设备或虚拟化环境,需要各自具备相应观测条件;越狱或 root 身份也不自动意味着能够任意插桩内核。观测到原始结果,和改变原始结果,更是两种能力。本文没有实现内核 Hook,也不把 macOS 上的跟踪工具直接当成 iPhone 上可用的方案。 Direct Syscall 因而补全了前面示例的边界:**替换一个导出函数,只覆盖到达该入口的调用。** 它能够提高部分用户态对抗的成本,但不能仅凭采用 SVC 就宣称检测不可绕过,更不能据此推断商业风控的普遍采用比例。 到这里,越狱检测已经可以分成对象可见性、实际能力、查询路径和控制流四层。接下来把视线移进进程,Frida 检测还会继续问:工具留下了什么,代码又发生了什么变化。 ## 三、Frida 与反 Hook:藏住工具,不等于覆盖了修改 ### 3.1 先说明 Frida 是怎样进入进程的 讨论“能不能检测 Frida”,最好先说清楚测试的运行方式。同一个工具,在不同加载方式和时机下,留下的痕迹并不相同。 | 实验方式 | 主要差异 | 需要关注的检测时段 | |---|---|---| | server 配合 attach | 在目标已经运行后介入 | 介入前是否已有采样、缓存和初始化 | | server 配合 spawn | 在更早的启动阶段介入 | 加载器、初始化与探针安装的先后 | | Gadget 随应用加载 | 运行时组件成为应用加载过程的一部分 | 镜像、加载配置以及可能变化的签名身份 | | 定制构建或加载方式 | 名称、布局和部分运行特征发生变化 | 规则依赖的是名称、协议还是代码改动 | attach 与 spawn 首先是介入时机的区别,不是两套天然不同的“隐身能力”。Gadget 也不能直接套用某个 server 默认端点的检测假设。 这一步看似只是交代环境,实际上决定了后面的阴性结果有没有意义:探针没有发现它本来就没有机会观察的对象,不能据此证明整体不可检测。 ### 3.2 第一层看痕迹:名称、镜像、线程与端点 `frida-server`、`gum-js-loop`、`gmain`、`gdbus` 等名称,是最容易理解的一类入口。它们来自具体实现与配置,适合定位线索,却不适合作为全部结论。 | 检测面 | 观察什么 | 常见对抗影响什么 | 尚未回答的问题 | |---|---|---|---| | 文件与进程名称 | 特定名称、路径或安装物 | 改名、改变布局影响名称匹配 | 运行中的其他痕迹是否变化 | | 加载镜像 | 动态库名称、地址范围与来源 | 过滤某条枚举路径影响该读者 | 方法、指针与实际代码是否变化 | | 线程 | 名称及可取得的线程信息 | 改变名称或查询结果影响对应规则 | 正常 GLib 线程如何排除 | | 本机端点 | 服务可达性与协议响应 | 端点配置改变该条观察前提 | 其他加载形态是否有相同端点 | | 运行时修改 | IMP、导入槽、目标指令 | 取决于实际 Hook 位置 | 工具改名是否改变了这些位置 | 对抗到这一层,最容易产生一个错觉:某份脚本查不到 Frida 字样,便认为已经处理完检测。事实上,这只验证了那份脚本的名称规则。下一层完全可以不关心工具叫什么,直接观察函数有没有被改变。 检测方也需要正常对照。GLib 不是 Frida 专属,诊断库可以来自 Xcode,合法 SDK 也可能改写方法。来自同一次注入的文件、镜像和线程线索,还应保留共同原因,避免重复计成多份独立证据。 ### 3.3 第二层看修改:IMP、导入指针和机器码 一个地址属于系统库,不能独立证明这次调用没有被改变。先把常见调用方式画出来: ```mermaid flowchart LR A[Objective-C 方法调用] --> B[当前 IMP] C[导入函数调用] --> D[调用方间接指针] E[动态符号解析] --> F[解析器返回指针] B --> G[目标实现代码] D --> G F --> G G --> H[实际执行路径] classDef point fill:#fff2d8,stroke:#ad7b24,color:#583d11 class B,D,G point ``` 方法名不变,IMP 可以被替换;`dlsym` 找到原始实现,业务调用使用的导入槽却可能指向别处;地址和指针都没变,原实现入口的指令也可能已经改变。 | 修改方式 | 具体改变的位置 | 检测入口 | 容易产生的漏检 | |---|---|---|---| | Objective-C 方法替换 | 方法到 IMP 的映射 | `class_getInstanceMethod`、`method_getImplementation`、`dladdr` | 只比较原实现代码,没有读当前方法映射 | | 导入重绑定 | 指定调用方的间接符号指针 | 实际调用点及其当前目标 | 只重新调用 `dlsym`,没有检查调用方 | | Inline 修改 | 函数入口或函数体指令 | 代码区域、分支与跳转目标 | 只看原地址是否仍位于系统镜像 | 由此可以解释一类常见现象:`dladdr` 报告系统镜像,而函数执行却已经转向其他位置。传入的原地址确实在系统镜像里,地址来源检查没有回答它之后执行了什么。 对抗也要与位置匹配。只处理 IMP 检查,不等于处理了入口指令检查;只改变镜像枚举,不会自然恢复调用方指针。验证时,把“解析到谁、调用点指向谁、实际执行到谁”对应起来,比单独打印一个函数地址有用。 这里仍需要区分正常行为:开发者可以合法交换方法,系统包装函数与编译器跳板可以包含分支。首条指令是跳转,并不是完整的 Hook 判据。 #### 动手:先绕过一个明确的 IMP 检测返回 面对 IMP、导入槽、Inline 三种观察,先选择一个已经定位的检测器。下面是一个可以直接放进自有 App 的完整小类:先保存 IMP,再替换方法,最后检查当前映射。 ```objc #import <Foundation/Foundation.h> #import <objc/runtime.h> @interface IOSRiskDemo : NSObject + (NSString *)demoValue; + (NSString *)replacementValue; + (void)captureIMPBaseline; + (void)installDemoSwizzle; + (BOOL)hasUnexpectedIMP; + (BOOL)cachedIMPResult; @end static IMP demoExpectedIMP; static BOOL demoCachedResult; @implementation IOSRiskDemo + (NSString *)demoValue { return @"original"; } + (NSString *)replacementValue { return @"changed"; } + (void)captureIMPBaseline { demoExpectedIMP = method_getImplementation( class_getClassMethod(self, @selector(demoValue))); } + (void)installDemoSwizzle { method_setImplementation(class_getClassMethod(self, @selector(demoValue)), method_getImplementation(class_getClassMethod(self, @selector(replacementValue)))); } + (BOOL)hasUnexpectedIMP { if (demoExpectedIMP == NULL) [NSException raise:@"DemoSetup" format:@"Capture IMP before swizzling"]; BOOL changed = method_getImplementation( class_getClassMethod(self, @selector(demoValue))) != demoExpectedIMP; demoCachedResult = changed; return changed; } + (BOOL)cachedIMPResult { return demoCachedResult; } @end // 在安装 Hook 前的按钮中调用: // [IOSRiskDemo captureIMPBaseline]; // [IOSRiskDemo installDemoSwizzle]; // NSLog(@"before=%d", [IOSRiskDemo hasUnexpectedIMP]); ``` 这里的最小对抗可以直接发生在检测返回处: ```javascript const method = ObjC.classes.IOSRiskDemo['+ hasUnexpectedIMP']; Interceptor.attach(method.implementation, { onLeave(result) { result.replace(0); } }); ``` 先保存基线,再用 Demo 的方法替换制造一个已知变化,确认检测返回 YES;安装这段 Hook 后,同一次检测的调用者应得到 NO。这就是一个具体的返回值绕过,代码没有假装把原 IMP 恢复了。 但例子故意保留了 `demoCachedResult = changed`。原方法先执行并写入缓存,`onLeave` 才改返回,所以还要同时读取缓存。§6.2 会展示为什么“检测返回 NO”和“历史风险记录清空”是两回事。 若另一个检测器检查此检测方法自身的代码,这段 Hook 也可能被看到。把 `dladdr` 全部伪造成系统库来源,或把所有 IMP 都替换成同一个地址,不会解决独立的指针与代码比较。针对哪条检测改哪层,并记录剩余观察,才是这类示例可迁移的部分。 ### 3.4 换一条解析路径,究竟绕开了什么 某些检测会通过动态解析取得系统函数,而不是一直使用某个固定导入槽。更深入的实现还可能研究私有 dyld 入口,例如 `__dyld_func_lookup` 一类路径。 它们的价值在于提供另一条取址方式:如果只修改了调用方的导入槽,另一条解析路径可能不受同一修改影响。 但如果多条解析路径最终都到达同一份被修改的代码,换解析器仍会碰到共同目标。这就是为什么“动态解析可以避开某种重绑定”与“动态解析不受 Hook 影响”不是同一个结论。 私有入口的名称、可用性与语义需要按系统版本验证;ASLR、共享缓存和 arm64e 指针认证,也会影响实际地址处理。未验证的地址掩码与旧式导入表遍历,不适合直接当成跨版本实现。 如果调用方进一步直接执行 SVC,问题就从“通过哪条路径取得函数地址”,变成了“是否还经过系统库包装”。两者的对抗位置不同,详见 §2.10。 ### 3.5 初始化入口:目标函数之外的研究方向 Mach-O 的初始化入口提供了另一个研究方向。传统布局可以包含 `__mod_init_func` 数组;检查时应按实际 Mach-O 的段、节和加载信息定位,不能固定假设它总在 `__DATA`,也不能假设所有初始化都通过同一种布局表达。随后才能核对入口指向哪些代码、所属镜像是否符合预期。 这里的增量是把观察从某个目标函数扩展到加载与初始化过程。对抗是否覆盖一个业务函数,与是否覆盖初始化相关观察,是不同问题。 正常动态库本来就有初始化函数。要写成有效检测,还需要给出实际遍历、比较条件、执行时机与正常基线。本节只提出观察位置,没有给出经过验证的识别器;仅发现初始化相关名称或框架符号,不足以判断注入。 ### 3.6 名字改了,为什么本机服务仍可能暴露它 假设工具的文件名和进程名都已经改变,但它仍在原来的地址接收连接,并按原来的协议回答请求。此时,查询名字的规则可能失效,主动连接的规则却仍有机会取得线索。 `127.0.0.1` 是设备自己的 IPv4 回环地址。向它发起连接,是在询问本机服务。这里要分清三层:**端口能连上、服务会回答、回答符合某种协议特征**。越往后,判断越具体,需要验证的条件也越多。 | 观察层次 | 检测在问什么 | 能得到什么 | 还缺什么 | |---|---|---|---| | 连接探测 | 这个端点能否建立连接 | 当前端点的可达性 | 监听者身份与用途 | | 协议交互 | 发出约定请求后,是否返回可解析的应答 | 服务支持某种交互的线索 | 协议版本、完整匹配条件与正常服务对照 | | 特定功能请求 | 某个 HTTP 路径是否表现出约定功能 | 更具体的服务行为线索 | 状态码、响应内容和该功能的实际归属 | 以 Frida 排查中常见的认证协商思路为例,可以研究带行结束符的 `AUTH` 请求与 `REJECT` 类响应标记。重点是观察一次有上下文的交互:连接是否成功、发送了哪些字节、回包是否完整、哪条规则成立。认证被拒绝不妨碍探针获得协议线索;它要识别的是应答方式,未必需要登录成功。 不过,`AUTH` 与拒绝应答不是 Frida 独占的词。静态材料里出现 `REJECT`,也没有证明真实回包就只有这个词,更没有给出完整认证流程。将它写成 Frida 检测,需要补上具体版本、端点配置、请求与响应的对应关系,并检查其他协议兼容服务是否也会命中。不同 Frida 部署方式,包括不同配置的 server 与 Gadget,也不能默认暴露相同端点。 HTTP 探测则应单独理解。`GET / HTTP/1.0` 配合本机 Host,可以用来观察是否存在 HTTP 响应者;请求某个控制接口路径,可以进一步观察功能特征。但返回一个网页不等于发现 Frida,路径里带有录制或控制字样也不足以确定服务身份。**检测对象应由实际响应与协议行为确定。** 从对抗角度看,这几层也对应不同的作用范围: | 改变了什么 | 为什么检测结果可能变化 | 仍需观察什么 | |---|---|---| | 文件名或进程名 | 名称规则失去匹配对象 | 监听地址和协议应答可能保持原样 | | 监听地址、端口或连接方式 | 原定端点可能不再可达 | 服务是否仍被其他端点规则发现,进程内是否仍有插桩痕迹 | | 某条连接或接收调用的返回 | 使用该调用路径的探针看到不同结果 | 错误码、超时、其他调用路径与正常本机连接是否一致 | 因此,这一节适合做一个直观对照:在自有实验环境中,先记录原始连接与回包,再仅改变名称,最后改变端点配置。每一步分别展示名称查询、端点可达性与协议匹配结果。若只改名后应答仍相同,就能解释为什么改名没有覆盖这条检测;连接失败则只能说明本次未取得应答,不能推出进程中没有 Frida。 #### 动手:在握手开始之前,拦住指定回环连接 对于“连上端点 → 发送 AUTH 或 HTTP → 比较回包”的检测,最小对抗不必伪造每一种响应。若它依赖 `connect`,可以先让选定端点的连接返回拒绝,握手就不会通过这次连接发生。 关键在地址解析。Darwin 的 `sockaddr_in` 前两个字节是 `sin_len`、`sin_family`;不能照搬 Linux 的字段布局。端口是网络字节序,也不能直接按主机字节序读取。 ```javascript function darwinIPv4Endpoint(address, length) { if (address.isNull() || length < 16) return null; try { if (address.readU8() !== 16 || address.add(1).readU8() !== 2) return null; // sizeof(sockaddr_in)=16, AF_INET=2 const port = (address.add(2).readU8() << 8) | address.add(3).readU8(); const host = [4, 5, 6, 7].map(i => address.add(i).readU8()).join('.'); return { host, port }; } catch (_) { return null; } } const ports = new Set([27042]); // 示例候选;改成实际观察到的端口 const connectAddress = Module.getGlobalExportByName('connect'); const types = ['int', 'pointer', 'uint']; const original = new SystemFunction(connectAddress, 'int', types); Interceptor.replace(connectAddress, new NativeCallback(function (fd, address, length) { const endpoint = darwinIPv4Endpoint(address, length); if (endpoint && endpoint.host === '127.0.0.1' && ports.has(endpoint.port)) { this.errno = 61; // Darwin ECONNREFUSED,不能直接套 Linux 数值 return -1; } const result = original(fd, address, length); this.errno = result.errno; return result.value; }, 'int', types)); ``` **这里直接改变的是指定连接的结果。** 代码命中时不执行原 `connect`,依赖这次连接的握手也就拿不到应答;其他端点保留原返回与错误码。只有上层将这种失败解释为未命中,这次改动才构成该项规则的绕过。`27042` 是示例端口,实际应以服务的监听配置为准。 验证时至少比较目标回环端点、另一个正常回环端点和普通网络连接。目标应在安装前确实可达,安装后返回 `-1/61`;其余连接按原行为执行。再观察上层怎样解释失败:如果它把失败记成异常,或者改走另一条探测路径,阻止握手也不等于整体风控通过。 这个例子没有覆盖 IPv6 的 `::1`、Unix socket、`connectx`、直接系统调用或已有连接。也不建议全局改写 `strcmp` 去吞掉 `REJECT`:那会影响大量无关比较,而且真正的协议判据可能根本不经过该函数。 ### 3.7 调试状态:能附加上,不等于其他观察都正常 反调试回答的是另一组问题: | 观察 | 常见研究入口 | 需要区分的情况 | |---|---|---| | 当前进程调试状态 | `sysctl` 进程信息与 `P_TRACED` | 查询失败与有效状态值 | | 拒绝附加行为 | `ptrace` 的相关操作 | 阻止附加与发现当前调试是不同动作 | | 异常处理配置 | `task_get_exception_ports` | 调试器与正常崩溃处理 | | 线程调试状态 | 允许读取时的 `thread_get_state` | 硬件断点相关状态与其他插桩方式 | 中和拒绝附加,不会自动消除其他状态;隐藏一个状态位,也没有回答方法映射和代码是否改变。反过来,普通调试与 Frida 插桩也不必然产生完全相同的观测。 #### 动手:拒绝附加要在原函数执行前处理 下面是独立的 Darwin 示例:`PT_DENY_ATTACH` 的操作码为 31,其他请求透传。 ```javascript const address = Module.getGlobalExportByName('ptrace'); const types = ['int', 'int', 'pointer', 'int']; const original = new SystemFunction(address, 'int', types); Interceptor.replace(address, new NativeCallback(function (request, pid, addr, data) { if (request === 31) { this.errno = 0; return 0; } const result = original(request, pid, addr, data); this.errno = result.errno; return result.value; }, 'int', types)); ``` 这里不能只在 `onLeave` 把结果改成 0:原调用可能已经实施拒绝附加,或者导致被调试进程退出。替换要发生在原调用之前,安装也要早于这次操作;进程已经拒绝附加后,再尝试 attach 加载脚本可能根本进不去。 这个例子不修改 `sysctl` 的 `P_TRACED`,也不清除硬件断点和异常端口。对 `ptrace` 的替换自身也会改变代码,因此要分开记录无插桩、仅加载运行时和安装该替换后的状态。 ### 3.8 时机与基线:为什么同一脚本换个启动方式就不同 除了“改哪里”,还要问“什么时候改”。 若应用先保存了函数代码基线,随后才发生修改,前后比较可能看到变化;若基线第一次采样时已经受影响,后续一致只能说明没有进一步变化。attach 错过的初始化,也可能已经把环境结果写入缓存。 另一方面,观察工具本身会带来扰动。为了监控一次查询而安装 Hook,可能先改变了镜像、线程或函数入口。应用崩溃时,应先区分检测处置、Hook 实现错误、调用约定问题和时序变化,不能把所有崩溃都叫作“被反 Frida”。 比较不同介入方式时,至少保留三种状态:无插桩的正常运行、只加载运行时但不安装目标 Hook、安装单一目标 Hook 后的运行。它们能帮助定位变化发生在哪一步。 对抗效果也可以按层报告: | 本次改动 | 可以直接声称什么 | 不能由此代替的验证 | |---|---|---| | 改名或改变端点配置 | 指定名称或端点规则的结果变化 | 方法、指针与代码完整性 | | 过滤某条查询 | 使用该路径的读者看到不同结果 | 其他查询路径与正常对照 | | 修改某项检测返回 | 指定调用者取得不同返回值 | 状态副作用与旧缓存 | | 调整介入时机 | 某些初始化或采样先后发生变化 | 全生命周期与最终请求结果 | 这比一句“Frida 已隐藏”更精确,也更方便把方法迁移到其他版本:读者知道你处理了哪些观察,哪些仍然存在。 ### 3.9 Hook 确实命中了,但它是真正的检测入口吗 还有一种情况更容易让人误判:日志证明 Hook 已执行,返回值也符合预期,但你改到的是一个兼容接口,实际采集从另一条入口调用原生函数。 方法名只能帮助找候选。一个叫作“环境检查”的方法,可能是当前版本保留的空实现,也可能只是门面;不能因为它返回 NO,就认为整个进程没有其他检测。 #### 动手:把兼容接口与真实采集分开 下面是人为构造的路由模型。`legacyCheck` 故意返回 NO,`evaluate` 则通过初始化时保存的函数指针采样。所有名称都是示例名称。 ```objc #import <Foundation/Foundation.h> #import <dispatch/dispatch.h> #import <stdlib.h> #import <string.h> typedef int (*DemoProbeFunction)(void); static DemoProbeFunction volatile savedDemoProbe; __attribute__((noinline)) static int ReadRouterDemoEnvironment(void) { const char *value = getenv("IOS_ROUTER_DEMO_FLAG"); return value != NULL && strcmp(value, "1") == 0; } @interface ProbeRouterDemo : NSObject + (BOOL)legacyCheck; + (int)evaluate; + (void *)nativeProbeAddress; @end @implementation ProbeRouterDemo + (BOOL)legacyCheck { return NO; } + (int)evaluate { static dispatch_once_t once; dispatch_once(&once, ^{ savedDemoProbe = ReadRouterDemoEnvironment; NSLog(@"probe pointer initialized"); }); DemoProbeFunction current = savedDemoProbe; return current(); } + (void *)nativeProbeAddress { return (void *)ReadRouterDemoEnvironment; } @end // 实验按钮中先调用 setenv("IOS_ROUTER_DEMO_FLAG", "1", 1)。 // 确认 setenv 自身返回 0(设置成功),再执行下面两项查询。 // 再分别打印 [ProbeRouterDemo legacyCheck] 与 [ProbeRouterDemo evaluate]。 // 实验结束时 unsetenv("IOS_ROUTER_DEMO_FLAG")。 ``` 未干预时,兼容接口应为 0,实际采集应为 1。把兼容接口继续改成 0,对后者没有作用。再针对实际原生入口安装 Hook: ```javascript const demo = ObjC.classes.ProbeRouterDemo; if (!demo) throw new Error('Load ProbeRouterDemo before attaching'); const probeAddress = demo.nativeProbeAddress(); if (probeAddress.isNull()) throw new Error('Demo probe address is null'); Interceptor.attach(probeAddress.strip(), { onLeave(result) { result.replace(0); } }); ``` `nativeProbeAddress` 是自建 Demo 为展示地址提供的辅助入口,不是实际应用通常会有的接口。真实分析中需要通过调用关系和运行观察确认目标。这里使用 Frida 的 `strip()` 处理可能带有认证信息的代码地址,不手写跨版本地址掩码。 在支持这次 Hook 的设备上,下一次 `evaluate` 的调用者预期收到 0。这说明保存函数指针没有让目标实现免于改变;代码也没有改写函数指针槽,更不能据此宣称所有调用点检查都已绕过。 再看 `dispatch_once`:它只保存一次函数指针,`current()` 却在每次 `evaluate` 时执行。清除示例环境变量后重新采样,也应得到新的当前值。**初始化次数、检测执行次数、结果刷新次数,是三个不同的量。** 这类问题的定位顺序很直接:先确认被 Hook 的入口确实被目标流程调用,再确认谁消费它的返回,最后检查旁边是否还有独立采集。名称相似与日志命中,都不能替代这三步。 ## 四、行为篇:环境通过后,一次点击还会留下什么 ### 4.1 按钮回调执行,不等于经历了相同输入过程 即使环境探针都得到预期值,应用仍可以观察操作怎样发生。手指触屏、系统测试设施驱动操作、直接调用业务处理函数,可能从不同层进入。 ```mermaid flowchart TB A[手指或输入设备] --> B[系统事件处理] T[系统测试输入] -. 具体路径依实现 .-> B B --> C[UIKit 事件与触摸] C --> D[手势识别与控件动作] D --> E[业务回调] X[直接调用业务函数] --> E C -. 事件采样 .-> R[行为记录] D -. 控件采样 .-> R E -. 业务事件 .-> R M[运动传感器] -. 时间对齐 .-> R ``` 直接调用业务函数,不会自动补齐之前的输入事件;系统测试动作又可能经过正常事件分发。因此,有 UIKit 事件不等于已经证明真人操作,没有完整手势也要结合实际入口解释。 更低层的研究会涉及 `_hidEvent`、`IOHIDEventGetType`、`IOHIDEventGetEventFlags`、时间戳与子事件。HID 私有字段与访问条件需要逐版本核实,不能把特权工具能取得的事件,写成任意 App 都能稳定读取。 ### 4.2 坐标之外,检测还能读什么 | 维度 | 可观察内容 | 可以形成的分析 | |---|---|---| | 手势阶段 | began、moved、ended、cancelled | 开始、变化与结束是否对应 | | 时间 | 事件时间、持续时间、相邻间隔 | 动作节奏与重复性 | | 几何 | 位置、路程、方向转折、停顿 | 轨迹形态与当前视图的关系 | | 输入属性 | 触点数量、工具、设备支持的 force 等 | 是否符合当前输入方式 | | 业务上下文 | 页面、控件、前后台、事件队列 | 动作与业务记录如何关联 | | 传感器质量 | 空帧、不完整、恒定、分段缺失 | 采样是否覆盖了动作时间窗 | 例如,同一位置按下并在 80 毫秒后抬起,可以只有 began 和 ended,没有 moved。这是正常点击。用结束与开始时间计算时长、用采样点计算路程之前,应先保留正常取消、多指与设备能力差异。 给坐标加随机数,改变的是部分几何特征,未必改变输入来源、阶段顺序、时间节奏与页面上下文。检测可以分别比较来源与统计形态;对抗也应分别验证,不能把轨迹变弯当成整条行为链已经一致。 ### 4.3 传感器与计数缺口,要先排除采集问题 手机放在桌面或支架上,触摸时运动很小;队列延迟、后台切换和提前停止采样,也可能造成空帧或时间窗不完整。这些状态可以被记录,但不是自动化的直接证明。 关联触摸与 IMU 时,先统一时间源和单位,区分事件发生时间与回调处理时间。否则,正常的调度延迟也能制造出“触摸与运动对不上”的现象。 触摸数、控件回调数、业务埋点数和请求数也不应该默认相等。一次滑动会有多个事件,一次点击可能被本地校验拦下。只有定义了哪些手势对应哪些业务动作,计数缺口和异常顺序才有意义。 对抗只改点击次数,不能自动补齐事件;检测也不能拿未经业务定义的数量差异直接判异常。 ### 4.4 定位、截图与网络,提供补充上下文 从 iOS 15 起,`CLLocation.sourceInformation` 可以提供 `isSimulatedBySoftware` 与 `isProducedByAccessory`[3]。它们让检测可以在经纬度之外观察来源描述。 改坐标是否影响来源字段,需要单独验证;软件模拟标记为 `false` 不排除一切伪造,外设标记为 `true` 也可能来自正常 GPS 配件或 CarPlay。 截图通知、屏幕捕获状态、代理和 VPN 可以提供其他环境信息。其中 `UIApplication.userDidTakeScreenshotNotification` 发生在截图完成后[7],不是预先阻断接口。配置存在与实际连接路径也应分别观察,不能只凭其中一项认定业务操作有问题。 ## 五、补充:字体与资源保护,改变的是内容读取方式 除了识别环境和操作,应用还可以增加页面内容被直接提取的成本。这属于内容保护,与前面的越狱、Frida 检测分开理解即可。 屏幕上的 298,未必来自字符串中的普通数字。自定义字体可以把三个私用区码点画成 2、9、8;位图方案则直接用图片呈现。iOS 可通过 CoreText 注册与使用自定义字体[5]。 | 方式 | 直接读取可能得到什么 | 对抗研究的入口 | |---|---|---| | 自定义字形 | 编码字符 | 对应当前字符、字体版本与可见字形 | | 位图文字 | 图片或纹理 | 图像内容、排列、裁剪与缩放 | | 分帧呈现 | 某一时刻的画面 | 帧间关系、合成前资源与实际捕获路径 | | 加密资源 | 文件密文 | 解码结果、解析对象与绘制输入 | 分帧方案可以研究 `CADisplayLink` 驱动的图像切换,但期望帧率不等于实际帧率[6],双图交替也不自动证明 OCR 失败。无障碍与资源失败回退可能提供另一种内容表达;VoiceOver 不要求所有应用切成普通明文[8],回退也可能只是占位或错误提示。 字体注册、资源版本、失败阶段与耗时还可以形成渲染记录。它们是否进入风险判断,需要继续看消费者,不能仅凭字体 API 就称为设备指纹或签名因子。 ## 六、结果篇:为什么改了检测返回,请求还是没变 ### 6.1 查询、缓存与上报可能属于不同时间 回到开头的问题。假设自有 Demo 在启动时采样,把结果放进缓存,发请求时只读缓存。下面是这个模型的时间线,不是真机日志。 ```mermaid sequenceDiagram participant L as 应用生命周期 participant P as 环境探针 participant C as 结果缓存 participant R as 请求装配 L->>P: 启动时采样 P->>C: 保存结果 A Note over L,P: 此后改变查询实现 L->>P: 再次手动查询 P-->>L: 得到结果 B L->>R: 发起业务请求 R->>C: 读取已保存结果 C-->>R: 仍返回 A Note over C,R: 刷新条件尚未发生 ``` 手动调用探针得到了 B,证明这次查询改变了;请求还使用 A,可能只是因为没有重新采样。 另一种实现持续采样,却只在变化时上报。没有新报告,也不代表没有执行检测。行为记录经过队列、过滤、持久化与批量组装后,发生时间与发送时间更不能直接等同。 `dispatch_once` 也要看它初始化了什么:一次性创建函数指针表,完全可以在后续反复调用探针;只有明确保存并复用结果,才能谈结果缓存。 ### 6.2 检测函数可能不只有一个返回值 一个函数可以返回布尔值,同时通过回调更新风险记录。只修改返回值,并没有自然消除这些副作用。界面读取的状态与报告读取的历史数据,也未必是同一份。 §3.3 的 IMP Demo 可以直接观察这种分离。在安装那一节的返回值 Hook 之后,再执行: ```objc BOOL returned = [IOSRiskDemo hasUnexpectedIMP]; BOOL cached = [IOSRiskDemo cachedIMPResult]; NSLog(@"returned=%d cached=%d", returned, cached); ``` 代码预期为 `returned=0 cached=1`:原检测发现变化并保存 YES,Frida 在返回时让调用者收到 NO。要让之后的采样与记录也反映预期状态,需要回到当前记录的生产和刷新路径;只继续修改更多界面返回,并没有改变这份缓存。 | 改动位置 | 直接影响 | 还需确认 | |---|---|---| | 原始查询 | 使用该路径的后续采样 | 其他来源与已有缓存 | | 检测返回 | 消费该返回值的调用者 | 回调、记录和其他副作用 | | 保存结果 | 从该位置取值的消费者 | 刷新、其他副本与历史记录 | | 输出字段 | 本次组装的数据 | 并行报告、绑定与独立验证 | 验证应当追到声称的结果为止:改变返回值,就展示返回;改变报告,就展示记录与组装;影响业务判断,则需要决策方证据。应用不闪退只是一个可观察结果,不能替代整条链。 ### 6.3 一条检测改好了,另一条记录为什么还在 前面的例子主要沿着一条链分析。但一个应用可以同时存在环境采集、业务统计和诊断组件,它们未必共用检测器,也未必在同一时刻采样。 假设采集器 A 查询文件与环境变量,采集器 B 使用自己的读取路径。只修改 A 的结果时,B 仍可能继续观察到原环境。这与“旧缓存没刷新”不同:B 完全可能刚刚执行过一次新检测。 ```mermaid flowchart LR E[同一进程环境] --> A[采集器 A 的读取路径] E --> B[采集器 B 的读取路径] A --> RA[A 的结果与历史] B --> RB[B 的结果与历史] RA --> OA[环境报告] RB --> OB[另一份本地记录或报告] H[仅针对 A 的改动] -.-> A ``` 这张图没有预设采集器一定相互独立。若 A、B 共用同一个被修改的下层接口,两边可能一起变化;如果 B 直接遍历环境变量表,A 使用 `getenv`,则可能出现 §2.6 的分离。 | 实验状态 | A 的当前结果 | B 的当前结果 | 可以说明什么 | |---|---|---|---| | 相同输入、未干预 | 有线索 | 有线索 | 两个采集者都能观察到该环境 | | 只影响 A 使用的读取路径 | 无该线索 | 仍有线索 | A 的改动未覆盖 B 的这次采样 | | 影响两者共用的实际来源 | 可能一起变化 | 可能一起变化 | 仍须检查各自是否复用历史结果 | 所以,遇到“这个字段正常了,另一份记录还异常”,先区分采集者、采样时间和数据来源。不要立即把它归因于更隐蔽的反 Frida,也不要通过不断清空更多输出字段来替代对生产路径的确认。 ### 6.4 原始因素变了,合并结果也可能不变 即使已经改到了正确的采集者,输出仍可能不动,因为多个因素被压缩成了一个结果。 例如,一个自建模型只记录“文件线索或环境变量线索是否存在”。从“二者都有”变为“只剩环境变量”,原始信息确实改变了,合并结果仍然是 1。若它又只在结果变化时产生记录,本次还不会出现新记录。 下面是可直接在 Node.js 或浏览器控制台执行的普通 JavaScript。它只演示本地合并与去重,没有网络或实际风险规则: ```javascript function createLocalCollector(name) { let previous; // undefined 表示尚未采样;0 是一个有效结果。 let samples = 0; return function sample(filePresent, environmentPresent) { const summary = Number(filePresent || environmentPresent); const recordCreated = previous === undefined || summary !== previous; const row = { collector: name, sample: ++samples, filePresent, environmentPresent, summary, recordCreated }; previous = summary; return row; }; } const collect = createLocalCollector('demo'); console.table([ collect(true, true), collect(false, true), collect(false, false) ]); ``` | 次数 | 文件线索 | 环境变量线索 | 合并结果 | 本地新记录 | |---|---|---|---|---| | 1 | 有 | 有 | 1 | 有,首次采样 | | 2 | 无 | 有 | 1 | 无,合并结果未变 | | 3 | 无 | 无 | 0 | 有,合并结果改变 | 第二行尤其值得注意:**没有新记录,既不代表没执行检测,也不代表原始因素没变。** 单看最终码值,也无法区分第一行与第二行的输入。 这个模型默认本地记录创建成功;真正的上报还要区分入队、发送和确认,去重状态在什么阶段更新会影响重试语义。它的用途是说明信息如何被压缩,不是模拟某个产品的协议。 对抗验证因此要保留三层观察:本次读取是否改变,合并后的状态是否改变,记录生产条件是否成立。只查看最后一个布尔值或等待一个新请求,容易把有效的局部改动看成“完全没作用”,也容易把没有上报误判成“检测已停止”。 ### 6.5 动态逻辑与独立验证,还会改变覆盖范围 有的实现把采集接口放在原生宿主,把对象选择、条件组合或记录编码交给配置与解释执行逻辑。当前关闭的探针,可能在其他配置或场景下启用。 因此,静态研究应逐步连接名称、引用、参数、执行、记录与消费者。把配置键、格式串、对照对象与真实探针混在一起计数,很容易高估检测覆盖。 本地身份检查、代码完整性和 App Attest 则有不同的验证对象。开发权限不自动意味着恶意重签,磁盘内容一致不证明内存代码没变;App Attest 还需要服务端按流程验证 attestation、登记密钥,并结合挑战、请求数据、签名和计数器验证 assertion[4]。 本地把失败改成成功,不会自动生成服务端认可的证明。与此同时,证明有效也不保证每次操作来自真人,或传感器数据一定真实:采集事实与保护这些数据的机制,仍然是两件事。 --- **资料与范围说明:** 本文按通用机制组织,代码与使用说明均内嵌于本页。私有 dyld、Mach/XPC 与 HID 需要在指定系统、签名和权限下验证;示意流程不代表所有应用采用相同实现。本次完成本地 JavaScript 检查,Apple SDK 编译与 iOS 设备效果数据尚待补充,文中没有宣称新的通用绕过或检测准确率。 ## 参考资料 1. <a href="elink@f4dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3I4A6j5Y4u0S2M7Y4W2Q4x3V1k6S2M7X3y4Z5K9i4k6W2i4K6u0r3k6r3!0U0N6h3#2W2L8Y4c8S2N6r3W2G2L8W2)9J5c8V1k6A6L8r3g2y4j5h3&6S2k6$3g2E0k6h3&6@1i4K6u0r3b7$3!0F1j5$3g2H3N6s2g2S2L8q4)9J5c8V1k6A6L8r3g2e0P5i4y4@1k6h3#2b7M7X3!0Y4M7X3q4E0L8h3W2F1k6@1N6#2K9h3c8W2i4K6u0r3c8X3W2D9k6g2y4&6M7%4c8W2L8f1!0$3k6i4u0$3K9h3g2%4i4K6u0r3c8X3W2D9k6g2y4&6M7%4c8W2L8f1!0$3k6i4u0$3K9h3g2%4i4K6u0W2K9s2c8E0L8l9`.`.">Apple:File System Programming Guide — File System Basics</a>。应用容器与 Documents 的用途。 2. <a href="elink@4aeK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6#2K9h3E0A6N6q4)9J5c8Y4g2A6j5i4m8H3L8r3W2U0j5i4c8A6L8$3&6Q4x3V1k6U0j5h3&6G2M7r3g2F1N6i4u0D9i4K6t1^5i4K6g2X3i4K6y4m8">Apple:UIApplication.canOpenURL(_:)</a>)。Scheme 查询与声明条件。 3. Apple Core Location:<a href="elink@df4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6U0L8%4u0W2L8r3!0U0j5i4c8A6L8$3&6Q4x3V1k6U0L8r3I4G2j5$3q4@1K9h3!0F1M7$3!0#2M7X3y4W2K9h3&6X3L8%4u0E0j5i4c8A6L8$3^5`.">CLLocationSourceInformation</a>、<a href="elink@fa0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6U0L8%4u0W2L8r3!0U0j5i4c8A6L8$3&6Q4x3V1k6U0L8r3I4G2j5$3q4@1K9h3!0F1M7$3!0#2M7X3y4W2K9h3&6X3L8%4u0E0j5i4c8A6L8$3&6Q4x3V1k6A6M7%4y4A6L8i4g2D9j5i4c8W2k6r3u0&6M7$3!0X3N6s2N6S2M7X3f1`.">isSimulatedBySoftware</a>、<a href="elink@1e5K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6U0L8%4u0W2L8r3!0U0j5i4c8A6L8$3&6Q4x3V1k6U0L8r3I4G2j5$3q4@1K9h3!0F1M7$3!0#2M7X3y4W2K9h3&6X3L8%4u0E0j5i4c8A6L8$3&6Q4x3V1k6A6M7%4m8J5L8$3c8#2j5$3g2V1j5Y4W2S2j5$3y4W2M7%4y4G2M7Y4V1`.">isProducedByAccessory</a>。位置来源描述。 4. <a href="elink@7a4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6V1k6i4k6A6j5$3g2U0K9r3g2U0K9#2)9J5c8Y4k6S2L8r3W2V1j5i4c8A6L8X3N6Q4x3X3c8S2M7s2m8K6i4K6u0V1N6r3S2S2N6q4)9J5k6r3y4G2L8X3&6W2j5%4c8Q4x3X3c8@1L8#2)9J5k6s2W2G2N6i4u0Q4x3X3c8K6k6i4u0$3k6i4t1`.">Apple:Validating apps that connect to your server</a>。App Attest 服务端验证流程。 5. <a href="elink@58bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6U0L8%4u0W2N6r3g2^5N6q4)9J5c8X3y4@1k6X3!0F1N6r3#2S2L8X3q4Y4k6i4u0J5k6h3N6A6M7%4c8W2M7X3N6J5j5i4m8Z5K9h3y4K6k6X3!0F1N6q4)9J5z5q4)9#2k6W2)9K6b7g2)9#2k6W2)9K6b7b7`.`.">Apple:CTFontManagerRegisterGraphicsFont(_:_:)</a>)。字体注册与失败条件。 6. <a href="elink@7d1K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6I4N6h3q4J5N6s2A6U0L8%4u0W2i4K6u0r3j5$3q4V1K9i4y4H3L8r3q4&6L8r3W2F1K9#2)9J5c8Y4m8J5k6h3k6W2M7Y4u0W2k6r3k6J5j5h3#2W2M7%4m8W2M7Y4y4W2j5$3!0F1k6l9`.`.">Apple:CADisplayLink.preferredFramesPerSecond</a>。期望更新频率与实际系统调度的关系。 7. <a href="elink@843K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6#2K9h3E0A6N6q4)9J5c8Y4g2A6j5i4m8H3L8r3W2U0j5i4c8A6L8$3&6Q4x3V1k6#2M7$3g2J5k6r3W2V1N6r3q4C8k6i4y4U0M7X3g2W2L8Y4y4Z5L8%4c8F1L8%4c8A6k6X3W2U0j5i4c8A6L8$3^5`.">Apple:UIApplication.userDidTakeScreenshotNotification</a>。通知发生于截图完成之后。 8. <a href="elink@f96K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8G2j5%4g2E0k6h3&6@1j5i4c8A6L8$3&6Q4x3V1k6#2K9h3E0A6N6q4)9J5c8Y4g2A6j5h3y4U0k6i4y4K6K9h3u0A6L8r3W2@1P5g2)9J5c8X3W2K6N6X3!0A6j5$3g2G2N6X3g2J5M7Y4g2F1L8X3W2F1k6H3`.`.">Apple:UIAccessibility.isVoiceOverRunning</a>。VoiceOver 状态与界面适配。 9. [看雪:从端侧风险检测到策略闭环](https://bbs.kanxue.com/thread-292994.htm)。相关阅读,涵盖端侧采集、信号关联与策略消费;本文侧重越狱与插桩的具体观察通道及对抗作用范围。 10. <a href="elink@e70K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6X3M7X3W2V1j5g2)9J5k6i4u0W2i4K6u0r3k6r3!0U0M7#2)9J5c8X3A6S2N6X3q4K6j5%4u0A6M7s2c8Q4x3X3c8S2M7r3W2Q4x3V1j5`.">Frida:JavaScript API</a>。NativeCallback 调用上下文、SystemFunction 错误状态、Interceptor.replace 与 ObjC bridge。 11. <a href="elink@326K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1k6i4k6W2L8r3!0H3k6i4u0Q4x3X3g2S2M7s2m8D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3I4A6j5Y4u0S2M7Y4W2Q4x3V1k6S2M7X3y4Z5K9i4k6W2i4K6u0r3k6r3!0U0N6h3#2W2L8Y4c8S2N6r3W2G2L8W2)9J5c8W2y4&6M7%4c8W2L8g2)9J5c8V1y4G2L8X3y4W2M7s2c8#2j5h3I4Q4x3V1k6y4j5h3&6b7j5h3N6W2M7#2)9#2k6X3W2b7K9r3!0F1k6f1!0e0i4K6u0r3L8h3q4F1x3W2)9J5c8X3#2E0j5i4m8Q4x3X3f1J5i4K6u0W2K9s2c8E0L8l9`.`.">Apple:mmap(2)</a>。文件映射参数、失败返回与访问异常。 12. Apple 开源 XNU:<a href="elink@f3aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6S2M7s2m8D9k6g2)9J5k6r3!0K6M7#2)9J5k6r3c8A6M7%4c8J5K9h3u0#2N6r3W2G2L8Y4y4Q4x3V1k6^5L8Y4g2Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4A6L8W2)9J5c8X3I4A6j5Y4y4&6M7$3y4S2L8r3I4Q4x3V1k6U0N6i4y4@1L8$3#2Q4x3V1k6e0h3g2y4Q4x3X3g2Z5">libsyscall/custom/SYS.h</a>、<a href="elink@88fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6S2M7s2m8D9k6g2)9J5k6r3!0K6M7#2)9J5k6r3c8A6M7%4c8J5K9h3u0#2N6r3W2G2L8Y4y4Q4x3V1k6^5L8Y4g2Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4A6L8W2)9J5c8X3u0K6k6q4)9J5c8X3E0W2M7X3&6Q4x3V1k6K6P5i4y4U0j5h3I4D9M7#2)9J5k6h3#2S2M7%4c8W2M7R3`.`.">bsd/kern/syscalls.master</a>、<a href="elink@b9bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6S2M7s2m8D9k6g2)9J5k6r3!0K6M7#2)9J5k6r3c8A6M7%4c8J5K9h3u0#2N6r3W2G2L8Y4y4Q4x3V1k6^5L8Y4g2Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4A6L8W2)9J5c8X3!0K6k6X3#2C8i4K6u0r3L8h3q4U0K9q4)9J5c8X3q4J5L8g2)9J5c8Y4k6E0i4K6g2X3M7r3q4J5j5h3#2Q4x3X3g2Z5">osfmk/mach/arm/vm_param.h</a>。ARM64 系统调用包装、调用表与陷入常量;源码版本不等同于任意设备的系统版本。 13. <a href="elink@ba1K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6S2M7s2m8D9k6g2)9J5k6r3!0K6M7#2)9J5k6r3c8A6M7%4c8J5K9h3u0#2N6r3W2G2L8Y4y4Q4x3V1k6^5L8Y4g2Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4A6L8W2)9J5c8X3u0K6k6q4)9J5c8X3c8W2N6W2)9J5c8X3q4J5L8g2)9J5c8Y4y4&6M7%4c8W2L8h3y4S2L8r3I4K6i4K6u0W2j5H3`.`.">Apple 开源 XNU:bsd/dev/arm/systemcalls.c</a>。BSD 调用分派、参数提取、错误返回及受配置控制的跟踪路径。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
1
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
Imxz
雪 币:
110
活跃值:
(9556)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
773
粉丝
9
关注
私信
Imxz
2
楼
tql
1天前
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
mxystery
2
6
发帖
5
回帖
90
RANK
关注
私信
他的文章
[原创] 浅谈ios的风控检测与对抗
246
[原创]从调用级并发到 Guest Thread Runtime:unidbg 单后端线程语义的重建
2240
[原创]从时间片轮转到调用级并发:unidbg 单后端多线程架构重构
41579
[原创]给 unidbg 装上原生时间片:如何让模拟器真正跑起多线程
2954
[原创]2026腾讯游戏安全竞赛决赛安卓客户端安全分析
8945
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部