首页
社区
课程
招聘
[原创] 浅谈ios的风控检测与对抗
发表于: 2026-10-6 20:46 1186

[原创] 浅谈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 设备效果数据尚待补充,文中没有宣称新的通用绕过或检测准确率。

参考资料

  1. Apple:File System Programming Guide — File System Basics。应用容器与 Documents 的用途。
  2. Apple:UIApplication.canOpenURL(_:)。Scheme 查询与声明条件。
  3. Apple Core Location:CLLocationSourceInformation、isSimulatedBySoftware、isProducedByAccessory。位置来源描述。
  4. Apple:Validating apps that connect to your server。App Attest 服务端验证流程。
  5. Apple:CTFontManagerRegisterGraphicsFont(::)。字体注册与失败条件。
  6. Apple:CADisplayLink.preferredFramesPerSecond。期望更新频率与实际系统调度的关系。
  7. Apple:UIApplication.userDidTakeScreenshotNotification。通知发生于截图完成之后。
  8. Apple:UIAccessibility.isVoiceOverRunning。VoiceOver 状态与界面适配。
  9. 看雪:从端侧风险检测到策略闭环。相关阅读,涵盖端侧采集、信号关联与策略消费;本文侧重越狱与插桩的具体观察通道及对抗作用范围。
  10. Frida:JavaScript API。NativeCallback 调用上下文、SystemFunction 错误状态、Interceptor.replace 与 ObjC bridge。
  11. Apple:mmap(2)。文件映射参数、失败返回与访问异常。
  12. Apple 开源 XNU:libsyscall/custom/SYS.h、bsd/kern/syscalls.master、osfmk/mach/arm/vm_param.h。ARM64 系统调用包装、调用表与陷入常量;源码版本不等同于任意设备的系统版本。
  13. Apple 开源 XNU:bsd/dev/arm/systemcalls.c。BSD 调用分派、参数提取、错误返回及受配置控制的跟踪路径。

[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)

收藏
点赞 ・1
打赏
分享
最新回复 (2)
雪    币: 110
活跃值: (9641)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2 楼
tql
2026-10-7 10:15
0
雪    币: 364
活跃值: (432)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3 楼
cool!
2026-10-10 15:51
0
游客
登录 | 注册 方可回帖
返回