某银行 APP 在越狱设备上启动后,大约五百七十毫秒便会退出。围绕这次退出,我们进行了二十几轮分析与干预。最终没有得到可用的绕过方案,不过这些实验揭示了一件更值得记录的事:检测环境、保护检测代码和阻止异常启动,是怎样共同起作用的。
防御体系的分工
先给出本文的主要判断:这套防御要求环境检查、引擎完整性与启动流程同时满足条件,修改其中一个环节不足以恢复正常使用。 从已读出的代码和实验行为看,可以按职责将它分成以下五个部分。这是功能上的划分,同一个安全框架可能同时承担其中几项职责。
第一,环境与注入检查负责收集不可信环境的证据。包内既有检查可疑文件、URL scheme 等常见越狱特征的组件,也有直接读取已加载镜像的启动期防护引擎。后者读取 Mach-O 头及加载命令,检查范围确实包含注入的动态库。这个部分产生检查结果,交给后续判定处理。我们已确认注入库进入了检查范围,但还没有将本次退出唯一归因于某个库名或某条环境特征。
第二,代码完整性保护负责检查防护引擎自身有没有被修改。已确认的一项检查使用 xxHash64,对引擎的整个 __text 计算哈希,再与期望值比较。它约束的是检测程序本身:插件若直接改写检查函数,也会改变参与校验的字节。这个部分保护前一部分和后续判定代码,使攻击者不能只考虑「把返回值改成通过」。
第三,运行期控制与反调试相关机制影响观察和干预能否持续进行。记录中出现过信号处置函数被替换,也出现过进程仍在消耗 CPU、探针却停止记录的现象。它们使插件难以持续采样或接管异常。不过,我们没有定位全部相关调用,尚不能把这些现象具体解释成「引擎识别并杀死了插件线程」。这一部分已知的效果是观察窗口受限,具体实现仍有未知。
第四,判定、报告与终止执行负责将检查结果变成行动。引擎内存在检查项的集合,也存在不同的失败分支;已分析的一个报告函数根据参数选择返回或者进入退出分支。进程外观测确认,未经本轮补丁干预的退出表现为正常结束、退出码为 1。部分历史干预中,阻止已知退出调用之后又出现了 SIGILL 终止,说明改变一个终止入口并不足以保证进程继续运行。
第五,业务风险处理与启动依赖将安全组件接入 APP 的使用流程。包内有汇总越狱、调试器和代码修改等风险标记的组件,也有显示提示或拦截页面的界面组件。与此同时,启动期防护引擎的初始化参与了 APP 启动:跳过初始化会崩溃,打断处置曾得到长时间黑屏。本次最早的失败发生在正常业务界面出现之前,没有证据表明那些业务风险组件都已参与了这次判定。
关系如下:
图中需要区分两种协作。一种是明确的调用关系,例如检查项将结果交给报告和处置函数;另一种是运行条件上的约束,例如完整性校验保护检测代码,而正常启动又依赖安全组件完成初始化。防御效果可以由这些约束共同形成,不必假设每个组件都向一个中央控制器报告。
各部分怎样协作
采集方式保护判定输入
插件隐藏越狱环境,通常从修改系统接口的返回值开始。例如,应用通过常规接口枚举已加载的动态库,插件便可以从返回结果里删掉自己的条目。这个办法成立的前提是,检查程序也通过被修改的接口取得信息。
某银行 APP 的启动期引擎没有遵守这个前提。早期记录显示,它先取得共享缓存的地址范围,而后集中读取缓存之外的镜像。在一次记录中,缓存内的 790 个镜像没有被逐个读取,缓存外的 142 个镜像则读取了 141 个。注入库也在读取范围内。这里的镜像指映射进进程内存的可执行文件或动态库,共享缓存则集中存放许多系统库。
修改公共镜像枚举接口之后,这些读取并未停止;修改若干能够找到的镜像记录,也没有使注入库从读取范围里消失。由此能确定的是,修改插件熟悉的那份枚举结果,没有同时改变引擎取得的检测输入。 至于引擎究竟使用了哪份内部记录、最终采用哪些字段判定,仍没有全部还原。
这便形成了第一种配合:判定函数的输入不必经过容易被替换的公共接口。插件即使成功改变某个 API 的返回值,也可能完全没有改变检测程序看到的内容。
类似的情况也出现在终止操作上。我们自行调用 exit(99) 时,安装的钩子能够命中,外部观察者也能读到对应退出状态;目标自身结束时,却没有经过已测的 libc 退出入口。检测输入和失败处置都不能简单地用一组常见函数钩子覆盖。
这里不能进一步推成「所有操作都经过同一个私有系统调用入口」。静态分析确实找到了一条 svc 指令,以及 83 个读取全局指针后间接调用的封装。但后期强制替换该指针后,在相应测试配置中没有记录到调用。这份静态结构不足以解释所有运行期行为,更不能证明真实入口必然由运行期生成。
自检保护检测代码
既然输入侧的隐藏不够,插件自然会转向检测程序本身:改变检查项、修改条件分支,或者让某个函数直接返回。这时,代码完整性保护开始影响攻击。
已识别的 xxHash64 检查覆盖引擎整个 __text。函数入口的跳转和条件分支都在这个范围里,修改这些指令也会改变哈希值。也就是说,原本用于处理环境风险的引擎,同时检查着插件试图修改的那些代码。
这项检查本身并非无法修改。我们确实改变了它的比较行为,使后续一些代码补丁不再引出此前的异常,从而获得了进一步插桩的条件。但 APP 仍未正常启动。解除一处自检,只解决了修改代码的一个前提,没有使环境检查自动通过。
反过来,未干预时的哈希也不是本次退出的充分解释。用相同参数对磁盘代码复算,结果与期望值相同;记录中的内存比对也支持这项自检本来能够通过。环境不可信与引擎被修改,是两种不同的失败条件。
这正是两部分协作的意义。环境检查限制 APP 在什么状态下运行,完整性保护限制攻击者怎样改变检查程序。攻击者需要同时处理这两个问题,不能用一个局部补丁代替全部条件。
多项判定限制局部修改
在一个已读出的流程中,引擎取得检查项集合,复制其中的向量,而后逐项调用对象的方法。某轮观测中,三个检查项分别返回 0、1、0,足以说明其中有一项产生了失败结果。
但修改这张表,没有得到正常启动。清除其他干预后的另一组记录中,更早的失败分支先执行了,检查表甚至尚未建立。这里不能把原因码直接翻译成某个具体事实,但可以确认:已找到的那张检查表没有覆盖全部失败条件。
集合的使用方式又增加了一项约束。检查对象是按需建立的,循环遍历的则是向量副本。即使插件修改了原来的集合,已经复制出去的内容也未必随之改变。因此,「找到检查表」「成功写入检查表」与「改变了正在执行的检查」并不等价。
从防守效果看,多个失败条件使修改单项结果不足以放行,数据的创建和复制时机又限制了事后修改。我们没有证据证明这些条件彼此监督,不过它们已经共同提高了局部干预的要求。
检查结果与处置之间也有单独的判断。已分析的一个报告函数接收原因码和说明文字:说明为 NULL 或空串时进入退出分支,说明非空时则执行另一种报告流程并返回。也就是说,报告一次事件与终止进程并不等价,原因码也不能直接当成某项越狱特征的名字。插件即使改掉一个看似决定生死的数值,也未必改变了决定处置的条件。
安全初始化约束业务启动
如果逐项改变判定过于困难,另一个办法是让整个安全引擎不启动。对于与业务没有依赖关系的检查模块,跳过初始化可能足以避免检查。
某银行 APP 的实验结果不同。它的一条重要初始化入口是 Objective-C 的 +load,仅清空 C 构造函数没有阻止这条入口执行。进一步跳过 +load 后,进程在约 545 毫秒发生 SIGSEGV。实验支持 APP 依赖这段初始化,但缺失的具体对象或状态尚未定位。
阻断退出同样没有恢复业务。部分版本把退出线程停住,或者尝试让退出调用返回,进程曾存活十二至十四秒,屏幕却一直是黑的。我们没有取得足以命名某个等待条件的完整调用栈,但结果已经很明确:维持进程存活,没有使启动流程继续完成。
这使安全组件与业务启动形成了比「检测失败就弹窗」更强的联系。移除检查可能破坏初始化,拦住处置可能保留一个无法进入业务界面的进程。插件需要恢复的是一套能够继续工作的启动状态。
观察窗口限制持续干预
前面的修改都需要知道程序正在做什么。若一个补丁错过了检查时刻,插件往往还可以创建线程、周期采样,再寻找下一次介入机会。
这套方法在本次研究中也受到限制。不同配置下,探针记录在约 52 至 130 毫秒内停止:一组心跳最后写到 123 毫秒,定时器信号记录停在 78 毫秒,排到主队列的任务没有留下执行记录。与此同时,外部观察到同一 PID 仍然存在,累计 CPU 时间继续增加。
线程枚举还改变了观测结果。加入 task_threads 采样后,记录比不采样时更早停止。这说明采样并非没有扰动,但尚不足以证明引擎专门监控了这个调用。另有一处计时问题曾使日志看似冻结,更换时间基准后得到修正;这也不能作为引擎主动修改硬件计数器的证据。
对攻击效果而言,能够确认的是:插件没有获得贯穿整个启动过程的可靠观测。 较晚的行为无法持续记录,便难以验证补丁是否仍在起作用,也难以在状态改变后继续修正。这个限制与前面的代码保护、启动依赖共同存在,使「先挂上插件,再慢慢修改」难以奏效。
tweak 注入的验证结果
要评价这张防御网络是否有效,需要先确定攻击者能做什么。本次研究允许在目标进程内注入 tweak、替换函数和修改可写数据,也允许对已定位代码做补丁;不修改越狱本体,不更换系统级隐藏方案。成功标准是 APP 能够进入正常界面并继续使用。
在这个范围内,我们已经覆盖了从检测输入到最终处置的主要干预位置:
- 改变检测输入。 尝试隐藏镜像枚举结果、修改登记信息,并将部分镜像读取重定向到替代内容。某些修改能够确认生效,但没有得到可用启动,也没有确定唯一触发特征。
- 改变检查结果。 尝试修改检查项集合及已定位分支。实验暴露了更早的失败条件,也遇到了对象按需建立和集合复制带来的时序限制,未能使整体判定通过。
- 解除代码自检。 已成功干预一处 xxHash64 比较,为进一步修改引擎代码提供条件。这个局部成功没有消除其他失败条件。
- 停止安全初始化。 清空 C 构造函数没有阻止关键初始化;跳过已定位的
+load又导致异常终止,未能得到一个没有安全引擎却可正常工作的 APP。 - 阻断失败处置。 对 libc 终止入口和引擎内已知退出位置进行拦截,部分配置延长了进程存活,结果仍是黑屏或后续异常终止。
- 维持运行期控制。 使用心跳、信号处理和线程采样观察启动,并尝试接管静态发现的系统调用指针。记录窗口有限,后期指针接管未记录到调用,未能建立稳定的持续干预。
这些结果来自不同轮次,不能写成一次启动里所有入口都经过了完整验证。尤其有一类历史记录需要剔除:直接修改函数首部的补丁曾覆盖同地址的函数钩子,使「钩子没有命中」失去证明力。没有确认安装并正常工作的探针,不能用于证明目标避开了该入口。
最后看同环境下的存活基线。不加载本 tweak 的两次记录分别为 575 毫秒与 562 毫秒;加载后期组合补丁的记录约为 643 至 672 毫秒。这里的「不加载本 tweak」仍保留越狱注入基础设施,不能理解成完全没有注入的设备。能够比较的是,本轮组合修改只使退出延后了约六十至一百毫秒,仍未换来可用界面。
至此,输入隐藏、判定修改、自检干预、初始化移除和退出拦截,都已有失败记录。局部修改也确实取得过效果,因而这次失败不能简单归结为「插件没有加载」或者「任何代码都改不了」。从本轮已经识别并能够实施的主要攻击类别看,单点成功始终没有扩展成正常使用,足以说明这些防御约束组合后对 tweak 注入具有实际效果。
不过,二十几轮实验无法证明所有可能的进程内程序都必然失败。检测输入仍有未定位的来源,运行期控制也有未解释的行为。严格的结论是:在不改动越狱本体的约束下,本轮覆盖的主要 tweak 干预方式均未攻破它;我们尚未获得足以同时满足检查、初始化和业务运行条件的修改方案。