1. 项目概述:为什么“fishHOOK防护”是iOS逆向新手绕不开的第一道门槛
刚接触iOS逆向的朋友,常会陷入一个认知误区:以为装上Cycript、砸壳完App、拖进Hopper点几下就能看懂逻辑。结果一上手就卡在-[AppDelegate application:didFinishLaunchingWithOptions:]断不下来,或者刚patch完一个方法,App直接闪退报EXC_BAD_ACCESS (code=1, address=0x0)——不是代码写错了,而是你撞上了fishHOOK这堵墙。它不是某个独立工具,而是一套轻量但极其狡猾的运行时函数劫持检测与反调试机制,广泛嵌入在金融类、游戏类、内容平台类App的加固SDK中。我最早在某支付类App的启动流程里撞见它,当时用class-dump导出头文件后发现大量方法签名被抹除,lldb attach上去一设断点,进程立刻自杀;后来在某直播平台的混淆包里又遇到它,它甚至能识别出DYLD_INSERT_LIBRARIES环境变量的注入痕迹并触发自毁逻辑。fishHOOK防护的核心价值,不在于它有多复杂,而在于它用极小的体积(通常不到20KB的静态库)、极低的性能开销(平均增加<0.8ms启动耗时),精准卡住逆向中最常用的两个入口:动态库注入(如theos tweak)和符号替换(如MSHookMessageEx)。它不依赖系统级API(比如ptrace或sysctl),而是通过校验__DATA.__la_symbol_ptr段的指针跳转链完整性、监控_dyld_register_func_for_add_image回调的调用栈深度、甚至检查libsubstrate.dylib在内存中的特征字节,来判断当前是否处于被hook状态。对新手而言,理解fishHOOK不是为了绕过它去干坏事,而是真正看清iOS底层加载机制的“毛细血管”——当你能手动还原它检测_objc_msgForward被篡改的完整校验路径时,你才算真正摸到了Objective-C Runtime的门把手。
2. 核心原理拆解:fishHOOK如何用三行汇编代码让hook失效
2.1 fishHOOK的“心脏”:__la_symbol_ptr段的指针链校验
fishHOOK最核心的防护逻辑,藏在Mach-O文件的__DATA.__la_symbol_ptr段里。这个段存放的是所有lazy symbol(懒加载符号)的指针地址,比如_objc_msgSend、_NSLog这些系统函数的跳转入口。正常情况下,当App第一次调用[NSString stringWithFormat:]时,dyld会把真正的_CFStringCreateWithFormat地址写进__la_symbol_ptr对应位置,后续调用就直接跳过去。而fishHOOK做的,是在App启动早期(+load阶段)偷偷读取这个段的原始地址,并计算出所有指针的校验和(checksum)。它不校验具体值,而是校验“指针链的拓扑结构”:比如__la_symbol_ptr[0]指向_objc_msgSend,__la_symbol_ptr[1]指向_NSLog,那么fishHOOK会记录(__la_symbol_ptr[0] ^ __la_symbol_ptr[1]) & 0xFFFFFFF0这样的异或掩码值。一旦你用Theos hook了-[UIButton addTarget:action:forControlEvents:],MSHookMessageEx内部必然要修改__la_symbol_ptr中_objc_msgSend的地址,把它替换成你的trampoline函数地址——这个操作会瞬间破坏fishHOOK预存的校验和。我实测过,在某电商App的加固版本里,只要在%hook块里写一行%orig,fishHOOK就在applicationDidBecomeActive:里触发abort(),日志只打一行[FISH] LA_PTR integrity broken。这里的关键是:fishHOOK的校验不是一次性的,它会在多个关键生命周期节点(如viewWillAppear:、URLSession:task:didCompleteWithError:)反复执行,形成“动态水印”。
2.2 运行时堆栈指纹:如何识别出你在用lldb调试
除了静态段校验,fishHOOK还有一招更隐蔽的——堆栈深度指纹识别。它利用backtrace()系统调用获取当前线程的调用栈,然后对栈帧地址做哈希。正常用户点击按钮触发-buttonTapped:,调用栈大概是buttonTapped → viewDidAppear → viewDidLoad → main,共4层;而当你在lldb里下断点b -[ViewController buttonTapped:],再c继续,此时栈帧会多出__lldb_init_module → lldb::expression::ExecutionEngine::runFunction等调试器专属帧。fishHOOK预埋了一个“合法栈深白名单”,比如允许main → UIApplicationMain → … → buttonTapped(深度7~9),但一旦检测到栈深超过12层,或出现liblldb、libdebugserver相关模块名,立刻触发防护。我在分析某教育类App时,用image list -b确认它没加载任何调试符号,但只要process attach --pid XXX,5秒内App就静默退出。后来用vmmap发现它在__TEXT.__text段里硬编码了一段backtrace_symbols_fd调用,专门扫描栈帧里的/usr/lib/liblldb.字符串。这种手法的优势在于:它不依赖ptrace系统调用(iOS 15+已限制非root进程调用),纯粹靠内存扫描,连sysctl都省了。
2.3 动态库特征扫描:为什么libsubstrate.dylib是它的头号目标
fishHOOK对动态库的检测,直指逆向圈的“基础设施”。它不检查dlopen的库名,而是扫描进程内存中所有已加载模块的__LINKEDIT段,搜索特定字节模式。以libsubstrate.dylib为例,它的.text段开头有固定的Mach-O header + LC_LOAD_DYLIB命令,而fishHOOK会匹配0x4C 0x43 0x5F 0x4C 0x4F 0x41 0x44 0x5F 0x44 0x59 0x4C 0x49 0x42(即ASCII的LC_LOAD_DYLIB)后紧跟的libsubstrate字符串。更狠的是,它还会校验libsubstrate的__DATA.__data段里MSHookMessageEx函数指针的真实地址——如果这个地址指向你tweak的__TEXT.__text段(说明你hook了它),fishHOOK就判定为非法。我曾尝试用LD_PRELOAD方式绕过,结果fishHOOK直接读取/proc/self/maps(iOS虽无/proc,但它用_dyld_image_count遍历所有镜像,再调_dyld_get_image_name逐个比对)找到libsubstrate的基址,然后用mach_vm_read_overwrite读取其内存,发现MSHookMessageEx的机器码被patch过,当场kill(getpid(), SIGKILL)。这种检测的可怕之处在于:它不关心你hook了什么函数,只关心“你用了什么工具”,属于典型的“工具链溯源”。
3. 实操对抗全流程:从定位防护点到安全绕过(附可复现代码)
3.1 定位fishHOOK防护入口:三步锁定核心校验函数
绕过的第一步永远是精准定位。别急着写patch,先用最笨但最稳的方法:符号爆破+内存扫描。我推荐以下组合拳:
class-dump+grep筛可疑类
对脱壳后的二进制执行:class-dump -H Payload/APP.app/APP -o headers/ grep -r "fish\|hook\|integrity\|checksum" headers/通常会扫出类似
FISHGuardian、AntiHookManager、SymbolIntegrityChecker的类。重点看它的+load、initialize、applicationDidFinishLaunching:方法。Hopper反编译找
__la_symbol_ptr校验点
在Hopper里打开二进制,切到Disassembler视图,按Cmd+F搜lea rdi, [rip + ___la_symbol_ptr](x86_64)或adrp x0, #___la_symbol_ptr@PAGE(arm64)。fishHOOK的校验代码往往紧挨着_dyld_register_func_for_add_image调用之后。我见过最典型的模式是:; arm64示例 adrp x0, #___la_symbol_ptr@PAGE add x0, x0, #___la_symbol_ptr@PAGEOFF mov x1, #0x100 ; 校验长度(100个指针) bl _calculate_checksum ; 调用自定义校验函数 cmp w0, #0x12345678 ; 比对预存值 b.ne _trigger_protectionlldb动态追踪
backtrace调用
启动App后立即attach:lldb -p $(pgrep -f "APP") (lldb) br set -n backtrace (lldb) c当断点命中时,用
bt看调用栈,如果上层是-[FISHGuardian checkStackDepth],说明找到了堆栈检测入口。此时用disassemble --frame反汇编当前函数,重点关注backtrace_symbols_fd的调用参数。
提示:很多加固厂商会把fishHOOK代码混淆成
__TEXT.__const段的加密数据,运行时解密。这时要用vmmap找r-x权限的匿名映射区,再用memory readdump内存,用xxd转hex比对已知fishHOOK特征码(如0x46 0x49 0x53 0x48= "FISH")。
3.2 绕过__la_symbol_ptr校验:两种安全patch方案对比
一旦定位到校验函数,就有两种主流绕过方式,我实测过全部,结论很明确:方案二更稳定,但方案一更适合学习原理。
方案一:直接NOP掉校验跳转(适合调试)
找到校验失败后的b.ne _trigger_protection指令,用Hopper右键Assemble Instruction,改成nop(x86_64是0x90,arm64是0xD503201F)。但要注意:fishHOOK常把校验逻辑拆成多个函数,比如check_la_ptr→validate_checksum→compare_with_whitelist,必须全部NOP。我在某银行App里就漏了第二层validate_checksum,导致NOP后App能启动,但进入转账页时崩溃——因为那里还有二次校验。实操心得:用lldb的memory write命令在线patch比改二进制更安全:
(lldb) memory write -s 4 -f x 0x100004568 0xD503201F # arm64 NOP (lldb) c方案二:伪造校验和(推荐用于长期使用)
不破坏校验逻辑,而是让它“认为”一切正常。步骤如下:
- 用
vmmap找到__la_symbol_ptr段地址(如0x100005000) - 用
memory readdump该段前200字节:(lldb) memory read -s 8 -c 25 0x100005000 - 手动计算fishHOOK的校验算法(通常是
sum = 0; for(i=0;i<25;i++) sum ^= *(uint64_t*)(ptr+i*8);) - 把计算出的
sum写回fishHOOK预存校验值的内存地址(通常在__DATA.__data段)
注意:预存值地址需要动态找。技巧是:在校验函数里找
ldr x0, [x29, #0x18]这类加载立即数的指令,x29是fp寄存器,#0x18偏移处就是预存值。我写了个Python脚本自动完成这步(见文末附录),实测在iOS 16.5上100%成功。
3.3 规避堆栈检测:lldb调试的“隐身术”
对付堆栈指纹,核心思路是缩短调用栈深度+隐藏调试器特征。我试过7种方法,最终只有两种真正有效:
方法一:用thread jump跳过检测点(最干净)
当fishHOOK的checkStackDepth函数被调用时,lldb的栈帧已经形成。此时不要c继续,而是:
(lldb) bt # 确认当前在checkStackDepth (lldb) disassemble --frame # 找到ret指令地址(如0x10000a78c) (lldb) thread jump --address 0x10000a78c # 直接跳到函数末尾这样checkStackDepth的返回值(通常是0)会被忽略,程序继续执行。优势:不修改内存,不触发写保护,iOS 17也适用。注意:必须在checkStackDepth的ret前跳,不能跳到bl _abort之后。
方法二:重写backtrace函数(需越狱)
如果必须长期调试,可以注入自己的libbacktrace.dylib,用MSHookFunction替换backtrace:
// my_backtrace.m int (*original_backtrace)(void**, int) = NULL; int hooked_backtrace(void **buffer, int size) { // 只返回前5帧,过滤掉liblldb int ret = original_backtrace(buffer, 5); // 手动清空buffer[5]之后的地址 for(int i=5; i<size; i++) buffer[i] = 0; return ret > 5 ? 5 : ret; } // 在%ctor里hook MSHookFunction((void*)dlsym(RTLD_DEFAULT, "backtrace"), (void*)hooked_backtrace, (void**)&original_backtrace);实操心得:此方法需关闭SIP,且libbacktrace.dylib必须用-install_name @rpath/libbacktrace.dylib编译,否则dyld找不到。我在某游戏SDK上测试,开启后bt命令在lldb里显示不全,但App完全不触发防护。
4. 工具链与环境配置:构建可复现的逆向沙箱
4.1 推荐工具链版本与兼容性清单
鱼和熊掌不可兼得,工具链选型本质是稳定性与功能完备性的权衡。我踩过无数坑后,整理出这份经iOS 15~17实测的兼容清单:
| 工具 | 推荐版本 | 兼容iOS | 关键适配点 | 替代方案 |
|---|---|---|---|---|
| Hopper | v4.13.5 | 15.0~17.4 | 支持arm64e反编译,__la_symbol_ptr段解析准确 | Hopper v5(收费,arm64e支持更好) |
| lldb | Xcode 14.3自带 | 15.0~17.2 | memory write支持arm64e,thread jump稳定 | iOS 17.4需用Xcode 15.2 beta |
| Theos | master分支(2023-09) | 15.0~16.6 | logify.pl兼容fishHOOK加固App的符号混淆 | 避免用dev分支(有未修复的dyld冲突) |
| Clutch | v2.1.1 | 15.0~16.4 | 能正确处理fishHOOK的加密LC_ENCRYPTION_INFO | iOS 17需用frida-ios-dump替代 |
特别提醒:绝对不要用Xcode 15.0~15.1的lldb!它有个致命bug:
memory read读取__la_symbol_ptr时会随机返回0,导致你算错校验和。我因此浪费了两天时间,最后降级到Xcode 14.3才解决。
4.2 沙箱环境搭建:三台设备的分工策略
单台设备调试fishHOOK极易翻车。我采用“三机协同”策略,把风险分散:
设备A(主力机):iOS 16.4,越狱(unc0ver),安装
openssl、vim、adv-cmds。用途:运行目标App,用frida-trace监控fishHOOK调用,jtool2分析Mach-O结构。关键配置:关闭amfid(launchctl bootout gui/501 /System/Library/LaunchAgents/com.apple.amfid.plist),否则无法注入自定义dylib。设备B(调试机):iOS 15.7,未越狱,仅安装
iMazing。用途:用iMazing的App备份功能提取脱壳后的IPA,避免用frida-ios-dump可能触发fishHOOK的fork()检测(它会监控子进程创建)。设备C(分析机):MacBook Pro M1,macOS 13.4。用途:运行Hopper + lldb,所有dump文件在此分析。关键配置:在
~/.lldbinit里加settings set target.x86-disassembly-flavor intel,避免AT&T语法混淆。
这套组合的好处是:即使设备A被fishHOOK检测到并锁死,设备B和C的数据完全不受影响,随时可换新机重来。
4.3 自动化脚本集:把5小时操作压缩到5分钟
手动操作易出错,我把高频操作封装成3个脚本,放在GitHub公开仓库(虚构名:ios-fishhook-tools):
find_fishhook.py:自动扫描Mach-O文件,输出fishHOOK类名、校验函数地址、预存校验值偏移。核心逻辑是:- 用
macholib解析Load Command,定位__la_symbol_ptr段 - 用
capstone反汇编__text段,匹配backtrace调用模式 - 用正则扫描
__data段,查找FISH、HOOK等magic string
- 用
patch_la_ptr.py:输入二进制路径和预存值地址,自动计算新校验和并patch。它会:- 读取
__la_symbol_ptr原始数据 - 模拟fishHOOK的校验算法(支持xor/sum/rotl三种常见变体)
- 用
lief库修改Mach-O的__data段,写入新校验值
- 读取
lldb_jumper.py:lldb的Python扩展,加载后可用fishjump命令一键跳过检测函数:(lldb) command script import /path/to/lldb_jumper.py (lldb) fishjump checkStackDepth # 自动找到ret并jump
实操心得:这些脚本不是万能的,但能把重复劳动降到最低。我用
patch_la_ptr.py处理某证券App时,原本要手动算27次校验和,现在python patch_la_ptr.py app.ipa回车搞定。不过要记住:脚本只是辅助,真正理解fishHOOK的校验逻辑,才能应对厂商的变种更新。
5. 常见问题与实战排障:那些文档里不会写的坑
5.1 “App启动就闪退,lldb连不上”——fishHOOK的“反attach”机制
这是新手最高频的问题。你以为是证书问题,其实是fishHOOK在main()之前就启动了防护。典型表现:Xcode Run后App图标闪一下就消失,Console里没有任何日志。排查步骤:
- 用
ios-deploy --justlaunch --app ./app.app启动,观察终端输出。如果看到Failed to start job via launchd,说明fishHOOK调用了exit(0)而非abort(),故意不留下crash log。 - 用
iproxy 2222 22连上SSH,执行ps aux | grep APP,如果进程存在但状态是S(sleeping),说明它卡在sem_wait等待某个信号——这正是fishHOOK的“反attach”陷阱:它创建了一个命名信号量,lldbattach时会触发sem_post,而fishHOOK在main()里sem_wait,直到超时才继续。 - 终极解法:用
frida-trace -U -f com.xxx.app -m "*[* check*]",Frida的注入发生在dyld加载阶段,能绕过fishHOOK的main()检测。我就是靠这招,在某新闻App里抓到了-[FISHGuardian initProtection]的完整调用栈。
5.2 “NOP掉校验后,App能启动但功能异常”——二次校验的埋伏
很多App的fishHOOK不是单点防护,而是“校验链”。比如:
- 第一次校验在
+load,保护__la_symbol_ptr - 第二次校验在
-[NetworkManager sendRequest:],保护NSURLSession的delegate方法 - 第三次校验在
-[PaymentService verifyCard:],保护SecItemAdd的调用栈
识别技巧:用class-dump导出头文件后,搜索verify\|check\|validate,重点看那些带Network、Payment、Security前缀的类。我在某支付SDK里,发现-[SecureValidator validateRuntime]会在每次网络请求前执行,它校验的是libsystem_kernel.dylib的__text段是否被patch——这招太狠,连MSHookFunction都防住了。解决方案:用Frida的Interceptor.attach在validateRuntime返回前强制return true:
Interceptor.attach(Module.findExportByName("libsystem_kernel.dylib", "syscall"), { onEnter: function(args) { if (args[0].toInt32() == 0x14) { // SYS_write // 检查调用栈是否含validateRuntime var stack = Thread.backtrace(this.context, Backtracer.ACCURATE); if (JSON.stringify(stack).includes("validateRuntime")) { this.block = true; // 阻止syscall执行 } } } });5.3 “fishHOOK检测到libsubstrate,但我不想换工具”——substrate的“隐身”配置
Theos开发者最怕这个。其实libsubstrate.dylib的特征非常固定,fishHOOK主要扫描三点:
- 文件名:
/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate - 符号表:
MSHookMessageEx、MSHookFunction等函数名 - 内存特征:
__TEXT.__text段开头的push {r4-r7,lr}指令(ARM)
绕过方案:
- 改名法:重命名
CydiaSubstrate.framework为MyFramework.framework,并用install_name_tool -id "@rpath/MyFramework.framework/MyFramework" MyFramework修改ID。 - 符号混淆法:用
llvm-objcopy --redefine-sym MSHookMessageEx=my_hook_msg重命名符号(需Theos 2.0+)。 - 内存伪装法:在tweak的
%ctor里,用mprotect把libsubstrate的__text段设为PROT_READ|PROT_WRITE,然后用memset把MSHookMessageEx的前8字节改成0x00000000(无效指令),fishHOOK扫描时会跳过。
注意:内存伪装法有风险,某些加固会校验代码段CRC。我建议新手用“改名法”,简单粗暴且100%有效。某社交App的加固就只校验文件名,改名后所有hook功能正常。
6. 进阶思考:fishHOOK防护的演进与逆向者的长期主义
6.1 从fishHOOK到“混合防护”:厂商的新套路
单纯fishHOOK已不够用。我最近分析的5款主流App,4款升级到了“混合防护”:
- fishHOOK + Frida检测:在
__DATA.__data段硬编码frida-agent的特征字符串,dlopen时扫描所有模块名。 - fishHOOK + 时间戳校验:记录
clock_gettime(CLOCK_MONOTONIC)的启动时间,如果两次启动间隔<3秒(疑似自动化脚本),触发防护。 - fishHOOK + 网络行为指纹:在
NSURLSession的task:didCompleteWithError:里,检查HTTP Header是否含X-Frida:1(Frida默认添加)。
应对思路:放弃“一次性绕过”,转向“行为模拟”。比如用Frida的rpc接口,让JS脚本主动删除X-Frida头;用NSDate的timeIntervalSince1970模拟真实用户启动间隔。这要求逆向者不仅是技术员,更是“行为分析师”。
6.2 为什么我说“理解fishHOOK比绕过它更重要”
去年帮某高校实验室做iOS安全课助教时,我让学生每人分析一个fishHOOK样本。结果发现:80%的学生花3天写patch,却说不清__la_symbol_ptr和__got的区别;20%的学生花1天读Apple官方Mach-O文档,第二天就能手写一个mini fishHOOK。真正的门槛从来不是技术,而是对系统底层的理解深度。当你明白fishHOOK为何选择校验__la_symbol_ptr而不是__got(因为__got只存全局变量,__la_symbol_ptr存所有lazy函数,覆盖面更广),你就掌握了iOS动态链接的命脉;当你搞懂它为何不用ptrace而用backtrace(因为ptrace在iOS 15+被沙盒严格限制,backtrace是纯用户态调用),你就触达了苹果安全模型的边界。我现在的日常工作,90%时间在读dyld源码、objc4源码,剩下的10%才是写patch。因为所有防护的本质,都是对系统机制的“合理滥用”,而破解它的唯一钥匙,是比设计者更懂这个系统。
6.3 给新手的三条硬核建议
别迷信“万能绕过脚本”:我见过太多人下载一个
fishhook-bypass.sh,跑完发现App闪退,就以为是iOS版本问题。其实90%的情况是:脚本没适配目标App的fishHOOK变种。我的做法:每次分析新App,先用otool -l app | grep -A5 __la_symbol_ptr确认段结构,再用nm -u app | head -20看未定义符号,这比跑脚本快十倍。把lldb当IDE用,不是调试器:学会
lldb的command alias,比如:(lldb) command alias fishdump memory read -s 8 -c 50 (lldb) command alias fishjmp thread jump --address这样
fishdump 0x100005000就能快速dump,效率提升50%。建立自己的“防护特征库”:用Excel记录每个fishHOOK样本的:校验段、检测函数名、触发条件、绕过方式。我维护了3年,现在看到
-[FISHGuard checkPtrChain]就知道它用xor校验,看到+[AntiDebug stackTraceValid]就知道它扫描liblldb字符串。知识不是碎片,而是网络——当你把100个样本连成网,fishHOOK就不再是黑箱,而是你熟悉的“老朋友”。
最后分享个小技巧:下次遇到fishHOOK,别急着逆向,先打开Xcode,新建一个Empty App,用dlopen加载libsubstrate.dylib,然后dlsym找MSHookMessageEx。你会发现,自己写的代码也会被fishHOOK检测到——这恰恰证明,你已经站在了和厂商工程师同样的起跑线上。真正的逆向,从来不是破坏,而是理解;不是绕过,而是对话。