首页
社区
课程
招聘
我们绕过了 GarudaDefender 整套 Frida 检测,但这已经不是重点了
发表于: 2小时前 325

我们绕过了 GarudaDefender 整套 Frida 检测,但这已经不是重点了

2小时前
325

近年来商业移动端 RASP 越来越卷。Garuda 系列从去年下半年开始在反作弊和金融风控圈子里频繁出现,把代码混淆、完整性校验、环境检测、反 Hook、网络保护、远程策略这一堆东西全栈打包,被很多团队当成新一代客户端防护的参考样本。

前阵子接到一个 GarudaDefender 的分析需求,我用 Hermes Agent 当主驱动,花了两天时间,把它的告警链路从 Java 层 SystemAlert 弹窗一路追到 native 层 hash_crc 校验,绕过了它的全部 Hook Detected / Malicious framework detected / Root Detected 检测。能拿下这种规模(21MB 混淆 so、函数上万、双 SO 协作)的商业 RASP 当然有意义。然而,和我在此之后注意到的事情比起来,这次绕过就不算什么了。

虽然不是重点,作为背景还是先简单说一下。

GarudaDefender 的核心检测逻辑都在 libkikypspro.so 里,通过 libcrackme.so 的自定义 dlsym 跳板间接调用。Java 层 SystemAlert.onCreate 是 native 方法,真实业务在 libkikypspro.so + 0x37A754,后续经过 sub_68BD10 调度,最终由 sub_64B840 构造并显示 AlertDialog。

所有弹窗的源头,是 sub_AB31A0 里的一处 hash_crc 一致性校验:

X23 强制设为 X0 让校验恒等,再把 14 个环境检测函数的返回值统一替换为 0x64(在该样本族中是"通过"状态码),整个 RASP 就被静默了——主页面 root 检测正常,弹窗不再出现,Frida 注入完全无感。

整个绕过过程并不简单。21MB 混淆 so、函数上万、间接调用一抓一大把,反编译经常因为混淆给出错的伪代码,光定位 SystemAlert 弹窗的真实入口按传统方法就可能耗掉一两天。但说到底,这仍然是任何有经验的逆向工程师都能做到的事情,谈不上有多新鲜。

让我决定把这次分析认真写成长文的,是另一件事。

时间紧,这次没按老办法走。我把 Hermes Agent 接进来当主驱动,自己只负责出假设、审证据、定方向,剩下查 xref、跑 decompile、写 Frida 脚本、对栈、改返回值这些事全部丢给 Agent 去做。

事情的转折发生在分析的第三天下午。

那天我在追 sub_61482C 的上游 caller,原本计划手动反编译十几个候选函数,根据形态判断哪个是真正的告警处理器。Hermes 跑完一轮工具调用之后,在日志里多出了一行我没下达过的命令:

——难道 Hermes 自己创建了一个逆向方法论的 Skill?我打开那个文件:

我反复确认了对话历史——之前我从来没让 Hermes 总结方法论,更没让它创建 Skill。它是自己做的。

后面几天我顺着这套机制观察下去。Hermes 一共自主沉淀了 7 个跟 GarudaDefender 系列分析直接相关的 Skill:

每一个 Skill 都不是"sub_61482C 在 0x61482C"这种一次性偏移结论,而是"如何在新版本中重新定位 sub_61482C 等价物"的可执行方法论。这意味着 GarudaDefender 下个版本 v4.5.x、v5.x 出来,符号重新混淆,本文里所有具体地址都会作废——但只要加载这 7 个 Skill,Hermes 会按方法论在新偏移上自动重新定位、自动生成新版本的 Frida 脚本。

得出这个结论后,我非常震惊。但反复检查之后还是确认了:分析开始之前,这些 Skill 一个都不存在,~/.hermes/skills/reversing/ 目录甚至是空的;一切都是 Hermes 在分析过程中的自主行为。

也就是说,没有人为提示,没有预置的方法论模板,没有外部审计——Hermes 在做完一次 RASP 逆向分析之后,自己把这次分析的全套方法论提取了出来,变成了下次面对同家族样本时可以直接复用的能力。

兴奋之余,我开始想:这一切是怎么发生的?

翻 Hermes 源码,run_agent.py 里有两个计数器:

每当工具调用次数超过阈值,Hermes 会 Fork 一个 Review Agent 重新审视整段对话,把值得沉淀的经验写成 Memory 或 Skill。这套机制最初设计是给 Agent 积累用户偏好和工作流程用的——并不是为了逆向工程方法论沉淀。然而,用户偏好是经验,工作流程是经验,逆向方法论也是经验。由于我反复多次按相似模式定位 sub_61482Csub_AB31A0sub_81DDF0 这一类函数,机缘巧合之下,Hermes 忽然意识到"这套查 mutex + 筛 caller + 形态匹配的流程值得沉淀",于是自己调用 skill_manage 把它写成了 Skill。

读过玄武实验室那篇 Hermes Agent 自主防御网络攻击的文章的人应该会觉得眼熟——同一套 Review 机制,同一个 skill_manage 工具。区别只在于:他们触发的是"安全免疫"——在反复遭受攻击后,Hermes 自己建立起了对网络攻击的免疫系统;而我触发的是"逆向方法论沉淀"——在反复执行同类逆向操作后,Hermes 自己把这些操作的共性抽取成了可复用的 Skill。

换个说法,Hermes 的这套 Review 机制本质上不只是一套安全免疫系统,而是一套通用的经验适应性沉淀系统:安全是经验,逆向方法论是经验,任何在对话里反复出现的高价值模式都可能被它识别、提取、固化。这一次,我恰好把它用在了商业 RASP 对抗这个场景里,于是看到了它在另一个方向上的涌现行为。

所以本文 1-19 章是这次跟 GarudaDefender 对抗的具体过程记录,但真正值得关注的是第 20 章——那里会详细展开 Hermes 自主沉淀出来的 7 个 Skill 是什么样的、怎么在新变体上跑的、以及为什么这套"Agent 自主方法论沉淀"的范式可能改变整个商业 RASP 对抗的工作流。

我以为我在分析 GarudaDefender,但其实是这次分析,让我看到了 Agent 时代逆向工程的新形态。一个具体的绕过结果只对一个版本有效,但一套被 Agent 自己内化的方法论,只要 RASP 家族的行为模式没变,就能持续生效。和这比起来,绕过本身当然就不算什么了。

本文章的分析过程使用 Hermes Agent 辅助记录分析日志、整理 IDA 静态分析结果,并配合 Frida 脚本进行动态验证。实验前需要先确认本地已经可以正常调用 hermes

本文章环境中使用的版本信息为:

可以使用下面命令检查 Hermes Agent 运行环境:

如果是首次使用,先完成 Hermes Agent 初始化和认证配置:

动态分析时,通过 Hermes Agent 执行 Frida 命令,由 Frida 连接目标设备并注入脚本。本文实际运行脚本的方式为:

其中 ok.js 是本文最终使用的 Frida 脚本,负责等待 libkikypspro.so 加载、安装检测绕过点,并 hook sub_61482C 打印告警文案和 native 调用栈。

Garuda Defender Hook Detected 弹窗

这些可见文本后来分别在 Frida 中通过 TextView.setText 捕获到:

其中 Oke 是检测弹窗确认按钮的初始文本,后续倒计时文本 Oke (10)Oke (9) 等来自 Java 层 AppTerminationTimer.onTick

最开始通过 hook Android 弹窗相关 API 和 TextView.setText 观察到弹窗文案:

对应 Java 栈显示弹窗来自:

堆栈片段:

Dialog.show 也显示同样的来源:

反编译 Java 类:

结论:SystemAlert 是承载弹窗的 Activity,但生命周期方法是 native 实现,真正逻辑不在 dex 里。

同时还观察到按钮倒计时来自:

对应日志:

因此弹窗显示本体和倒计时更新不是同一处逻辑:弹窗构造在 SystemAlert.onCreate(Native Method),倒计时更新在 Java 层 AppTerminationTimer.onTick

通过 hook libart.soRegisterNatives,确认 SystemAlert 的 native 方法注册在 libcrackme.so

同时还观察到:

因此 SystemAlert.onCreate 的 JNI 注册入口是:

IDA 中查看 libcrackme.so + 0x1B15C 附近代码后,发现它不是最终业务函数,而是一个 wrapper。它会:

这类结构的特点是:RegisterNatives 注册到的地址不是最终业务逻辑,而是一个延迟解析跳板。第一次执行时解析真实函数地址,后续执行时直接调用缓存的函数指针。

动态 hook libcrackme.so + 0x26F78 后确认它是一个自定义符号解析器,行为类似 dlsym。关键日志:

对应关系:

在运行时日志中,libcrackme.so + 0x1B15C 的 wrapper 进入后,紧接着出现 resolver 解析:

这说明 libcrackme.so + 0x1B15C 不是直接构造弹窗,而是把调用转发到 libkikypspro.so + 0x37A754

因此,本文将 SystemAlert.onCreate 后续实际业务入口定位到:

另外还确认:

IDA 中查看 libkikypspro.so + 0x37A754,结合 JNI vtable 偏移可判断:

该函数里可见类似流程:

动态 hook 后确认调用链:

sub_37C0E8 更像生命周期转发/调用父类方法;sub_68BD10 是弹窗构造的上层调度函数。

sub_37C0E8onCreateonPauseonDestroy 中都被调用过,且参数中包含从 FindClass/GetMethodID 得到的类和方法,因此它更接近“调用 Activity 生命周期父类/目标方法”的辅助函数,而不是弹窗本体。

IDA 中查看 sub_68BD10,结合 JNI vtable 偏移、动态日志和调用形态,可以把其中一批间接调用归类为 JNI 调用及字符串解密/初始化逻辑,包括:

该函数最后会走到一个分支,调用:

在本轮运行时验证中,sub_64B840 的返回地址 lr 固定落在:

结合静态反汇编可知,这是 sub_68BD10 内部调用 sub_64B840 之后的下一条指令。运行时记录:

动态 hook 进一步确认:

结合静态 callsite、TextView.setTextDialog.show 日志,可以确认:

是当前告警路径中实际构造并触发显示 AlertDialog 的核心函数。

sub_64B840 的前两个参数已通过 JNI 调用形态确认:

第 3、4 个参数是小整数,不是指针:

后续通过绑定 TextView.setTextDialog.show,确认这两个参数会影响弹窗文案类型。

为了避免仅凭日志顺序猜测,后续使用脚本同时 hook:

脚本逻辑是在 sub_64B840 进入时记录当前线程的 arg2/arg3,在同一线程内捕获 TextView.setTextDialog.show,从而把参数与最终显示文案绑定。

确认结果如下。

同一次函数调用内捕获到:

映射:

同一次函数调用内捕获到:

映射:

同一次函数调用内捕获到:

映射:

因此目前可靠结论是:

0xD9/0xE50xD1/0xE9 显示相同文案,可能表示不同检测来源共用同一套 Hook Detected 弹窗内容。这个“检测来源不同”的解释只是推测,当前日志只确认它们显示相同文案。

截图 upload/attach/202605/662006_NA7XNAXTVGZA3GW.png 对应的是 Hook Detected 类型弹窗,即运行时映射中的:

或:

仅凭截图本身不能区分是哪一组参数触发;需要依赖同一次 sub_64B840 调用内捕获到的参数和 TextView.setText 文案绑定日志。

完整链路如下:

最终确认的关键函数:

它会设置弹窗标题、正文、按钮文案,并调用:

最终弹出:

动态 hook JNIEnv CallVoidMethodV -> startActivity(Landroid/content/Intent;)V 时,捕获到的关键 native 栈:

静态分析后,这 5 个地址分别落在:

sub_38E8F4 是 JNI 可变参数调用包装器。它从 a1 的虚表偏移 0x1F0 取函数指针,拷贝 va_list,然后执行:

结合动态日志里 GetMethodID startActivity(Landroid/content/Intent;)VCallVoidMethodV,这里基本可以归为 JNIEnv->CallVoidMethodV 这一层。0x38e96c 本身已经在 BLR X8 之后,是调用返回后的栈保护检查位置,不是业务触发点。

sub_689370 是这条链里最关键的 native 分发函数。0x68b1bc 附近逻辑如下:

也就是说 0x68b1b8 负责调用 JNI 包装器,触发 startActivity0x68b1bc 是调用之后的对象状态/清理回调。结合动态日志中 sub_64B840 的 LR 为 0x68ca48,本文将 sub_689370 描述为“弹窗/startActivity 分发器”。

sub_61482C 是上层桥接/缓存函数。它持有全局互斥锁,使用 qword_152BB00/qword_152BB08/qword_152BAF8 维护状态;当状态满足时会调用:

0x6148dc 位于 sub_689370 返回之后,随后通过虚表偏移 0xB8 调用对象清理/释放逻辑。因此它能出现在栈上,但不是直接构造弹窗的位置。

sub_4CCE88basic_string::reserve 风格的字符串扩容函数,负责检查容量、分配新 buffer、复制旧数据,失败路径会抛出:

0x4ccf10 位于分配完成后的旧字符串状态读取位置。它出现在栈上,说明弹窗链路中正在构造字符串,例如类名、方法名、Intent 相关字符串或参数文本。

sub_36267C 是字符串赋值辅助函数。流程是:

0x3626b8 位于调用 sub_4CCE88 后的分支位置。它同样属于字符串构造辅助层,不是弹窗触发点。

本轮结论:当前这条 SystemAlert 弹窗链路中,真正值得继续下钻的是:

其中 sub_689370 是核心分发函数,sub_61482C 是上层状态/缓存桥接,sub_38E8F4 是 JNI 调用包装器;sub_4CCE88sub_36267C 只是字符串基础设施。

动态日志里 sub_64B840 的 LR 是:

静态确认 0x68ca48 不属于 sub_689370,而是属于 sub_68BD10sub_68BD10 起始地址:

sub_64B840 的直接调用点:

因此动态日志中的:

可以和静态代码完全对上:

sub_64B840 返回后,sub_68BD10 会连续通过虚表偏移 0xB8 释放或清理若干 JNI/对象引用:

这也解释了为什么 hook sub_64B840 的 LR 总是落在 0x68ca48:这是 BL sub_64B840 后的下一条指令。

动态日志中 sub_68BD10 alert dispatcher 的 LR 是:

静态确认 0x37bfa8 属于导出/解析出来的混淆函数:

关键调用点:

所以这里的真实链路是:

sub_37C0E8 更像生命周期/父类调用辅助,sub_68BD10 才是弹窗调度器,sub_64B840 是当前告警路径中实际构造并展示 AlertDialog 的函数。

目前静态和动态结合后,可以把弹窗相关逻辑拆成两段:

这两段不是互相替代关系,而是同一弹窗流程的前后两部分:

因此后续如果要定位“是哪一个检测结果导致弹窗”,更应该继续追 sub_61482C 的调用者,特别是动态栈中两条分支:

sub_68BD10/sub_64B840 已经偏向“展示结果”,而不是“产生检测结果”。

继续从 sub_61482C 的动态栈往上追,确认两条分支都不是简单的普通 BL 链,而是经过任务对象、队列和虚表回调进入检测处理函数。

动态栈:

0x36e4f0 位于 sub_36CE08,但它不是调用点,而是调用 sub_61482C 后的清理逻辑。真正调用点是:

因此 sub_36CE08 会把:

传给 sub_61482C。这里的 W2 后续会进入 sub_61482C(a1, a2, a3) 的第三个参数,并参与后面的 sub_689370 / SystemAlert 启动流程。

0xace1d0 位于 sub_ACE184。这个函数很小,是一个虚表回调转发器:

也就是说 sub_36CE08 很可能是通过对象虚表 +0x30 间接调进来的。

0xacab7c 位于 sub_AC7A90。关键位置:

这层负责准备参数并调用 sub_ACE184,属于上游检测调度/任务逻辑。它传入的 X2 来自全局字符串对象 qword_152DDE8 / s2

0x4c874c 位于 sub_4C871C,这个函数更像任务入口:

它从 sub_360B7C() 拿到一个全局/单例对象,然后调用该对象的虚函数,最终进入 sub_AC7A90 这一类任务执行逻辑。

0x1095010 位于 sub_1094FB4,它像线程任务/异步任务的收尾调度函数:

它还维护原子计数、互斥锁、任务释放等状态。这个位置更偏调度框架,不像具体检测点。

分支 A 归纳:

动态栈:

0x36b3b8 位于 sub_369CA4,同样不是调用点,而是 sub_61482C 返回后的清理位置。真实调用点:

sub_369CA4sub_36CE08 结构高度相似:都是大量混淆计算后,把 X19 和栈上计算出的 W2 提交给 sub_61482C

0x6d237c 位于 sub_6CEE14。关键位置:

这里也是虚表 +0x30 间接调用。结合动态栈,sub_369CA4 很可能就是这个 BLR X8 调到的处理函数。

0x945054 位于 sub_94500C,主要是状态置位和条件变量广播:

它更像异步任务/事件完成通知层。

0x56bbbc 位于 sub_56BA1C。它处理链表/队列节点:

这层像任务队列搬运/封装层,会把待执行节点取出后交给下游。

0xe0ec78 位于 sub_E0EBE8。它是带锁的执行器:

分支 B 归纳:

两条分支最终都收敛到:

但上游意义不同:

更像“检测结果处理函数”,它们把检测上下文和 code 提交给统一告警桥。

更像任务调度、队列执行、虚表派发框架。

如果要继续找“具体检测了什么”,下一步应该在 sub_36CE08sub_369CA4 内部继续看它们在调用 sub_61482C 前构造了哪些字符串/对象,尤其是提交给 sub_61482CX1W2 的来源。

sub_AB31A0 是一个很大的混淆函数:

它不像直接弹窗函数,也不像单个检测函数;更像“任务/规则描述表初始化 + 字符串匹配 + 对象绑定”的中间层。

入口处先检查 a1 的状态字段:

这说明 a1 是一个带状态、计数或容量字段的对象,0x80/0x88/0x8C 附近像任务表或条目表状态。

随后调用:

sub_AB2800 会从全局 obj__2 构造一个临时列表/容器:

所以 sub_AB31A0 的第一步是拿到一批待处理条目。

之后它遍历这个临时表,每个条目大小是 0x18

对每个条目,它会取字符串并计算长度:

然后用 sub_DB877C / sub_DB8768 / sub_D9B120 做字符串/对象封装:

接着它遍历另一个全局对象表:

全局表的每个外层条目大小也是 0x18

匹配到外层条目后,又遍历内层表,内层条目大小是 0x60

核心匹配逻辑是比较字符串长度和内容:

匹配成功后进入对象绑定/构造:

sub_ABCFD8 的作用是按条件创建/获取一个对象节点,必要时分配 0x50 字节对象,并初始化其中的链表字段:

sub_AB31A0 后面有大量混淆分支,但结尾可以看到它会清理临时字符串表,并返回一个整型结果:

另外,异常/失败路径里会调用:

这些更像容器断言、异常处理或全局状态恢复,不是正常主路径。

当前判断:

它做的事情大致是:

它不是 SystemAlert 的直接触发函数,但可能参与“检测任务/规则对象”的初始化。如果某条检测分支最终通过虚表调到 sub_36CE08sub_369CA4sub_AB31A0 这类函数可能就是更早期负责把字符串规则和回调对象装配起来的地方。

sub_ABC9D0sub_AB31A0 匹配成功后调用的对象绑定函数之一:

它不是检测函数,也不直接触发弹窗;它是一个“按字符串 key 查找或插入节点”的容器函数。

函数原型按寄存器实际用途更适合理解为:

其中:

入口会先把容器指针调整到 container + 8

如果 *X1 != 0,走 sub_ABCD08

如果 *X1 == 0,走 sub_ABCB5C

这两个函数都是树/有序容器查找逻辑,内部用字符串长度和 memcmp 比较 key:

sub_ABC9D0 调完查找函数后,返回:

对应代码:

如果 X21 == 0,说明已经找到现有节点,直接返回:

如果 X21 != 0,说明没有找到,需要插入新节点。新节点大小是 0x38

随后复制 key 字符串到新节点 +0x18

sub_379C00 是 string copy helper,会把 X23 指向的 short/long string 拷贝到目标字符串对象。

新节点结构大致可以按这个理解:

插入时会根据查找返回的方向,把新节点挂到父节点左/右:

随后初始化新节点指针关系,并调用平衡/修复函数:

最后增加容器节点计数:

最终返回结构:

对应:

当前判断:

sub_ABCFD8 的关系:

结合 sub_AB31A0 看,sub_ABC9D0 的作用是把匹配到的内层描述对象挂进某个容器,形成后续任务/规则对象的索引关系。它本身不做检测,只负责容器插入和对象绑定。

sub_AB9EA8 不是弹窗触发函数,也不是对象调度函数。它更像是一个高度优化的 64-bit 非加密 hash 函数,输入是:

IDA 反编译原型显示为:

其中 a1 是待 hash 的 buffer,n0x61 是长度。

函数内部按长度分多条路径处理:

大长度路径会使用若干常量表和 NEON 寄存器做并行混合,例如 xmmword_103900xmmword_103C60unk_170A40 等。

最终收尾 avalanche 有典型 hash 混合形态:

小长度路径还会使用类似下面的乘法常量:

整体风格类似 wyhash / XXH3 这一类非加密高速 hash,但目前不能直接断言就是某个公开算法的原版实现。

sub_AB31A0 中有一处很关键的调用:

结合后续反编译代码,这里的 sub_AB9EA8 更准确地说是 hash_crc 校验函数。它会对两份来源的数据片段分别计算 hash/crc,然后比较结果:

这说明 sub_AB31A0 的核心逻辑不是简单缓存,而是做一致性校验:

其中 sub_ABCFD8 / sub_ABC9D0 负责找到或创建对应节点,并把第一次计算出来的 hash_A 保存到 node+0x30 / ptr_7+48

因此更准确的判断是:sub_AB31A0 内部存在一组基于 hash_crc 的完整性/一致性校验。如果两份数据算出的 hash 不一致,就会跳出当前循环,后续很可能进入异常处理或告警路径。结合前面已经确认的 SystemAlert 弹窗链,这个校验失败点很可能是弹窗触发条件之一。

另一条链里,sub_AB2250 会先计算 hash,再通过 sub_ABC9D0 找到或创建节点,最后把 hash 存到节点偏移 +0x30

结合前面对 sub_ABC9D0 的分析,可以推测结构关系大概是:

sub_AB9EA8 负责提供“内容指纹 / CRC-like 校验值”。它本身不直接弹窗,但 sub_AB31A0 会用它比较两份数据是否一致。

和前面的函数串起来看:

所以 sub_AB9EA8 可以命名为:

如果后面要动态验证,可以 hook 这个函数打印:

重点看它是否只在规则表初始化和规则刷新路径里出现。如果是,就能进一步确认它是规则对象的内容 hash,而不是检测逻辑本体。

sub_AB31A0 中这段反编译:

对应的关键汇编是:

含义:

不相等以后并不是立刻弹窗,而是先检查 a1 + 0x70

如果 callback_obj 存在,后面会做第二层去重/缓存判断:

也就是:

真正的回调调用点在:

伪代码可以写成:

所以这条分支的意义是:

结合之前动态栈里多次出现的 vtable+0x30 -> sub_36CE08/sub_369CA4 -> sub_61482C -> startActivity,这里很可能就是把“校验失败事件”派发给上层处理器的地方。弹窗不是在 CMP/B.NE 当场发生,而是在这个虚函数回调后面的处理链里发生。

动态 hook 0xAB3C80 得到:

说明 sub_AB31A0 内部确实出现了两份数据 hash_crc 不一致。

随后 hook 0xAB3D98 BLR X8 得到:

动态验证进一步确认:0xAB3D98X8 没有直接指向 sub_36CE08sub_369CA4,而是先指向 sub_ABC344

sub_ABC344 是一个桥接包装函数:

关键汇编:

也就是说完整分发关系是:

动态日志显示后面进入了 sub_61482C,并且参数就是弹窗文案:

随后进入 JNI startActivity

因此现在可以确认:

这里的弹窗不是 sub_AB31A0 直接创建的,而是 sub_AB31A0 产生校验失败事件,交给后面的虚函数回调链处理。

sub_61482C 可以命名为:

它不是最早的检测函数,也不是 SystemAlert.onCreate。它是 native 层的告警分发/启动函数,负责把上游检测器传来的告警标题和正文继续传给 sub_689370,最终通过 JNI 调用 Context.startActivity(Intent) 拉起 com.kikyps.kikypspro.SystemAlert

函数原型可整理为:

入口处保存参数:

随后它会加锁并做状态检查:

如果全局告警上下文没有初始化,还会创建一批对象:

核心调用在 0x6148D8

所以 sub_61482C 本身没有直接操作 Java Intent,而是把 title/message 传给下一层 sub_689370

hook sub_61482C 后,运行时参数直接显示告警文案:

另一组:

这说明:

同时动态栈显示它的上游来自不同告警处理器:

例如:

0x36cc5c 位于 sub_36B564 内部。

0x36e4f0 位于 sub_36CE08 内部。

0x36b3b8 位于 sub_369CA4 内部。

sub_61482C 调用 sub_689370

动态日志里 sub_689370 收到的 arg3/arg4 正是 sub_61482C 的标题和正文:

随后:

结合动态参数和后续 startActivity 调用,可以把这条链路整理为:

sub_61482C 的定位不是单独靠反编译猜出来的,而是通过动态栈、静态调用点和运行时参数三部分交叉验证出来的。

第一步,先从最终弹窗位置反推 native 调用栈。

hook JNI CallVoidMethodV,只在方法名为 startActivity(Landroid/content/Intent;)V 时打印 native backtrace,得到:

其中 0x6148dc 位于 sub_61482C 内部。这个地址非常关键,因为它不是函数入口,而是 sub_61482C 调用下一层函数返回后的地址。

第二步,静态确认 0x6148dc 的前一条调用。

反汇编 sub_61482C 可见:

因此动态栈里的 0x6148dc 可以准确解释为:

第三步,确认 sub_61482C 的参数含义。

入口处参数保存逻辑:

调用 sub_689370 时:

说明:

动态 hook sub_61482C 后,运行时参数直接显示:

另一条:

所以 sub_61482C 的参数含义可以确定为:

第四步,确认下游确实启动了 Java 弹窗。

sub_689370 进入时收到同样的字符串:

随后 sub_689370 内部调用:

动态 hook sub_38E8F4 映射到 JNI 方法:

Java 层 hook 也确认最终 Intent 指向:

所以 sub_61482C -> sub_689370 -> sub_38E8F4 -> startActivity(SystemAlert) 这条下游链闭合。

第五步,确认上游来源。

sub_61482C 有多条上游告警处理器调用路径:

动态日志中分别表现为:

其中 sub_AB31A0 的 hash_crc 失败路径已经验证到:

因此完整闭环为:

所以 sub_61482C 的最终定位是:

sub_689370 是 native 到 Java 的告警启动桥接函数。它的作用是:

可以命名为:

sub_61482C 调用它的位置:

动态日志对应:

所以参数可以整理为:

函数内部大量通过 JNIEnv 函数表调用 JNI API。

开头检查参数后,先通过 env->GetObjectClass(context) 类似的调用获取对象类:

随后多次检查 JNI 异常:

释放 local ref 的清理逻辑集中在:

0x108 偏移用于 GetMethodID 一类方法查找:

最终关键调用:

sub_38E8F4 是 JNI varargs 包装:

动态 hook 已经把它映射成:

动态栈里:

0x68B1BC 正好是:

也就是 sub_689370 调用了 CallVoidMethodV(startActivity)

同时 sub_689370 的参数保留了上游告警文本:

所以完整关系是:

sub_689370 不负责检测,也不负责生成告警文案。告警文案在进入它之前已经确定。

它负责的是:

因此它是弹窗链路里最关键的 JNI 桥接层。

ok.js 当前承担两个目标:

这份脚本的核心不是一开始就直接 hook libkikypspro.so。原因是 libkikypspro.so 并不是普通系统 loader 直接加载的,而是由 libcrackme.so 内部的 sub_26538 自实现 dlopen-like wrapper 加载。

因此脚本先 hook 系统 android_dlopen_ext,等 libcrackme.so 加载完成:

然后再 hook libcrackme.so + 0x26538

这一步的意义是:

动态日志已经验证:

也就是说,sub_26538libcrackme.solibkikypspro.so 的加载交接点。

如果脚本过早 hook libkikypspro.so + 0x61482C,模块还不存在,地址无法计算;如果等 sub_26538 返回后再 hook,就能稳定拿到:

脚本里真正参与当前绕过的点主要有四组。

第一组,hook_crc(base)

0xAB3C80sub_AB31A0 中比较两份 hash_crc 的位置:

寄存器含义:

脚本在比较前执行:

等价于强制:

这样 CMP X23, X0 一定相等,随后走:

也就是跳回正常循环,不进入:

这就是绕过 sub_AB31A0 hash_crc 校验触发弹窗的关键点。

第二组,sub_6CEE14(base) 下的返回值替换:

这些 hook 对应的是 sub_6CEE14 / sub_369CA4 这一类检测分支的下游判断函数。动态栈里这一支会进入:

脚本把这些检测函数返回值改成期望的正常值,避免进入恶意框架告警路径。

第三组,hook_AEB528(base)

这属于另一条检测分支的返回值修正。它不是弹窗启动函数本身,而是让上游检测状态保持在“通过/正常”的值。

第四组,hook_AC7A90(base)

这一组对应动态栈里的另一条告警来源:

通过改写返回值,脚本阻断这条检测分支继续产生告警事件。

脚本中的:

sub_61482C 已经静态和动态确认是:

所以 hook 它可以直接看到:

同时 backtrace 能看到是谁调用了 sub_61482C

这就是“通过堆栈看谁调用了 0x61482C”的方法。

对应的动态堆栈截图:

sub_61482C 调用栈截图

sub_61482C 本身不直接调用 Java。它会把告警文案传给 sub_689370

动态栈里 0x6148dc 正是 sub_61482C 调用 sub_689370 后的返回地址:

sub_689370 是 JNI 桥接函数,会构造/查找 Java 对象和方法:

最终调用:

Java 层 hook 进一步确认 Intent 指向:

实际弹窗效果如下,标题和正文与 sub_61482C 中打印到的 title/message 一致:

SystemAlert Hook Detected 弹窗

因此完整闭环是:

脚本中的绕过点则对应闭环的上游:

当前 ok.js 已经去掉了前期用于线程、SVC 和大函数跟踪的历史调试代码,只保留和本文闭环直接相关的逻辑。

脚本入口是:

dlopen()libcrackme.so 加载完成后,调用:

hookSub26538() 再等待 libcrackme.so + 0x26538 加载 libkikypspro.so 成功。libkikypspro.so 可枚举后,安装下面这些 hook:

因此当前脚本可以分为三类:

ok.js 里还加入了 hook_str(base),用于跟踪运行时字符串解密/还原结果:

这里 base + 0x15264F0 保存的是字符串处理函数指针。hook 这个函数后,可以直接看到运行时被还原出来的检测规则和特征字符串。

典型日志:

这批字符串非常有价值,因为它直接暴露了 Garuda Defender 的检测关注点:

也就是说,动态 hook 字符串解密函数可以快速枚举检测规则,比单纯静态搜索字符串更直接。很多字符串只有运行时解密后才会出现,直接 hook 解密出口能帮助定位后续检测函数和绕过点。

前面的弹窗链路主要解决了“告警如何从 native 进入 Java 并展示出来”的问题。后续继续分析主页面的 root 检测时,入口不是从 SystemAlert 开始,而是从 native 线程创建开始追。

先 hook libc.sopthread_create,观察 libkikypspro.so 创建的检测线程。运行时多次捕获到同一组信息:

静态查看 0x11AAF0C 附近:

也就是说,pthread_create 传入的线程入口是 libkikypspro.so + 0x11AAFBC

但是 0x11AAFBC 并不是具体检测函数,而是一个通用线程模板函数。它会从线程任务对象的 vtable 中取出真实执行函数:

0x11AAFF4 前的 X8 做动态打印并按 RVA 去重,得到多个真实任务入口:

其中和 root 检测相关的关键入口是:

静态查看 0x911970

因此这条环境检测线程链路可以先闭合到:

这里需要注意,0x9125C0 这类地址不一定落在 IDA 识别的函数入口上。它位于 sub_9125BC + 4,说明样本中有些任务入口可能会指向函数中间位置。因此这类链路应以动态记录到的 X8 为准,不能只依赖 IDA 自动识别的函数起始地址。

继续查看 sub_81DDF0,可以看到它内部初始化了一张检测任务表。每个检测任务由两部分组成:检测对象/上下文生成函数,以及真正的检测执行函数。

关键伪代码结构如下:

sub_867F80(v166, 14) 的第二个参数明确说明这里注册了 14 个检测任务。对应的检测执行函数为:

这里暂时称为“环境检测任务”,而不是全部归类为 root 检测。因为这 14 个任务可能同时覆盖 root、Hook、注入、虚拟环境、异常文件路径、运行时特征等多种检测面。

对这 14 个检测函数逐个 hook 返回值,当前环境下得到:

可以看到,大多数检测任务返回 0x64,而当前环境中有三个检测项返回了非 0x64

动态验证时,如果不修改这些返回值,App 主页面会显示 root 检测异常;如果将异常返回值替换为 0x64,主页面显示恢复正常。因此可以判断,在当前样本中 0x64 是可验证的通过状态码,非 0x64 返回值会被上层聚合逻辑视为检测命中或环境异常。

用于验证的 hook 逻辑可以简化为:

这一步说明 0x826C60 / 0x82BD98 / 0x83B774 是当前环境中实际命中的检测项。但这还不能直接说明它们全部都是 root 检测,需要继续看每个函数内部使用的检测字符串和行为。

为了确认 0x826C60 的具体语义,在进入该函数后开启字符串解密 hook:

运行时捕获到 0x826C60 相关字符串:

这些字符串均指向 Magisk、Zygisk、KernelSU、APatch、Shamiko、Riru 等 root 或 root-hide 框架,以及 /data/adb//data/adb/modules/ 这类典型 root 模块路径。

结合返回值验证:


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 2小时前 被秋落编辑 ,原因:
上传的附件:
收藏
免费 4
支持
分享
最新回复 (5)
雪    币: 1331
活跃值: (943)
能力值: ( LV4,RANK:55 )
在线值:
发帖
回帖
粉丝
2
本文在r0ysue老师指导下完成
2小时前
0
雪    币: 3916
活跃值: (2008)
能力值: (RANK:100 )
在线值:
发帖
回帖
粉丝
3
分析的很细致,思路很清晰。结果也明确。特别是结合Hermes,AI真的越来越能打了
2小时前
0
雪    币: 34
活跃值: (4494)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
非常牛x的文章,关键这篇文章代表了未来逆向的主要方向,很赞!!!
1小时前
0
雪    币: 34
活跃值: (4494)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
另外能提供ai的skill就更好了
1小时前
0
雪    币: 6106
活跃值: (10897)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
1小时前
0
游客
登录 | 注册 方可回帖
返回