切断因果链的攻防艺术:iOS 内存遍历检测 Frida 与 RunLoop 拟态崩溃实录
在移动安全与逆向对抗的领域中,以 Frida 为代表的动态插桩工具凭借其免二次编译、动态代码注入、JavaScript 脚本桥接以及对 Objective-C / C 运行时无缝拦截的能力,早已成为攻击者撕开 iOS 客户端防护的标配利器。无论是绕过风控签名、破解 SSL Pinning,还是篡改核心业务入参,Frida 几乎能在运行时任意操控 App 的每一个神经末梢。
面对这种降维打击,多数移动安全方案依然停留在检测越狱环境或判定默认端口的初级阶段,而一旦遇到高级攻击者的魔改 Frida 或注入式 Gadget,这类防护便瞬间形同虚设。为了在实战中真正构筑坚固防线,我结合对底层 Mach 内核机制的理解,探索出一套深入进程虚拟内存空间遍历特征,并配合 Use-After-Free 与 RunLoop 异步解耦实现“拟态系统 Bug 崩溃”的纵深防御体系。
一、猫鼠博弈的进化:Frida 为何成为 iOS 逆向的“瑞士军刀”
在传统的 iOS 逆向工程中,攻击者通常依赖 Hopper、IDA Pro 等工具进行静态反汇编,或者通过 class-dump 导出类头文件,然后编写 MobileSubstrate / Theos 插件进行 Hook 并重新签名打包。这种传统方式链路冗长、试错成本极高,且在面对强化了完整性校验(Code Signing & FairPlay)的生产环境 App 时阻力重重。
而 Frida 彻底改变了这种游戏规则。它基于动态二进制插桩(Dynamic Binary Instrumentation, DBI)技术,允许逆向工程师在应用运行过程中动态挂载、实时编写 JS 脚本来读取与修改内存、替换方法实现,甚至即时调用原本未暴露的私有 API。
flowchart TD
subgraph Offensive["Frida 攻击渗透路径"]
A1["攻击者注入意图"] --> A2{"目标运行环境"}
A2 -->|"越狱设备 (frida-server)"| B1["task_for_pid() 劫持进程 Task Port"]
B1 --> B2["thread_create_running() 远程创建线程"]
B2 --> B3["调用 dlopen() 将 frida-agent.dylib 强行映射入 VM"]
A2 -->|"非越狱设备 (Frida Gadget)"| C1["修补 Mach-O LC_LOAD_DYLIB"]
C1 --> C2["集成 FridaGadget.dylib 并重签名安装"]
B3 --> D1["Frida JS Runtime 启动 (QuickJS/V8/Duktape)"]
C2 --> D1
D1 --> E1["动态方法 Hook / 内存篡改 / 私有类解析"]
end
subgraph Impact["核心危害场景"]
E1 --> F1["绕过生物识别与登录鉴权"]
E1 --> F2["Hook 网络层抓取明文解密数据"]
E1 --> F3["伪造风控参数与设备指纹"]
E1 --> F4["对安全探针逻辑进行动态反制"]
end
在真实攻防对抗中,攻击者注入 Frida 的底层路径主要分为两类:
- 越狱环境下的
frida-serverAttach:以 root 权限运行的守护进程通过 Mach 内核接口task_for_pid()获取目标 App 的进程任务端口,接着在目标空间内调用thread_create_running创建远程线程,最终驱动dlopen()将frida-agent.dylib加载进宿主 App 的虚拟内存空间。 - 非越狱环境下的 Frida Gadget 嵌入:攻击者解包砸壳后的 IPA,通过
optool或insert_dylib篡改 Mach-O 的 Load Commands,强制塞入LC_LOAD_DYLIB依赖,并附带FridaGadget.dylib进行企业证书或个人证书重签名。在 App 启动的第一时间,Gadget 会在 dyld 加载器阶段直接驻留内存。
既然 Frida 无论是通过守护进程注入还是通过 Gadget 嵌装,最终都必须以动态库或内存段的形式驻留在 App 的虚拟内存空间中,这就为我在底层展开特征捕获提供了根本物理依据。
二、第一道浅层滤网:Localhost 默认端口探测与攻防对抗
当黑客在越狱设备上通过 CLI 执行 frida -U -f com.example.app 时,设备后台必须常驻一个 frida-server 进程。该服务默认监听本地网络端口 27042(TCP)。
因此,最轻量、成本最低的防御手段,便是在应用初始化或关键业务触发前,尝试向本机的 127.0.0.1:27042 发起一次非阻塞 Socket 握手。
1. 非阻塞端口探针实现
在编写这段探针时,我必须保证两点:
- 绝对不能阻塞主线程:网络请求哪怕超时 1 秒,也会导致 App 启动卡顿甚至被 iOS Watchdog 杀掉;
- 必须准确识别端口状态:不能仅凭
connect返回非 0 就草率下结论,必须利用select轮询配合getsockopt检查套接字底层的SO_ERROR状态。
#import <sys/socket.h>
#import <arpa/inet.h>
#import <fcntl.h>
#import <unistd.h>
#import <errno.h>
#import <Foundation/Foundation.h>
BOOL isFridaPortReallyOpen(void) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) return NO;
// 设置为非阻塞模式,杜绝卡死主线程
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(27042); // Frida Server 默认端口
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
if (ret < 0 && errno != EINPROGRESS) {
close(sockfd);
return NO;
}
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(sockfd, &wfds);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 300 * 1000; // 超时时间收敛在 300ms 以内
ret = select(sockfd + 1, NULL, &wfds, NULL, &tv);
if (ret <= 0) {
close(sockfd);
return NO;
}
// 核心判定:深入检查内核 socket 错误码
int so_error = 0;
socklen_t len = sizeof(so_error);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &so_error, &len);
close(sockfd);
// 只有当套接字连接毫无错误时,才判定该端口被真实开启且存活
return (so_error == 0);
}
2. 为什么端口检测只是“脆弱的纸老虎”?
在实战攻防中,端口检测仅仅是一道前置的粗筛网,绝不能作为判定环境安全的唯一准绳:
- 易被端口参数规避:攻击者只需在终端执行
frida-server -l 127.0.0.1:19999,或者在非默认端口建立反向 SSH 隧道,此检测就会瞬间失效; - Gadget 场景无效:在非越狱嵌入 Frida Gadget 的场景中,Gadget 往往运行在 Listen 模式或者直接加载本地打包的 JS 脚本(Script 模式),完全不依赖 27042 端口通信;
- 小概率端口冲突:极罕见情况下其他本地合法服务可能碰巧复用该端口,导致误杀。
因此,端口检测只能充当开销极小的“探针雷达”。一旦需要真正铁证如山地击中 Frida 的心脏,我必须直接下潜到系统的虚拟内存空间。
三、直捣黄龙:Mach 虚拟内存遍历与特征扫描机制
无论攻击者如何篡改端口、无论其使用 frida-server 还是 Frida Gadget,只要 Frida 想要在运行时发挥作用,它的 JavaScript 运行时、RPC 协议通信引擎、桥接代理模块(frida-objc-bridge)以及 Vala 核心库编译产生的符号与字符串,就必须不可逆地被载入目标 App 的虚拟内存空间中。
JavaScript 引擎在解析执行脚本时,必须将代码展开为内存明文字符串;同时 Frida 内部各种调度器、错误处理与断言信息,均会硬编码在只读常量区或堆区。这就构成了 Frida 无法彻底抹除的物理特征。
flowchart TD
Start["开始内存扫描:checkAppMemoryForFrida"] --> Init["init_self_image_range() 记录本进程主二进制内存区间"]
Init --> Task["获取 mach_task_self() 任务端口,addr = 0"]
subgraph Loop["虚拟内存逐段遍历循环"]
Task --> Region["调用 vm_region_64 获取内存段信息"]
Region --> CheckSelf{"addr 是否落在 App 自身二进制区间内?"}
CheckSelf -->|"是"| SkipSelf["跳过自身镜像 (防自检假阳性)"]
SkipSelf --> StepAddr["addr += size,进入下一段"]
CheckSelf -->|"否"| CheckProt{"protection 是否包含 VM_PROT_READ?"}
CheckProt -->|"不可读"| StepAddr
CheckProt -->|"可读"| AllocBuf["分配临时缓冲区,调用 vm_read_overwrite 读取"]
AllocBuf --> ScanList["遍历 kFridaPlainList 15 个明文特征词"]
ScanList --> Match{"内存子串是否存在匹配?"}
Match -->|"命中"| Resolve["调用 dladdr 解析所属动态库模块"]
Resolve --> RecordHit["hitCount++ 记录告警日志"]
Match -->|"未命中"| FreeBuf["释放临时缓冲区"]
RecordHit --> FreeBuf
FreeBuf --> StepAddr
end
StepAddr --> RegionEnd{"vm_region_64 返回结束?"}
RegionEnd -->|"否"| Region
RegionEnd -->|"是"| Final{"hitCount > 0?"}
Final -->|"是"| Trigger["触发 onDetected 拟态崩溃解耦防护"]
Final -->|"否"| Safe["环境正常,继续执行"]
1. 甄别 Frida 的 15 个关键明文特征
经过逆向分析与内存 Dump 验证,Frida 在初始化与运行时会不可避免地暴露出以下 15 个高特异性特征:
| 序号 | 核心特征关键词 | 来源与技术背景 | 漏报/误报风险 |
|---|---|---|---|
| 1 | frida_gadget |
Frida Gadget Native 层的核心模块标识 | 极高特异性,正常 App 不可能包含 |
| 2 | /frida-core/lib/gadget/gadget.vala |
Vala 编译器保留的源码断言与日志路径 | 官方产物特征,极度可信 |
| 3 | frida/runtime/core.js |
核心 JS 运行时入口(定义 Module / Memory / Interceptor) | 运行 Frida 必须加载的代码路径 |
| 4 | frida_agent_main |
Frida Agent 动态库的 Native 入口函数名 | 相当于 Agent 动态库的 main() |
| 5 | frida_dylib_range= |
Frida 内部用于“自我感知内存边界”的特殊字符串 | 内部专用通信标识 |
| 6 | /frida-core/lib/agent/agent.vala |
Frida Agent 核心控制器的 Vala 源码路径 | 官方构建产物残留路径 |
| 7 | frida:rpc |
Agent 与控制端交互的 JSON-RPC 协议报文头 | 双向通讯通道的硬编码标识 |
| 8 | Frida._loadObjC(); |
激活 Objective-C Runtime 挂钩的 JS 代码 | 进行 iOS Hook 必经之路 |
| 9 | require("frida-objc-bridge"); |
解析 ObjC 模块、Class 枚举与 Selector Hook 的桥梁脚本 | 关键桥接层标识 |
| 10 | /frida-core/lib/payload/spawn-monitor.vala |
进程派生(Spawn)监控模块的断言路径 | 逆向调试监控标识 |
| 11 | FridaAgentRunner |
负责拉起 JS 运行时并管理生命周期的核心类 | Native 运行实体名称 |
| 12 | FridaSpawnHandler |
处理应用 Spawn / Attach 事件的协调器 | 核心调度组件 |
| 13 | FridaPortalClient |
Agent 用于跨进程 / 跨网络与 Server 维持连接的客户端 | IPC 通信模块 |
| 14 | FridaAgentController |
Agent 的总控类,调度 Session 与 Script 上下文 | 控制中枢标识 |
| 15 | frida.Error. |
Frida JS Runtime 向上层抛出异常时的格式前缀 | 异常封装对象特征 |
2. 避免自身“自伤”的镜像边界锚定
很多开发者在尝试编写内存特征扫描时,经常遇到一个啼笑皆非的 Bug:刚启动 App,自己写的内存扫描函数就把自己当成 Frida 给杀了。
原因很简单:由于我在代码中定义的常量特征数组(如 "frida_gadget"、"frida/runtime/core.js")直接编译在 App 自身的主二进制 Mach-O 的 __TEXT,__cstring 段中,如果在扫描内存时不加区分地全盘读取,扫描器在扫到自己二进制的常量段时就会瞬间命中!
为了杜绝这种“假阳性自伤”,我必须在扫描开始前,通过 dyld 接口准确计算出 App 主镜像在虚拟内存中的真实映射起始地址与终止地址:
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
static void *self_image_start = NULL;
static void *self_image_end = NULL;
static void init_self_image_range(void) {
uint32_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
// App 自身的主二进制在 dyld 列表中的 image_header_type 必定为 MH_EXECUTE
const struct mach_header *header = _dyld_get_image_header(i);
if (header->magic == MH_MAGIC_64 && header->filetype == MH_EXECUTE) {
self_image_start = (void *)header;
const struct mach_header_64 *mh = (const struct mach_header_64 *)header;
intptr_t slide = _dyld_get_image_vmaddr_slide(i);
uintptr_t maxEnd = 0;
const struct load_command *cmd = (const struct load_command *)((uintptr_t)mh + sizeof(*mh));
// 遍历所有 LC_SEGMENT_64 加载命令,计算自身二进制在内存中的物理边界
for (uint32_t j = 0; j < mh->ncmds; j++) {
if (cmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64 *seg = (const struct segment_command_64 *)cmd;
uintptr_t end = seg->vmaddr + seg->vmsize;
if (end > maxEnd) {
maxEnd = end;
}
}
cmd = (const struct load_command *)((uintptr_t)cmd + cmd->cmdsize);
}
self_image_end = (void *)(maxEnd + slide);
break;
}
}
}
3. 基于 vm_region_64 的内存全域扫描实现
有了安全边界后,我便可以通过 Mach 内核 API vm_region_64 逐段遍历当前进程的虚拟内存空间,并通过 vm_read_overwrite 安全读取可读内存段:
#include <mach/mach.h>
#include <dlfcn.h>
#include <string.h>
#include <stdlib.h>
// 辅助函数:根据内存地址利用 dladdr 查找所属模块
static const char *find_module_for_address(vm_address_t addr) {
Dl_info info;
if (dladdr((void *)addr, &info)) {
if (info.dli_fname) {
return info.dli_fname;
}
}
return "unknown";
}
// 内存子串匹配工具函数
static int memory_contains(const void *haystack, size_t haystack_len,
const void *needle, size_t needle_len) {
if (needle_len == 0 || haystack_len < needle_len) return 0;
const unsigned char *h = (const unsigned char *)haystack;
const unsigned char *n = (const unsigned char *)needle;
for (size_t i = 0; i <= haystack_len - needle_len; i++) {
if (memcmp(h + i, n, needle_len) == 0) {
return 1;
}
}
return 0;
}
int checkAppMemoryForFrida(void (*onDetected)(void)) {
task_t task = mach_task_self();
vm_address_t addr = 0;
vm_size_t size = 0;
int hitCount = 0;
// 1. 初始化自身镜像区间
init_self_image_range();
// 2. 循环遍历虚拟内存段
while (1) {
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object;
kern_return_t kr = vm_region_64(
task,
&addr,
&size,
VM_REGION_BASIC_INFO_64,
(vm_region_info_t)&info,
&count,
&object
);
if (kr != KERN_SUCCESS) {
break; // 遍历完毕或发生异常中断
}
// 3. 严格跳过 App 自身主二进制区间,消除常量特征自伤
if ((void *)addr >= self_image_start && (void *)addr < self_image_end) {
addr += size;
continue;
}
// 4. 仅对具有可读属性(VM_PROT_READ)的段展开扫描
if (info.protection & VM_PROT_READ) {
void *buffer = malloc(size);
if (buffer) {
vm_size_t outSize = 0;
// 利用 Mach 安全读取机制将目标段内容覆写进隔离缓冲区
if (vm_read_overwrite(task, addr, size, (vm_address_t)buffer, &outSize) == KERN_SUCCESS) {
for (size_t i = 0; i < sizeof(kFridaPlainList) / sizeof(kFridaPlainList[0]); i++) {
const char *needle = kFridaPlainList[i];
size_t needleLen = strlen(needle);
if (needleLen == 0) continue;
if (memory_contains(buffer, outSize, needle, needleLen)) {
hitCount++;
const char *mod = find_module_for_address(addr);
// 实际项目中可在此处进行风控埋点与静默告警收集
}
}
}
free(buffer);
}
}
addr += size;
}
// 5. 命中处理:如果检测到非法痕迹,触发防御响应
if (hitCount > 0 && onDetected) {
onDetected();
}
return hitCount;
}
当我在已挂载 Frida 的应用中执行这段探测逻辑时,控制台或安全日志会立即爆出一系列清晰的底层铁证:
内存检测--发现(FridaAgentRunner), 地址:0x100c1c000, 模块:unknown, hitCount=1
内存检测--发现(FridaSpawnHandler), 地址:0x100c1c000, 模块:unknown, hitCount=2
内存检测--发现(FridaPortalClient), 地址:0x100c1c000, 模块:unknown, hitCount=3
内存检测--发现(frida_agent_main), 地址:0x10156c000, 模块:unknown, hitCount=7
内存检测--发现(frida/runtime/core.js), 地址:0x101580000, 模块:unknown, hitCount=10
内存检测--发现(require("frida-objc-bridge");), 地址:0x101580000, 模块:unknown, hitCount=13
无论是 anonymous 匿名映射空间(模块显示为 unknown)还是被篡改注入的动态库,这些深层 JS 运行时与 RPC 协议标记都会无所遁形。
四、切断因果链:从“主动退出”到“UAF 拟态崩溃”的终极反追踪
当我在内存扫描中发现了 Frida 的踪迹后,通常该如何处置?
我见过绝大多数商业防护 SDK 都会写下这样极其幼稚的代码:
if (hitCount > 0) {
exit(0); // 或者 abort(), 或者 kill(getpid(), SIGKILL)
}
1. 传统防御方案的“阿喀琉斯之踵”
这种直接调用退出函数的做法,在有经验的逆向者面前就像是“自投罗网”:
sequenceDiagram
autonumber
actor Attacker as 逆向分析者 (LLDB / Frida)
participant App as 目标应用 (受保护)
participant Kernel as 系统内核 / 信号子系统
Note over Attacker,App: 传统退出方案:因果链清晰暴露
App->>App: 内存检测命中 (checkAppMemoryForFrida)
App->>Kernel: 立即调用 exit(0) / abort()
Kernel->>Attacker: 捕获 SIGABRT 或进程退出通知
Attacker->>Attacker: 在 LLDB 中对 exit / abort 下条件断点
Attacker->>Attacker: 执行 bt 查看崩溃回溯栈
Note over Attacker: 逆向者根据 Frame #1 轻松反推出 checkAppMemoryForFrida 地址,直接 NOP 绕过!
Note over Attacker,App: 拟态崩溃方案:四维因果链彻底切断
App->>App: 内存检测命中
App->>App: 分配堆内存并立即 free() (构造潜在野指针)
App->>App: dispatch_async 派发至主队列
App->>App: 嵌入 CFRunLoopPerformBlock 延迟至下一事件循环
App->>Kernel: 在 RunLoop 内部向释放的内存写入 0x42
Kernel->>Attacker: 触发 Bus Error / EXC_BAD_ACCESS
Attacker->>Attacker: LLDB 截获崩溃,打印调用栈
Note over Attacker: 崩溃深陷在 CoreFoundation / UIKitCore / libobjc 内部!因果链断裂,逆向者以为是系统 Bug!
当攻击者通过 LLDB 挂载应用时,他只需在 exit、abort、__pthread_kill 上打一个断点,然后直接在调试器中敲下 bt(Backtrace):
(lldb) bt
* frame #0: 0x00000001a4123456 libsystem_c.dylib`abort
* frame #1: 0x0000000100086789 MyApp`checkAppMemoryForFrida + 328
* frame #2: 0x0000000100085420 MyApp`-[SecurityManager verifyEnvironment] + 120
攻击者甚至不需要理解你的扫描逻辑,直接把 checkAppMemoryForFrida 的前 4 个字节用 ret(0xC0035FD6)指令覆盖掉(NOP 掉),你的整套内存扫描防线便宣告彻底土崩瓦解。
2. 拟态崩溃的四维解耦理论
为了彻底让逆向者陷入“无法溯源”的死胡同,我必须切断「检测完成」与「应用终止」之间的直接因果联系。优秀的防御策略应当在以下四个维度完成彻底解耦:
- 时间解耦(Temporal Decoupling):绝对不在检测命中的当前执行周期直接终止,而是引入随机延迟或转入后续的事件轮询周期;
- 线程解耦(Thread Decoupling):从后台检测子线程调度脱钩,切换到主线程或系统的全局并发队列;
- 调用栈解耦(Call-Stack Decoupling):利用 iOS 消息派发与 RunLoop 块机制,彻底抹去原先的调用栈帧(Frame),让新的执行上下文处于一个全新的调用树根节点上;
- 语义解耦(Semantic Decoupling):不调用任何具有“主动退出意图”的系统函数(无
exit、无abort),而是人为制造一次极度逼真的工程并发缺陷—— Use-After-Free(UAF 内存悬挂指针非法写)。
3. 基于 UAF 与 RunLoop 的拟态崩溃实战实现
#import <Foundation/Foundation.h>
#import <CoreFoundation/CoreFoundation.h>
#import <stdlib.h>
void trigger_crash_async(void) {
// 1. 申请一块小内存,并立刻释放,制造悬挂野指针
void *p = malloc(16);
free(p);
// 2. 线程解耦:异步分发至主线程队列
dispatch_async(dispatch_get_main_queue(), ^{
// 3. 栈帧与事件解耦:通过 RunLoop 注册回调块,延迟至下一次 UI 渲染/事件提交周期
CFRunLoopPerformBlock(CFRunLoopGetMain(), kCFRunLoopCommonModes, ^{
// 4. 语义解耦:对已被 free 的内存进行脏写操作,触发 Bus Error / EXC_BAD_ACCESS
volatile char *q = (char *)p;
q[0] = 0x42;
});
});
}
4. 逆向者的绝望视角:崩溃堆栈剖析
当上述代码执行后,应用不会留下任何安全防护触发的显式痕迹。逆向者无论是在 Xcode 调试器、Crashlytics 还是在 iOS 系统的 Panic 报告中,看到的崩溃堆栈都是这样一副“天然工程灾难”的景象:
Termination Signal: Bus error: 10
Termination Reason: Namespace SIGNAL, Code 0xa
Terminating Process: exc handler [9303]
Triggered by Thread: 0
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 libobjc.A.dylib 0x00000001a82c4c98 objc_retain + 8
1 CoreFoundation 0x0000000194abbf14 __NSSingleObjectArrayI_new + 84
2 CoreFoundation 0x000000019496b334 -[NSArray initWithArray:range:copyItems:] + 412
3 UIKitCore 0x00000001972cf714 _runAfterCACommitDeferredBlocks + 160
4 UIKitCore 0x00000001972beb4c _cleanUpAfterCAFlushAndRunDeferredBlocks + 200
5 UIKitCore 0x00000001972f0260 _afterCACommitHandler + 76
6 CoreFoundation 0x00000001949ffecc __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
7 CoreFoundation 0x00000001949fa5b0 __CFRunLoopDoObservers + 604
8 CoreFoundation 0x00000001949faaf8 __CFRunLoopRun + 960
9 CoreFoundation 0x00000001949fa200 CFRunLoopRunSpecific + 572
10 GraphicsServices 0x00000001aaaf5598 GSEventRunModal + 160
11 UIKitCore 0x00000001972c0004 -[UIApplication _run] + 1052
12 UIKitCore 0x00000001972c55d8 UIApplicationMain + 164
13 TestSpace 0x00000001046deb6c main + 184
14 libdyld.dylib 0x00000001946d9598 start + 4
请仔细观察这个堆栈:
- 崩溃线程:Main Thread;
- 崩溃点:位于
UIKitCore内部渲染刷新(_cleanUpAfterCAFlushAndRunDeferredBlocks)时触发的libobjc的objc_retain; - 回溯栈中没有任何与检测相关的类名或函数名!
逆向分析者看到这个堆栈,第一反应绝不会怀疑“我被 App 的反调试/反插桩机制发现了”,而是会陷入深度自我怀疑:“是不是我注入的 Hook 逻辑破坏了某个 UI 组件的引用计数?”或者“这难道是 iOS 系统框架在特定机型上的并发 Bug?”
通过这种方式,我不但粉碎了攻击者的当前会话,更在心理战层面上将逆向者的排查方向引导到十万八千里之外的系统 UI 渲染死胡同中,极大抬高了攻击成本。
五、攻防升级:当逆向者使用魔改 Frida 时如何破局?
在现代高对抗场景下,职业黑客早已不再使用官方原版的 Frida,而是使用经过定制去特征化的版本(例如行业知名的 StrongR-Frida 或 HLuda)。这类工具在源码阶段就通过自动化脚本完成了全面重编译:
- 将所有
frida、gadget、gum等字符串全部替换为随机哈希或无意义字符; - 剥离所有 Vala 源码文件路径(
.vala断言日志全部清空); - 随机化 JSON-RPC 通信头部协议,抹除
frida:rpc。
面对魔改版 Frida,明文字符串匹配可能会出现漏报。因此,我通常会在内存扫描之上,追加三层更底层的系统行为与异常指纹交叉核验:
flowchart LR
subgraph MultiLayer["三维立体抗魔改矩阵"]
direction TB
L1["可执行/可写异常页扫描<br/>(RWX Memory Pages)"]
L2["未导出符号线程探查<br/>(Abnormal Threads & Pthread)"]
L3["底层跳板 Inline Hook 校验<br/>(Instruction Integrity)"]
end
L1 --> Judge["综合风险判定引擎"]
L2 --> Judge
L3 --> Judge
Judge -->|"风险阈值超标"| Poison["触发 UAF 拟态崩溃解耦逻辑"]
1. 扫描匿名 RWX 异常内存页
iOS 原生应用受到严格的 W^X(Write XOR Execute,写执行互斥)与代码签名机制保护。普通 App 的内存页要么是只读可执行(r-x,代码段),要么是可读可写不可执行(rw-,堆栈与数据段)。
Frida 要想在运行时实现动态方法拦截与 JIT 编译,必须使用 mprotect 或分配带有可写且可执行属性的内存页(rwx,或者匿名未映射到任何磁盘 Mach-O 的可执行段)。
通过在 vm_region_64 遍历过程中检查内存保护标记:
if ((info.protection & VM_PROT_READ) &&
(info.protection & VM_PROT_WRITE) &&
(info.protection & VM_PROT_EXECUTE)) {
// 发现非法 RWX 内存区域!非越狱/非合规 JIT 环境下几乎 100% 为动态插桩注入
hitCount += 5;
}
2. 异常 Pthread 线程与调用栈起止异常
Frida 在注入后,需要拉起专用的通信线程与事件循环(如 gum-js-loop)。虽然魔改版会修改线程名称,但攻击者往往难以伪造合法的系统线程生命周期:
- 遍历进程内部的所有 Mach 线程(
task_threads); - 获取每个线程的执行上下文与栈底指针;
- 如果某个线程的栈基址或入口 PC 指针并不指向 dyld 加载的任何合法动态库或主二进制地址区间内,说明存在一个无所属可疑模块运行的影子线程。
3. 系统底层核心 API 的 Inline Hook 跳板指纹检测
Frida 的 Interceptor 底层依靠在目标函数头部写入一段汇编跳板指令(Trampoline,通常为 LDR X16, #8; BR X16)来劫持控制流。
如果我对关键 C 库函数(如 dlopen、dlsym、ptrace、sysctl)的入口前 8 个字节进行签名校验:
uint32_t *func_ptr = (uint32_t *)dlsym(RTLD_DEFAULT, "dlopen");
// ARM64 下 LDR X16, #8 的机器码通常为 0x58000050,BR X16 为 0xD61F0200
if (*func_ptr == 0x58000050 || *func_ptr == 0xd61f0200) {
// 关键系统底层入口被安装了 Inline Hook 跳板!
trigger_crash_async();
}
这种基于指令完整性的探测属于物理层面的核验,无论 Frida 的字符串如何变异,只要它敢 Hook 关键 API,都会在指令级别留下不可逆的物理指纹。
六、多维防御自检清单与攻防矩阵
为了方便安全工程师在实际落地时对照自审,我将这套立体防御体系的技术特性归纳为下表:
| 防御手段 | 监控维度 | 探测开销 | 防御等级 | 常见绕过手法 | 进阶加固建议 |
|---|---|---|---|---|---|
| 端口探针 | 本地 TCP 27042 端口 | 极低(< 1ms) | L1 (初筛) | 自定义监听端口、端口反向代理 | 仅作为极速初筛,不作为独占判据 |
| 自身镜像锚定 | dyld 主镜像边界计算 | 极低(启动时一次) | 基石保障 | 无(自身环境计算) | 动态计算,杜绝写死内存基址 |
| 虚拟内存全域扫描 | 全进程 vm_region_64 |
中等(10~30ms) | L3 (核心) | 源码重编译替换字符串 | 字符串多态混淆、Aho-Corasick 多模式匹配 |
| RWX 权限异常页监控 | 内存段权限标记位 | 极低 | L4 (高危) | 仅使用普通内存页并动态交替 mprotect | 监控高频 mprotect 调用 |
| RunLoop 拟态崩溃 | UAF 内存脏写 + 事件异步 | 极低 | L5 (反制) | 逆向者需逐个 Hook 系统 UI 循环 | 随机化崩溃模式,多位置异步引爆 |
七、实战经验与工程落地的性能思考
在将上述检测逻辑推进到拥有数千万日活的真实线上 App 中时,我必须警惕一个极其致命的问题:内存全域扫描的性能与卡顿瓶颈。
一个中大型 iOS 应用在经过深度使用后,由于图片缓存、Web 容器和复杂的业务数据堆积,其虚拟内存段(VM Regions)可能多达数千个。如果直接在主线程或者高频定时器中调用 checkAppMemoryForFrida,极易引起 200ms 以上的掉帧或卡顿。
基于此,我在工程落地中总结了三条核心原则:
- 下沉至低优先级后台队列:扫描逻辑必须置于
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0)中分批执行; - 高价值时机稀疏采样:严禁采用
NSTimer每秒轮询;应仅在关键资金交易、敏感权限授权、应用自后台唤醒等核心安全卡点处进行一次异步静默采样; - 只读与匿名脏页针对性扫描:优先过滤掉体积巨大的只读文件映射段(如系统字体库、大型静态资源 Assets),重点聚焦在私有匿名内存段(
VM_MEMORY_MALLOC)与非常规映射段中,可将单次扫描耗时大幅压缩 80% 以上。
安全是一门平衡的艺术。通过“浅层端口快筛 + 深层内核内存遍历 + 拟态 Bug 异步崩溃”的三位一体策略,我在极低性能代价下,不仅能精准捕获动态插桩的蛛丝马迹,更能将逆向者的逆向定位成本提升数个数量级。
原文链接与参考资料
- 原文链接:看雪论坛 - [原创]iOS安全开发中的Frida检测(原作者:luoyanbei@360src)
- Frida 官方架构与动态插桩原理解析:Frida Documentation - Architecture
- Apple 官方 Mach 内核虚拟内存接口规范:Apple OpenSource - xnu Mach VM Subsystem
- CommonMark 与编译期安全规范:CommonMark Spec & Delimiter Flanking Rules