所有运行时攻击的前提是代码进进程,代码进进程的唯一通道是动态库注入,dyld 忠实地记录了每一笔。四路交叉验证 + 白名单基线,通杀所有基于动态库的注入。
1. 问题:你的 App 进程里可能跑着别人的代码
iOS 上任何对 App 的运行时攻击——Hook 方法、篡改数据、窃取密钥——前提都是攻击者的代码进入了你的进程。代码进进程的方式只有一种:动态库注入。dyld 作为动态链接器,在加载每个 Mach-O 时都会把它的路径和 header 地址记录在 dyld_all_image_infos 结构体里。攻击者注入的动态库也不例外——不管他用的是 DYLD_INSERT_LIBRARIES、LC_LOAD_DYLIB 篡改、dlopen 运行时加载,最终都会在 dyld 的 image 链表里留下一条记录。本文要做的事:用四种完全独立的路径去读这份 image 清单,交叉验证,建立白名单基线,让任何注入的动态库都能被检测到。
2. 为什么动态库枚举通杀所有注入攻击
iOS 上所有动态库注入,本质上是让 dyld 做同一件事:把一个额外的 Mach-O 映射进目标进程。注入方式可以不同,但最终结果都一样——dyld 的dyld_all_image_infos结构体里多了一个条目。 | | |
|---|
| 越狱 tweak 注入(Substrate / Substitute / ElleKit / Shadow) | 通过 DYLD_INSERT_LIBRARIES 或 LC_LOAD_DYLIB patch | |
| dlopen | |
| 解包 IPA → 塞入恶意 dylib → 改 Mach-O load command → 重签名 | |
| | |
攻击一个运行中的 iOS App,从技术路线上只有三个入口。逐条拆开看:路线一:扫内存替换。用 vm_write 或 mach_vm_write 直接往目标进程的地址空间写数据——把 __TEXT 段的某条指令从 MOV X0,#1改成 MOV X0,#0。或者用 vm_protect 把只读页改成可写后直接改代码。这条路不需要注入任何 dylib,因为修改的是进程里已经存在的代码。但它的前提是拿到目标进程的task port——在 iOS 上,普通 App 拿不到其他进程的 task port,只有越狱后通过 kernel patch(tfp0)或者拥有 task_for_pid-allow entitlement 的进程才行。门槛是三个入口里最高的,实战中极少有黑产能稳定走这条路。路线二:劫持系统服务。在 App 进程之外做手脚——Charles 代理抓 HTTPS、DNS 指向 127.0.0.1 做中间人、用 simulateLocation 伪造 GPS、通过 NEVPNManager 建 VPN 隧道。这条路完全不需要进入 App 进程,操作的是系统级的网络栈、定位服务、剪贴板。但它的能力边界也很明确:你只能拦截或篡改"进出 App 的数据",碰不到 App 内存里的逻辑——你改不了 onPaymentResult 里的金额、读不到 Keychain 里的密钥、Hook 不到 URLSession delegate 的回调。路线三:注入动态库。把一个 Mach-O 塞进 App 的进程地址空间——越狱 tweak 通过 DYLD_INSERT_LIBRARIES 注入、重打包通过篡改 LC_LOAD_DYLIB 植入、Frida Gadget 嵌入 IPA 包、Dobby 通过 dlopen 运行时加载。一旦进入进程,攻击代码和你的业务代码在同一个地址空间、拥有相同的权限——可以调用任何函数、读任何内存、Hook 任何方法。这条路门槛最低:越狱设备上一条 insert_dylib 命令就能注入;重打包也只需要解压 IPA → 塞 dylib → 改 load command → 重签名,全流程有现成工具。注入动态库是唯一一个**同时满足"门槛低"和"能力全"**的入口。扫码支付 SDK 被 Hook 篡改金额、社交 App 被注入脚本批量注册、游戏被植入 Dobby 加速外挂、企业 App 被重打包植入后门——实战中最高频的攻击,全部走的是第三条路。代码进进程 → 注入动态库 → image 清单多一条 → 检测 image 清单 = 通杀
不是跟攻击工具比谁的名字更难找,而是在攻击者的入场方式上设防。接下来从 dyld 加载机制开始,逐层拆解怎么拿到这份清单。
3. 四路交叉验证总览
| | | |
|---|
| _dyld_image_count | | |
| _dyld_register_func_for_add_image | | image 的 Mach-O header + slide,需配合 dladdr 取名称 |
| task_info(mach_task_self(), TASK_DYLD_INFO, ...) | | all_image_info_addr → 指向 dyld 内部结构体,全量 image 数组 |
| header 地址范围判断(0x180000000 ~ 0x200000000) | | 区分 shared cache image vs 非缓存 image,快速定位注入 |
四条路在 iOS 上都可用,代码也都不长。下面逐个给出完整实现。
4. 方法一:dyld 公开 API
#include// 返回当前已加载的 image 总数uint32_t _dyld_image_count(void);// 返回索引 i 对应 image 的路径(dyld 持有该字符串,不要 free)const char *_dyld_get_image_name(uint32_t image_index);// 返回索引 i 对应 image 的 Mach-O header 指针const struct mach_header *_dyld_get_image_header(uint32_t image_index);
三个函数的使用足够直白——循环遍历然后把路径和 header 打出来:// method1_dyld_api.c — 方法一:dyld 公开 API#include#includevoidprint_all_images_via_dyld_api(void){ uint32_t count = _dyld_image_count(); printf(”Total images: %u\n”, count); for (uint32_t i = 0; i < count; i++) { const char *name = _dyld_get_image_name(i); const struct mach_header *header = _dyld_get_image_header(i); if (!name) continue; printf(”[%3u] %s (header=%p)\n”, i, name, (void *)header); }}
Total images: 427[ 0] /usr/lib/dyld (header=0x100b28000)[ 1] /var/containers/Bundle/Application/.../MyApp.app/MyApp (header=0x100b24000)[ 2] /usr/lib/libobjc.A.dylib (header=0x1a7c00000)[ 3] /System/Library/Frameworks/Foundation.framework/Foundation (header=0x1a9000000)[ 4] /System/Library/Frameworks/UIKit.framework/UIKit (header=0x1b2000000)...[426] /usr/lib/system/libsystem_kernel.dylib (header=0x1d5000000)
5. 方法二:dyld 回调注册
_dyld_register_func_for_add_image 注册一个函数指针。dyld 在每次新 image 加载完成后调用它。关键特性:注册时已经存在于内存中的所有 image 也会逐一触发一次回调——相当于拿到了启动至今的完整加载记录。#includevoid _dyld_register_func_for_add_image( void (*func)(const struct mach_header *mh, intptr_t vmaddr_slide));
注意:回调只给你 mach_header* 和 slide,不直接给路径。要拿路径,必须用 dladdr:// method2_callback.c — 方法二:dyld 回调注册#include#include#include#includestatic int g_callback_image_count = 0; // 供交叉验证读取// 回调:每加载一个 image(包括已存在的)触发一次staticvoidimage_callback(conststruct mach_header *mh, intptr_t slide){ g_callback_image_count++; Dl_info info; if (dladdr(mh, &info) != 0 && info.dli_fname != NULL) { printf(” [callback] %s (slide=0x%lx, mh=%p)\n”, info.dli_fname, (long)slide, (void *)mh); }}intget_callback_image_count(void){ return g_callback_image_count;}// 在 +load 或 constructor 里尽早注册__attribute__((constructor))staticvoidsetup_image_monitor(void){ printf(”--- Registering dyld callback ---\n”); _dyld_register_func_for_add_image(image_callback);}
--- Registering dyld callback --- [callback] /var/containers/.../MyApp.app/MyApp (slide=0x3c8000, mh=0x100b24000) [callback] /usr/lib/dyld (slide=0x0, mh=0x100b28000) [callback] /usr/lib/libobjc.A.dylib (slide=0x0, mh=0x1a7c00000) [callback] /System/Library/Frameworks/Foundation.framework/Foundation (slide=0x0, mh=0x1a9000000) ... (注册后新加载的 image 会实时触发回调)
6. 方法三:Mach task_info — 从内核拿数据
前两种方法都走 dyld 的用户态代码。方法三彻底换了一条路:直接调 Mach 内核的task_info,拿到TASK_DYLD_INFO。dyld 在初始化时,通过 task_set_info 把自己的 all_image_info_addr 注册到了内核的 task 结构中。之后任何人(包括进程自身)都可以通过 task_info 读出这个地址——不需要经过任何 dyld 函数。// method3_task_info.c — 方法三:通过 Mach task_info 获取 dyld image 信息#include#include#include#include#includevoidprint_all_images_via_task_info(void){ // 第一步:用 task_info 拿到 task_dyld_info struct task_dyld_info dyld_info = {0}; mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT; kern_return_t kr = task_info( mach_task_self(), // 目标 task = 自己 TASK_DYLD_INFO, // flavor (task_info_t)&dyld_info, // 输出 &count ); if (kr != KERN_SUCCESS) { printf(”task_info(TASK_DYLD_INFO) failed: %d\n”, kr); return; } // dyld_info.all_image_info_addr 指向进程内的 // struct dyld_all_image_infos 结构体 uint64_t addr = dyld_info.all_image_info_addr; if (addr == 0) { printf(”all_image_info_addr is NULL — dyld 尚未初始化\n”); return; } printf(”dyld_all_image_infos @ 0x%llx\n”, addr); // 第二步:读取那个地址处的 dyld_all_image_infos 结构体 // 由于我们在自己的进程内,直接解引用指针即可 struct dyld_all_image_infos *infos = (struct dyld_all_image_infos *)(uintptr_t)addr; uint32_t version = infos->version; uint32_t count = infos->infoArrayCount; printf(”dyld version: %u, image count: %u\n”, version, count); // 第三步:遍历 infoArray const struct dyld_image_info *array = infos->infoArray; if (!array) { printf(”infoArray is NULL\n”); return; } for (uint32_t i = 0; i < count; i++) { const char *path = array[i].imageFilePath; const void *hdr = (const void *)array[i].imageLoadAddress; if (path) { printf(”[%3u] %s (mach_header=%p)\n”, i, path, hdr); } else { printf(”[%3u] (no path) mach_header=%p\n”, i, hdr); } }}
关键结构体定义(来自 ,iOS/Mac SDK 自带):// /usr/include/mach-o/dyld_images.hstruct dyld_image_info { const struct mach_header *imageLoadAddress; // image 在内存中的地址 const char *imageFilePath; // 文件路径 uintptr_t imageFileModDate; // 文件修改时间};struct dyld_all_image_infos { uint32_t version; // dyld 版本号 uint32_t infoArrayCount; // image 总数 const struct dyld_image_info *infoArray; // image 数组 // ... 后面还有更多字段(dyldImageLoadAddress, jitInfo 等)};// TASK_DYLD_INFO 用的结构体struct task_dyld_info { mach_vm_address_t all_image_info_addr; // ← 这就是我们要的地址 mach_vm_size_t all_image_info_size;};
四套方案拿到的是同一份数据吗?是的。_dyld_image_count 内部就是读 dyld_all_image_infos.infoArrayCount。方法三绕过了 dyld 的函数包装,直接对着内核说"把 dyld 注册给你的那个地址给我"——task_info 是纯 Mach 系统调用。
7. 四条路的关系
方法三走 Mach 内核调用,问的是内核 task 结构体里 dyld 注册的地址第四路不调任何函数——直接看 Mach-O header 地址落在哪个地址区间,区分 shared cache 系统库和第三方 dylib四条路最终都指向 dyld_all_image_infos 这个结构体,但入口各不相同,因此可以交叉验证
8. 拿到四份结果之后:交叉验证
四种方式都能拿到 image 清单,但每一条路都可能被单独干扰——Hook dyld API 函数、拦截 Mach 系统调用返回值、改内存中的结构体数据。单独信任何一路都有风险,四路结果互相验证才是正确用法。核心思路:四路各数一遍 infoArrayCount。如果结果不一致,至少有一路被干扰。// cross_validate.c — 四路交叉验证#include#include#include#include#includetypedef struct { uint32_t api_count; // 方法一的计数 uint32_t callback_count; // 方法二的计数 uint32_t task_info_count; // 方法三的计数 bool api_vs_task_mismatch; bool callback_vs_task_mismatch;} dyld_consistency_t;dyld_consistency_tcheck_dyld_consistency(void){ dyld_consistency_t result = {0}; // 1. 方法一:dyld API result.api_count = _dyld_image_count(); // 2. 方法二:从回调计数器读取 result.callback_count = get_callback_image_count(); // 3. 方法三:task_info → dyld_all_image_infos → infoArrayCount struct task_dyld_info dyld_info = {0}; mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT; kern_return_t kr = task_info(mach_task_self(), TASK_DYLD_INFO, (task_info_t)&dyld_info, &count); if (kr == KERN_SUCCESS && dyld_info.all_image_info_addr != 0) { struct dyld_all_image_infos *infos = (struct dyld_all_image_infos *)(uintptr_t)dyld_info.all_image_info_addr; result.task_info_count = infos->infoArrayCount; } // 4. 交叉比对 result.api_vs_task_mismatch = (result.api_count != result.task_info_count && result.task_info_count > 0); result.callback_vs_task_mismatch = (result.callback_count != result.task_info_count && result.task_info_count > 0); return result;}
光比数量不够——攻击者可以 Hook _dyld_get_image_name 让某个 dylib 从输出里消失,但用其他 image 的名字补位,总数不变。所以需要对具体路径做交集:// 检查某个疑似 dylib 在四路中的出现情况// 方法一走 _dyld_get_image_name,方法三走 infoArray[i].imageFilePath// 如果方法一查不到但方法三查到了 → 方法一的 API 被 Hookbooldylib_visible_in_api(constchar *target_path){ uint32_t count = _dyld_image_count(); for (uint32_t i = 0; i < count; i++) { const char *name = _dyld_get_image_name(i); if (name && strcmp(name, target_path) == 0) return true; } return false;}booldylib_visible_in_task_info(constchar *target_path){ struct task_dyld_info info = {0}; mach_msg_type_number_t cnt = TASK_DYLD_INFO_COUNT; if (task_info(mach_task_self(), TASK_DYLD_INFO, (task_info_t)&info, &cnt) != KERN_SUCCESS) return false; if (info.all_image_info_addr == 0) return false; struct dyld_all_image_infos *infos = (struct dyld_all_image_infos *)(uintptr_t)info.all_image_info_addr; for (uint32_t i = 0; i < infos->infoArrayCount; i++) { const char *name = infos->infoArray[i].imageFilePath; if (name && strcmp(name, target_path) == 0) return true; } return false;}// 交叉验证某个可疑路径// 返回 0 = 四路一致(都没看到),// 1 = 四路一致(都看到了),// 2 = 矛盾(方法一看不到但方法三看到了,疑似 Hook)intcross_check_single_dylib(constchar *path){ bool in_api = dylib_visible_in_api(path); bool in_task = dylib_visible_in_task_info(path); if (in_api == in_task) return in_api ? 1 : 0; // 方法三能看到,方法一不能 → 方法一的 API 链被劫持 if (!in_api && in_task) return 2; return 0;}
用法:对已知敏感路径(如 /usr/lib/libsubstitute.dylib)调用 cross_check_single_dylib,返回值 2 直接触发高风险标记。
9. 白名单策略:甩掉 shared cache,只盯非缓存区
拿到 400+ 个 image 之后,真正的问题是:哪些是该有的,哪些是多出来的?黑名单(Substrate、Substitute、Shadow……)的致命缺陷是攻击者改个名字就废了。正确的做法是给"正常状态"建一份白名单,多出来的就是注入。9.1 用 shared cache 消除 95% 的系统版本差异iOS 在不同机型、不同系统版本上加载的系统库集合不同。如果白名单里逐条列出 libobjc.A.dylib、libsystem_kernel.dylib……这个名单本身就会随版本漂移,维护成本不可控。但有个简单的解法:所有系统库都在 dyld shared cache 里。所以按地址区间放行全部 shared cache image,一条都不用记录:staticboolis_in_shared_cache(constvoid *mach_header_addr){ uintptr_t addr = (uintptr_t)mach_header_addr; return (addr >= xxx && addr < xxx);}
客户端本地存白名单有两个致命问题:一是越狱环境下攻击者可以直接读取甚至篡改本地存储的白名单数据;二是同一个 App 版本在不同设备上部署时,每个设备都要各自建一份。正确做法:端侧只负责采集,白名单的存储和比对全部放在服务端。首次启动时,端侧收集非缓存 image 的特征,加密上报:// 端侧:采集非缓存 image 的特征,上报服务端voidreport_non_cache_images_to_server(void){ struct task_dyld_info info = {0}; mach_msg_type_number_t cnt = TASK_DYLD_INFO_COUNT; if (task_info(mach_task_self(), TASK_DYLD_INFO, (task_info_t)&info, &cnt) != KERN_SUCCESS) return; struct dyld_all_image_infos *infos = (struct dyld_all_image_infos *)(uintptr_t)info.all_image_info_addr; if (!infos || !infos->infoArray) return; // 构造上报 payload:路径列表 + 哈希列表 for (uint32_t i = 0; i < infos->infoArrayCount; i++) { const void *hdr = (const void *)infos->infoArray[i].imageLoadAddress; const char *path = infos->infoArray[i].imageFilePath; if (!path) continue; // shared cache 的不上报——服务端自动放行 if (is_in_shared_cache(hdr)) continue; // 非缓存 image:记录路径 + 首页哈希,加入上报队列 uint64_t hash = siphash24(hdr, 4096, kSipHashKey0, kSipHashKey1); // sdk_enqueue_whitelist_report(path, hash); } // sdk_flush_whitelist_report(); // 加密上报到服务端}
服务端收到后,按 (app_version, device_model, ios_version) 三维度存储。同一维度下来自不同设备的多次上报做交叉验证——如果 100 台设备都报告了 MyApp.app/Frameworks/libUtils.dylib,它大概率是合法的 embedded framework;如果只有 1 台设备报告了某个路径,就需要标记为异常。之后每次启动,端侧同样采集非缓存 image 的特征,加密上报。服务端收到后做两件事:跟自己存储的同维度白名单做比对——不在白名单里 = 注入如果在白名单里但哈希不匹配——dylib 被篡改,重打包注入// 端侧:每次启动采集并上报,服务端负责比对voidreport_runtime_images_for_audit(void){ // ... 同上采集 logic ... for (uint32_t i = 0; i < infos->infoArrayCount; i++) { const void *hdr = (const void *)infos->infoArray[i].imageLoadAddress; const char *path = infos->infoArray[i].imageFilePath; if (!path || is_in_shared_cache(hdr)) continue; uint64_t hash = siphash24(hdr, 4096, kSipHashKey0, kSipHashKey1); // sdk_enqueue_audit_report(path, hash); } // sdk_flush_audit_report(); // 加密上报}
for each (path, hash) in request: if path not in whitelist: → 标记:注入的 dylib elif hash != whitelist[path]: → 标记:dylib 被篡改 else: → 正常if request 里有路径不在本次上报中: → 可能 dylib 被卸载或上报被截断,降低置信度但不直接判定
这套逻辑的核心优势:白名单在服务端,攻击者无法在端侧篡改——他改不了服务器上的数据。服务端还可以跨设备交叉验证、按版本灰度更新白名单、识别出"某个设备总是比别人多几条 dylib"这种异常模式。
10. 四路交叉验证 + 服务端白名单:完整检测管线
把枚举、shared cache 过滤、交叉验证、服务端白名单串起来:首次启动(建白名单)— 方法三枚举 → 过滤 shared cache → 非缓存 image 的路径 + 哈希加密上报 → 服务端按 (app_version, model, ios_version) 存储这条管线通杀所有注入的核心原因:白名单在服务端,攻击者无法篡改;shared cache 自动放行,消除机型/版本差异;四路交叉验证发现 Hook 痕迹。不管攻击工具叫什么、dylib 改了什么名字——只要多了一个 Mach-O,服务端就看不到它在白名单里。
11. 结论
攻击者可以 Hook _dyld_get_image_name 返回假路径,可以改 _dyld_image_count 的返回值,可以把注入的 dylib 重命名成系统库的名字。但他做不到把已经 mmap 到进程地址空间的 Mach-O 从 dyld 的内部链表里移除——除非他有内核漏洞。应对策略不是"防止 Hook",而是从四条完全独立的路径去读同一份数据——方法一和方法二走 dyld 用户态,可以被 Hook;方法三直接问内核的 task port,不经过任何用户态函数;第四路看地址区间,不调任何 API。四路结果交叉验证——不一致本身就是检测信号,不需要知道谁在撒谎、怎么撒的。
12. 参考资料
[1] Apple dyld(3) Manual Page — _dyld_image_count, _dyld_register_func_for_add_image[2] dyld 源码 —https://github.com/apple-oss-distributions/dyld[3] XNU 源码 — task_info / TASK_DYLD_INFO,https://github.com/apple-oss-distributions/xnu[4] — dyld_all_image_infos, dyld_image_info, task_dyld_info[5] mach-o/loader.h, mach-o/nlist.h — Mach-O 格式定义
对 iOS 动态库加载机制、dyld 内部实现或 Mach 内核编程感兴趣,欢迎深入探讨。读完本文,如果你希望把零散的知识点串成完整的攻防体系,可以系统学习《iOS逆向安全从入门到攻防实战》: | |
|---|
| 逆向基础、越狱、环境搭建、Hook 入门,动手修改 IDFA / IDFV |
| MachOView / Hopper / IDA Pro 工具链、Mach-O 格式精讲、脱壳、反编译分析 |
| Method Swizzling / FishHook / Dobby / Frida 四套 Hook 方案,覆盖 OC 方法、C 函数、符号表、运行时注入 |
| 非 MonkeyDev 重打包、注入动态库、越狱 Tweak 插件开发、Tweak 与 App 通信 |
| 虚拟相机(替换摄像头帧 + 预览视频)、虚拟定位(开发者方式 / Hook / 注入三类方案) |
| Method Swizzling 检测、Got 表 Hook 检测、InlineHook 检测、重打包检测 |
| |