-
-
[原创] 浅谈ios的风控检测与对抗
-
发表于: 2026-10-6 20:46 1186
-
浅谈ios的风控检测与对抗
开始分析一个 iOS 应用时,最先遇到的往往不是复杂算法,而是几件很具体的事:进程能不能正常启动,Frida 能不能附加,脚本执行以后,业务还能不能继续。
接着,问题会变得有些反直觉。
越狱路径已经查不到,为什么环境判断还会异常?Frida 的名字已经藏起来,为什么一安装 Hook 就出问题?手动调用检测函数,返回值明明正常,为什么下一次请求仍然带着旧记录?
它们指向同一个关键问题:你改变的位置,是否就是检测器观察的位置;这次改变,又有没有传到最终使用结果的地方。
iOS 风控并不只有一个“是否越狱”的开关。它可以从文件查到服务,从镜像名查到函数指针,再从运行时环境延伸到触摸记录。理解这些检测,比维护一份越来越长的 Hook 清单更有用:知道了每条查询在看什么,才能解释对抗为什么有效,以及它的作用范围在哪里。
本文以越狱环境、Frida 和反 Hook 为主线,随后讨论触摸与结果缓存;字体和资源保护放在补充部分。所有场景都按通用机制展开,不依赖某个商业应用的类名或接口。
下面会直接给出对抗代码:从精确隐藏一条路径,到截断本机协议握手,再到改变一个反 Hook 检测方法的返回。每个例子都说明它改了哪一层,以及怎样确认这次改动生效。
一、先看全貌:一次风险判断要经过哪些环节
可以先把检测器拆成四个问题,而不是立即寻找一个返回布尔值的方法。
| 问题 | 典型观察 | 对抗时要定位的地方 |
|---|---|---|
| 环境里有什么 | 越狱路径、注入目录、配置、环境变量、服务 | 环境对象与查询视图 |
| 进程里改了什么 | 加载镜像、线程、IMP、导入指针、函数代码 | 工具痕迹与实际修改位置 |
| 操作是怎样发生的 | 触摸阶段、时间、传感器、业务事件 | 输入来源与记录生产过程 |
| 结果被谁使用 | 本地状态、缓存、报告、请求验证 | 保存、刷新、组装与消费 |
下面是一个简化的数据流。具体实现会选择其中一部分,但每项对抗都可以放到这张图上定位。
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。
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 的字符串都误伤。
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 输出参数的形式:
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 加载脚本,重复相同查询。实验所需的文件准备与观察可以直接写在按钮里:
#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,阳性与阴性对照仍走原实现:
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 文件隐藏之后,服务还在不在
这是路径检测之外很值得研究的一层:安装文件被隐藏了,已经启动的守护进程却可能继续提供服务。
观察可以拆成三个步骤:当前命名空间能否查询到名称,能否取得可用端口,使用约定协议后能否得到有效应答。
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 查询某个服务,可以在这一层处理名称与输出端口。下面用自有服务名演示,按实验中实际查询的名称替换即可。
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/...。要核对的是目标与当前容器、访问授权的关系。
一条典型的文件映射采样链如下:
flowchart TB
A[确定目标路径] --> B[创建或打开文件]
B --> C[写入并刷新缓冲]
C --> D[取得文件描述符]
D --> E[共享读写映射]
E --> F[记录成功或失败阶段]
F --> G[解除映射并清理自建文件]
因此,即便某个实现把“映射成功”写进了风险位,也要分开描述程序如何编码结果和这个结果是否能证明越狱。没有正常设备的对照,不能把 Documents 可写称为“最强越狱判据”或“无法绕过”。
动手:先做一个能区分失败阶段的探针
以下代码可放入自有 App 的 .m 文件。它保留创建、写入、映射这条链;为了不覆盖旧文件,使用 open(O_EXCL) 创建,再交给 fdopen 取得文件流。只有本次创建成功的文件才会被清理。
#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 的共享读写映射。
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 文件,所有操作由同一串行实验入口触发,避免一边遍历、一边修改环境变量表。
#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 安装下面这段:
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 藏掉第一条线索,为什么反而出现了第二条
假设某次实验只记录到文件查询,隐藏路径后却突然出现了环境变量或写入探针。先别急着判断检测“升级”了:原来的函数可能命中第一项就返回,后面的代码此前根本没机会执行。
越狱检测常见两种控制流。首项命中即返回只报告当前顺序中最早的结果;逐项累积则继续执行后续探针,最后再组合。相同的路径、环境和权限检查,放进这两种控制流,实验日志会很不一样。
flowchart TB
A[同一组环境输入] --> B[首项命中即返回]
A --> C[逐项累积]
B --> D{路径项命中?}
D -->|是| E[返回路径项,后续未执行]
D -->|否| F[继续检查环境与能力]
C --> G[分别采集路径、环境与能力]
G --> H[保留各项结果,再合并]
以下普通 JavaScript 用两个虚构因素展示区别,可以直接放进 Node.js 或浏览器控制台。名称只表示教学模型,没有实际查询设备:
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 入口与符号别名,不能把一个导出名当成所有调用的必经之处。
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 编译或设备运行。
#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 文件中加入以下观察入口:
#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、导入指针和机器码
一个地址属于系统库,不能独立证明这次调用没有被改变。先把常见调用方式画出来:
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,再替换方法,最后检查当前映射。
#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]);
这里的最小对抗可以直接发生在检测返回处:
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 的字段布局。端口是网络字节序,也不能直接按主机字节序读取。
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,其他请求透传。
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 则通过初始化时保存的函数指针采样。所有名称都是示例名称。
#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:
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 按钮回调执行,不等于经历了相同输入过程
即使环境探针都得到预期值,应用仍可以观察操作怎样发生。手指触屏、系统测试设施驱动操作、直接调用业务处理函数,可能从不同层进入。
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 在启动时采样,把结果放进缓存,发请求时只读缓存。下面是这个模型的时间线,不是真机日志。
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 之后,再执行:
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 完全可能刚刚执行过一次新检测。
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。它只演示本地合并与去重,没有网络或实际风险规则:
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 设备效果数据尚待补充,文中没有宣称新的通用绕过或检测准确率。
参考资料
- Apple:File System Programming Guide — File System Basics。应用容器与 Documents 的用途。
- Apple:UIApplication.canOpenURL(_:)。Scheme 查询与声明条件。
- Apple Core Location:CLLocationSourceInformation、isSimulatedBySoftware、isProducedByAccessory。位置来源描述。
- Apple:Validating apps that connect to your server。App Attest 服务端验证流程。
- Apple:CTFontManagerRegisterGraphicsFont(::)。字体注册与失败条件。
- Apple:CADisplayLink.preferredFramesPerSecond。期望更新频率与实际系统调度的关系。
- Apple:UIApplication.userDidTakeScreenshotNotification。通知发生于截图完成之后。
- Apple:UIAccessibility.isVoiceOverRunning。VoiceOver 状态与界面适配。
- 看雪:从端侧风险检测到策略闭环。相关阅读,涵盖端侧采集、信号关联与策略消费;本文侧重越狱与插桩的具体观察通道及对抗作用范围。
- Frida:JavaScript API。NativeCallback 调用上下文、SystemFunction 错误状态、Interceptor.replace 与 ObjC bridge。
- Apple:mmap(2)。文件映射参数、失败返回与访问异常。
- Apple 开源 XNU:libsyscall/custom/SYS.h、bsd/kern/syscalls.master、osfmk/mach/arm/vm_param.h。ARM64 系统调用包装、调用表与陷入常量;源码版本不等同于任意设备的系统版本。
- Apple 开源 XNU:bsd/dev/arm/systemcalls.c。BSD 调用分派、参数提取、错误返回及受配置控制的跟踪路径。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)