文章总结: 作者使用HermesAgent成功绕过GarudaDefender的Frida检测机制,通过修改native层hash_crc校验和检测函数返回值实现静默绕过。但更重要的发现是HermesAgent在分析过程中自主沉淀出7个可复用的逆向工程方法论技能,这些技能能自动适应RASP新版本,展现了AI智能体在安全分析中的范式转变价值。 综合评分: 85 文章分类: 移动安全,逆向分析,安全工具,红队,AI安全
7. 最终链路总结
完整链路如下:
Java Activity:
com.kikyps.kikypspro.SystemAlert.onCreate(Bundle)
RegisterNatives:
libcrackme.so + 0x1B15C
wrapper / resolver:
libcrackme.so + 0x26F78
symbol:_ZTSN5boost9garbage21Env08out_of_rangeE
real native onCreate:
libkikypspro.so + 0x37A754
生命周期/父类调用辅助:
libkikypspro.so + 0x37C0E8
弹窗调度:
libkikypspro.so + 0x68BD10
实际构造并 show AlertDialog:
libkikypspro.so + 0x64B840
最终确认的关键函数:
libkikypspro.so + 0x64B840
它会设置弹窗标题、正文、按钮文案,并调用:
android.app.Dialog.show
最终弹出:
android.app.AlertDialog
8. startActivity native 栈静态归因
动态 hook JNIEnv CallVoidMethodV -> startActivity(Landroid/content/Intent;)V 时,捕获到的关键 native 栈:
libkikypspro.so!0x38e96c
libkikypspro.so!0x68b1bc
libkikypspro.so!0x6148dc
libkikypspro.so!0x4ccf10
libkikypspro.so!0x3626b8
静态分析后,这 5 个地址分别落在:
0x38e96c -> sub_38E8F4
0x68b1bc -> sub_689370
0x6148dc -> sub_61482C
0x4ccf10 -> sub_4CCE88
0x3626b8 -> sub_36267C
sub_38E8F4 是 JNI 可变参数调用包装器。它从 a1 的虚表偏移 0x1F0 取函数指针,拷贝 va_list,然后执行:
vfunc(a1, a2, a3, va_list)
结合动态日志里 GetMethodID startActivity(Landroid/content/Intent;)V 和 CallVoidMethodV,这里基本可以归为 JNIEnv->CallVoidMethodV 这一层。0x38e96c 本身已经在 BLR X8 之后,是调用返回后的栈保护检查位置,不是业务触发点。
sub_689370 是这条链里最关键的 native 分发函数。0x68b1bc 附近逻辑如下:
0x68b1a8 X0 = X19
0x68b1ac X1 = X22
0x68b1b0 X2 = X27
0x68b1b4 X3 = X26
0x68b1b8 BL sub_38E8F4
0x68b1bc LDR X8, [X19]
0x68b1c4 LDR X8, [X8,#0x720]
0x68b1c8 BLR X8
也就是说 0x68b1b8 负责调用 JNI 包装器,触发 startActivity;0x68b1bc 是调用之后的对象状态/清理回调。结合动态日志中 sub_64B840 的 LR 为 0x68ca48,本文将 sub_689370 描述为“弹窗/startActivity 分发器”。
sub_61482C 是上层桥接/缓存函数。它持有全局互斥锁,使用 qword_152BB00/qword_152BB08/qword_152BAF8 维护状态;当状态满足时会调用:
sub_689370(v9, v11, qword_152BAF8, a1, a2)
0x6148dc 位于 sub_689370 返回之后,随后通过虚表偏移 0xB8 调用对象清理/释放逻辑。因此它能出现在栈上,但不是直接构造弹窗的位置。
sub_4CCE88 是 basic_string::reserve 风格的字符串扩容函数,负责检查容量、分配新 buffer、复制旧数据,失败路径会抛出:
basic_string::reserve max_size() exceeded
0x4ccf10 位于分配完成后的旧字符串状态读取位置。它出现在栈上,说明弹窗链路中正在构造字符串,例如类名、方法名、Intent 相关字符串或参数文本。
sub_36267C 是字符串赋值辅助函数。流程是:
strlen(src)
sub_4CCE88(dst, len)
memmove(dst, src, len)
dst[len] = 0
更新字符串长度
0x3626b8 位于调用 sub_4CCE88 后的分支位置。它同样属于字符串构造辅助层,不是弹窗触发点。
本轮结论:当前这条 SystemAlert 弹窗链路中,真正值得继续下钻的是:
sub_61482C -> sub_689370 -> sub_38E8F4 -> JNIEnv CallVoidMethodV(startActivity)
其中 sub_689370 是核心分发函数,sub_61482C 是上层状态/缓存桥接,sub_38E8F4 是 JNI 调用包装器;sub_4CCE88 和 sub_36267C 只是字符串基础设施。
9. sub_68BD10 与 sub_64B840 的直接关系
动态日志里 sub_64B840 的 LR 是:
libkikypspro.so!0x68ca48
静态确认 0x68ca48 不属于 sub_689370,而是属于 sub_68BD10。sub_68BD10 起始地址:
sub_68BD10 @ 0x68BD10
sub_64B840 的直接调用点:
0x68ca34 MOV X0, X19
0x68ca38 MOV X1, X21
0x68ca3c MOV X2, X26
0x68ca40 MOV X3, X27
0x68ca44 BL sub_64B840
0x68ca48 LDR X8, [X19]
因此动态日志中的:
[sub_64B840 show alert dialog] enter
arg2 = 0xd5 / 0xd9 / 0xd1
arg3 = 0xe1 / 0xe5 / 0xe9
lr = libkikypspro.so!0x68ca48
可以和静态代码完全对上:
sub_68BD10(...):
sub_64B840(x19, x21, x26, x27)
sub_64B840 返回后,sub_68BD10 会连续通过虚表偏移 0xB8 释放或清理若干 JNI/对象引用:
0x68ca48 cleanup(x25)
0x68ca5c cleanup(x26)
0x68ca70 cleanup(x27)
0x68ca84 cleanup(x24)
0x68ca98 cleanup(stack_var_48)
0x68caac cleanup(x22)
0x68cac0 cleanup(x20)
这也解释了为什么 hook sub_64B840 的 LR 总是落在 0x68ca48:这是 BL sub_64B840 后的下一条指令。
10. _ZTSN5boost9garbage21Env08out_of_rangeE 上层入口
动态日志中 sub_68BD10 alert dispatcher 的 LR 是:
libkikypspro.so!0x37bfa8
静态确认 0x37bfa8 属于导出/解析出来的混淆函数:
_ZTSN5boost9garbage21Env08out_of_rangeE
关键调用点:
0x37bf84 MOV X0, X19
0x37bf88 MOV X1, X20
0x37bf8c MOV X2, X22
0x37bf90 MOV X3, X23
0x37bf94 MOV X4, X21
0x37bf98 BL sub_37C0E8
0x37bf9c MOV X0, X19
0x37bfa0 MOV X1, X20
0x37bfa4 BL sub_68BD10
0x37bfa8 LDR X8, [X19]
所以这里的真实链路是:
_ZTSN5boost9garbage21Env08out_of_rangeE
-> sub_37C0E8
-> sub_68BD10
-> sub_64B840
sub_37C0E8 更像生命周期/父类调用辅助,sub_68BD10 才是弹窗调度器,sub_64B840 是当前告警路径中实际构造并展示 AlertDialog 的函数。
11. startActivity 链和 show dialog 链的关系
目前静态和动态结合后,可以把弹窗相关逻辑拆成两段:
段 A:构造/展示弹窗内容
_ZTSN5boost9garbage21Env08out_of_rangeE
-> sub_68BD10
-> sub_64B840
-> AlertDialog / Dialog.show
段 B:启动 SystemAlert Activity
sub_61482C
-> sub_689370
-> sub_38E8F4
-> JNIEnv CallVoidMethodV(startActivity)
-> com.kikyps.kikypspro.SystemAlert
这两段不是互相替代关系,而是同一弹窗流程的前后两部分:
-
startActivity把
SystemAlert拉起来。 -
SystemAlert.onCreate的 native 注册函数进入
_ZTSN5boost9garbage21Env08out_of_rangeE。 -
该函数继续调用
sub_68BD10。 -
sub_68BD10准备参数并调用
sub_64B840。 -
sub_64B840最终构造并显示
AlertDialog。
因此后续如果要定位“是哪一个检测结果导致弹窗”,更应该继续追 sub_61482C 的调用者,特别是动态栈中两条分支:
0x36e4f0 ->0xace1d0 ->0xacab7c ->0x4c874c ->0x1095010
0x36b3b8 ->0x6d237c ->0x945054 ->0x56bbbc ->0xe0ec78
sub_68BD10/sub_64B840 已经偏向“展示结果”,而不是“产生检测结果”。
12. sub_61482C 上游两条检测分支
继续从 sub_61482C 的动态栈往上追,确认两条分支都不是简单的普通 BL 链,而是经过任务对象、队列和虚表回调进入检测处理函数。
12.1 分支 A:0x36e4f0 / sub_36CE08
动态栈:
0x36e4f0
0xace1d0
0xacab7c
0x4c874c
0x1095010
0x36e4f0 位于 sub_36CE08,但它不是调用点,而是调用 sub_61482C 后的清理逻辑。真正调用点是:
0x36e4e4 LDR W2, [SP,#var_188+4]
0x36e4e8 MOV X0, X19
0x36e4ec BL sub_61482C
0x36e4f0 cleanup...
因此 sub_36CE08 会把:
X0 = X19
X1 = 调用前已准备好的上下文
W2 = 栈上计算出的检测/告警 code
传给 sub_61482C。这里的 W2 后续会进入 sub_61482C(a1, a2, a3) 的第三个参数,并参与后面的 sub_689370 / SystemAlert 启动流程。
0xace1d0 位于 sub_ACE184。这个函数很小,是一个虚表回调转发器:
v5 = *(a1 + 0x20)
call (*(v5)->vtable + 0x30)(v5, &a2, &a3, &a4, &a5)
也就是说 sub_36CE08 很可能是通过对象虚表 +0x30 间接调进来的。
0xacab7c 位于 sub_AC7A90。关键位置:
0xacab54 load qword_152DDE8 / s2
0xacab6c MOV X0, X21
0xacab70 MOV X3, XZR
0xacab74 MOV X4, XZR
0xacab78 BL sub_ACE184
0xacab7c after call
这层负责准备参数并调用 sub_ACE184,属于上游检测调度/任务逻辑。它传入的 X2 来自全局字符串对象 qword_152DDE8 / s2。
0x4c874c 位于 sub_4C871C,这个函数更像任务入口:
v0 = sub_360B7C()
sub_1171030(*(v0+0x20), *(v0+0x18))
call (***(v0+8))(*(v0+8))
它从 sub_360B7C() 拿到一个全局/单例对象,然后调用该对象的虚函数,最终进入 sub_AC7A90 这一类任务执行逻辑。
0x1095010 位于 sub_1094FB4,它像线程任务/异步任务的收尾调度函数:
(***v1)(*v1)
v3 = *(v1 + 0xC0)
if (v3)
(*(v3->vtable + 0x30))(v3, &arg)
它还维护原子计数、互斥锁、任务释放等状态。这个位置更偏调度框架,不像具体检测点。
分支 A 归纳:
sub_1094FB4
-> sub_4C871C
-> sub_AC7A90
-> sub_ACE184
-> vtable + 0x30
-> sub_36CE08
-> sub_61482C
-> sub_689370
->startActivity(SystemAlert)
12.2 分支 B:0x36b3b8 / sub_369CA4
动态栈:
0x36b3b8
0x6d237c
0x945054
0x56bbbc
0xe0ec78
0x36b3b8 位于 sub_369CA4,同样不是调用点,而是 sub_61482C 返回后的清理位置。真实调用点:
0x36b3ac LDR W2, [SP,#var_1C8+4]
0x36b3b0 MOV X0, X19
0x36b3b4 BL sub_61482C
0x36b3b8 cleanup...
sub_369CA4 和 sub_36CE08 结构高度相似:都是大量混淆计算后,把 X19 和栈上计算出的 W2 提交给 sub_61482C。
0x6d237c 位于 sub_6CEE14。关键位置:
0x6d2368 X0 =*(X19 +0x60)
0x6d2370 LDR X8,[X0]
0x6d2374 LDR X8,[X8,#0x30]
0x6d2378 BLR X8
0x6d237c after call
这里也是虚表 +0x30 间接调用。结合动态栈,sub_369CA4 很可能就是这个 BLR X8 调到的处理函数。
0x945054 位于 sub_94500C,主要是状态置位和条件变量广播:
*(a1 + 0x28) = 1
pthread_cond_broadcast(a1 + 0x80)
遍历等待队列并 broadcast
call (*(a1)->vtable + 0x18)(a1, a2)
它更像异步任务/事件完成通知层。
0x56bbbc 位于 sub_56BA1C。它处理链表/队列节点:
从 a3 链表取节点
node[3] = 125
node[4] = &off_14C40B8
node[5] = 3
sub_56B4D4(*(a1+0x30), &list)
这层像任务队列搬运/封装层,会把待执行节点取出后交给下游。
0xe0ec78 位于 sub_E0EBE8。它是带锁的执行器:
if (*(a1+0x80))
sub_5655EC(*(a1+0x30), a5, 0)
else {
ok = sub_E0F420(a2, a3, a4, a5)
sub_143AFF0(1, *(a1+0x30)+0xD0)
if (ok)
(*(a1->vtable + 0x28))(a1)
}
分支 B 归纳:
sub_E0EBE8
-> sub_56BA1C
-> sub_94500C
-> sub_6CEE14
-> vtable + 0x30
-> sub_369CA4
-> sub_61482C
-> sub_689370
->startActivity(SystemAlert)
12.3 当前判断
两条分支最终都收敛到:
sub_61482C
但上游意义不同:
sub_36CE08 / sub_369CA4
更像“检测结果处理函数”,它们把检测上下文和 code 提交给统一告警桥。
sub_AC7A90 / sub_6CEE14 / sub_56BA1C / sub_E0EBE8
更像任务调度、队列执行、虚表派发框架。
如果要继续找“具体检测了什么”,下一步应该在 sub_36CE08 和 sub_369CA4 内部继续看它们在调用 sub_61482C 前构造了哪些字符串/对象,尤其是提交给 sub_61482C 的 X1 和 W2 的来源。
13. sub_AB31A0 静态分析
sub_AB31A0 是一个很大的混淆函数:
sub_AB31A0 @ 0xAB31A0
size = 0x4A3C
blocks = 770
complexity= 376
它不像直接弹窗函数,也不像单个检测函数;更像“任务/规则描述表初始化 + 字符串匹配 + 对象绑定”的中间层。
入口处先检查 a1 的状态字段:
0xab31d8 LDRB W10, [X0,#0x80]
0xab31e0 CBZ W10, loc_AB51EC
0xab31e4 LDRB W8, [X0,#0x88]
0xab31e8 CBZ W8, loc_AB31F8
0xab31ec LDP W9, W8, [X0,#0x8C]
0xab31f0 CMP W8, W9
0xab31f4 B.GE loc_AB5AD8
这说明 a1 是一个带状态、计数或容量字段的对象,0x80/0x88/0x8C 附近像任务表或条目表状态。
随后调用:
0xab3204 BL sub_AB2800
sub_AB2800 会从全局 obj__2 构造一个临时列表/容器:
sub_4BC64C(p_ptr, &obj__2)
sub_C7F844(...)
所以 sub_AB31A0 的第一步是拿到一批待处理条目。
之后它遍历这个临时表,每个条目大小是 0x18:
0xab3210 X27 = p_ptr
0xab3228 end = p_ptr + count * 0x18
...
0xab3284 X27 += 0x18
对每个条目,它会取字符串并计算长度:
0xab3290 LDRB W8, [X27]
0xab3294 LDR X9, [X27,#0x10]
0xab329c CSEL X0, X9, X27, EQ ; short/long string data
0xab32a4 BL .strlen
然后用 sub_DB877C / sub_DB8768 / sub_D9B120 做字符串/对象封装:
sub_DB877C(...)
sub_DB8768(...)
sub_D9B120(...)
接着它遍历另一个全局对象表:
obj__3 @ 0x152DD50
qword_152DD58
全局表的每个外层条目大小也是 0x18:
0xab3314 LDR X8, [obj__3 + 8] ; qword_152DD58/count
0xab331c LDR X24, [obj__3] ; obj__3/base
0xab332c end = base + count * 0x18
匹配到外层条目后,又遍历内层表,内层条目大小是 0x60:
0xab3350 X25 = [var_170]
0xab3358 end = X25 + count * 0x60
0xab3370 X25 += 0x60
核心匹配逻辑是比较字符串长度和内容:
0xab33b0 CMP X13, X11 ; length compare
0xab33b4 B.NE next
0xab33e8 BL .memcmp ; content compare
0xab33ec CBNZ W0, next
匹配成功后进入对象绑定/构造:
0xab340c X0 = a1 + 8
0xab3410 BL sub_ABCFD8
0xab3420 X0 = matched_outer_obj + 0x30
0xab342c X2 = matched_inner_obj
0xab3430 BL sub_ABC9D0
sub_ABCFD8 的作用是按条件创建/获取一个对象节点,必要时分配 0x50 字节对象,并初始化其中的链表字段:
if (*a2)
v = sub_ABC700()
else
v = sub_ABC554()
if (need_alloc) {
v = malloc(0x50)
sub_379C00(v + 0x18, a3)
初始化 v+0x30/v+0x38/v+0x40/v+0x48 链表字段
sub_93019C()
++*a4
}
sub_AB31A0 后面有大量混淆分支,但结尾可以看到它会清理临时字符串表,并返回一个整型结果:
0xab7714 if temp_count !=0:
遍历 p_ptr,每个 0x18 条目释放 string buffer
0xab7760 free(p_ptr)
0xab776c W0 = saved_result
0xab77a0 RET
另外,异常/失败路径里会调用:
sub_4BC0DC
sub_4C5E60
sub_4BCCA4
sub_360B7C
这些更像容器断言、异常处理或全局状态恢复,不是正常主路径。
当前判断:
sub_AB31A0 = 规则/任务描述表匹配器 + 对象绑定器
它做的事情大致是:
- 从
obj__2构造一批输入字符串条目。 - 遍历
obj__3/qword_152DD58描述表。 - 用长度和
memcmp匹配字符串。 - 匹配成功后调用
sub_ABCFD8/sub_ABC9D0创建或绑定任务对象。 - 清理临时字符串表并返回状态码。
它不是 SystemAlert 的直接触发函数,但可能参与“检测任务/规则对象”的初始化。如果某条检测分支最终通过虚表调到 sub_36CE08 或 sub_369CA4,sub_AB31A0 这类函数可能就是更早期负责把字符串规则和回调对象装配起来的地方。
14. sub_ABC9D0 静态分析
sub_ABC9D0 是 sub_AB31A0 匹配成功后调用的对象绑定函数之一:
sub_AB31A0:
0xab3420 X0 = matched_outer_obj + 0x30
0xab3428 X1 = local container slot
0xab342c X2 = matched_inner_obj
0xab3430 BL sub_ABC9D0
它不是检测函数,也不直接触发弹窗;它是一个“按字符串 key 查找或插入节点”的容器函数。
函数原型按寄存器实际用途更适合理解为:
sub_ABC9D0(out_pair, container, key_holder, key_string)
其中:
X8 = 返回结构地址 out_pair
X0 = container
X1 = key_holder / 查找状态
X2 = key_string
入口会先把容器指针调整到 container + 8:
0xabc9f8MOVX20, X0
0xabc9fcADDX0, X0, #8
0xabca04LDRX1, [X1]
如果 *X1 != 0,走 sub_ABCD08:
0xabca10 CBZ X1, loc_ABCA30
0xabca28 BL sub_ABCD08
如果 *X1 == 0,走 sub_ABCB5C:
0xabca30 ...
0xabca44 BL sub_ABCB5C
这两个函数都是树/有序容器查找逻辑,内部用字符串长度和 memcmp 比较 key:
sub_ABCB5C:
compare string length
memcmp(s1, s2, n)
根据比较结果向左/右子节点走
sub_ABCD08:
类似逻辑,但从已知节点附近继续查找/定位插入点
sub_ABC9D0 调完查找函数后,返回:
X0 = 找到的节点或插入位置
X1 = 是否需要新建节点
对应代码:
0xabca48MOVX22, X0
0xabca4cMOVX21, X1
0xabca50TSTW21, #0xFF
0xabca54B.EQloc_ABCB04
如果 X21 == 0,说明已经找到现有节点,直接返回:
0xabcb04 STR X22, [X19]
0xabcb08 STRB W21, [X19,#8]
如果 X21 != 0,说明没有找到,需要插入新节点。新节点大小是 0x38:
0xabca58 MOV W0, #0x38
0xabca5c BL sub_14388B0
随后复制 key 字符串到新节点 +0x18:
0xabca64 ADD X0, X0, #0x18
0xabca68 MOV X1, X23
0xabca6c BL sub_379C00
sub_379C00 是 string copy helper,会把 X23 指向的 short/long string 拷贝到目标字符串对象。
新节点结构大致可以按这个理解:
node + 0x00 parent/color/flag 混合字段
node + 0x08 left child
node + 0x10 right child
node + 0x18 key string
node + 0x30 extra/value/list ptr = 0
插入时会根据查找返回的方向,把新节点挂到父节点左/右:
0xabca90 STR X22, [X8,#8] ; left child
...
0xabcaac STR X22, [X8,#0x10] ; right child
...
0xabcac0 root 初始化路径
随后初始化新节点指针关系,并调用平衡/修复函数:
0xabcadc LDR X9, [X22]
0xabcae4 STP XZR, XZR, [X22,#8]
0xabcaf0 STR X8, [X22]
0xabcaf4 BL sub_93019C
最后增加容器节点计数:
0xabcaf8LDRX8, [X20]
0xabcafcADDX8, X8, #1
0xabcb00STRX8, [X20]
最终返回结构:
out_pair[0] = node
out_pair[8] = inserted_flag
对应:
0xabcb04 STR X22, [X19]
0xabcb08 STRB W21, [X19,#8]
当前判断:
sub_ABC9D0 = std::map/set 风格的 find-or-emplace(key) 函数
和 sub_ABCFD8 的关系:
sub_ABCFD8:
分配 0x50 字节节点
节点里还初始化了额外链表字段
sub_ABC9D0:
分配 0x38 字节节点
主要保存 key string
更像内层 key 表或轻量映射表
结合 sub_AB31A0 看,sub_ABC9D0 的作用是把匹配到的内层描述对象挂进某个容器,形成后续任务/规则对象的索引关系。它本身不做检测,只负责容器插入和对象绑定。
15. libkikypspro.so: sub_AB9EA8
15.1 结论
sub_AB9EA8 不是弹窗触发函数,也不是对象调度函数。它更像是一个高度优化的 64-bit 非加密 hash 函数,输入是:
uint64_tsub_AB9EA8(void *data, uint64_t len);
IDA 反编译原型显示为:
__int64 __fastcall sub_AB9EA8(int8x16_t *a1, unsigned __int64 n0x61)
其中 a1 是待 hash 的 buffer,n0x61 是长度。
15.2 行为特征
函数内部按长度分多条路径处理:
len == 0 -> 返回固定常量 0x2D06800538D394C2
len < 4 -> 使用首字节/中间字节/尾字节和长度混合
4 <= len < 9 -> 读取首尾 4 字节
9 <= len <= 16 -> 读取首尾 8 字节
17 <= len <= 0x80 -> 使用 NEON 16 字节块混合
0x81..0xF0 -> 更多 NEON 块处理
len > 0xF0 -> 大 buffer 向量化循环,len >= 0x401 时按 0x400 chunk 处理
大长度路径会使用若干常量表和 NEON 寄存器做并行混合,例如 xmmword_103900、xmmword_103C60、unk_170A40 等。
最终收尾 avalanche 有典型 hash 混合形态:
h ^= h >> 37;
h *= 0x165667919E3779F9;
h ^= h >> 32;
小长度路径还会使用类似下面的乘法常量:
0x9FB21C651E98DF25
0xC2B2AE3D27D4EB4F
0x6782737BEA4239B9
0xAF56BC3B0996523A
整体风格类似 wyhash / XXH3 这一类非加密高速 hash,但目前不能直接断言就是某个公开算法的原版实现。
15.3 在 sub_AB31A0 里的作用:hash_crc 校验
sub_AB31A0 中有一处很关键的调用:
0xAB3C70 LDR X8, [X25,#0x10]
0xAB3C74 LDRSW X1, [X25,#0x50]
0xAB3C78 ADD X0, X0, X8
0xAB3C7C BL sub_AB9EA8
0xAB3C80 CMP X23, X0
结合后续反编译代码,这里的 sub_AB9EA8 更准确地说是 hash_crc 校验函数。它会对两份来源的数据片段分别计算 hash/crc,然后比较结果:
if (p_ptr_2 == SHIDWORD(ptr_57)) {
v49 = sub_DB8760(v341);
p_ptr_2 = hash_crc(v49 + *((uint64_t *)ptr_18 + 1),
*((int *)ptr_18 + 20));
ptr_57 = 0;
sub_ABCFD8(&ptr_7, v328, &ptr_57, &ptr_16);
ptr_57 = 0;
sub_ABC9D0(&ptr_7, ptr_7 + 48, &ptr_57, ptr_18 + 24);
*(uint64_t *)(ptr_7 + 48) = p_ptr_2;
}
v50 = sub_DB8774(v341);
p_ptr_1 = hash_crc(v50 + *((uint64_t *)ptr_18 + 2),
*((int *)ptr_18 + 20));
if (p_ptr_2 != p_ptr_1) {
break;
}
这说明 sub_AB31A0 的核心逻辑不是简单缓存,而是做一致性校验:
来源 A: sub_DB8760(v341) + ptr_18[1] offset
来源 B: sub_DB8774(v341) + ptr_18[2] offset
长度: *((int *)ptr_18 + 20)
hash_A = hash_crc(source_A + offset_A, len)
hash_B = hash_crc(source_B + offset_B, len)
hash_A != hash_B -> break,进入后续失败处理路径
其中 sub_ABCFD8 / sub_ABC9D0 负责找到或创建对应节点,并把第一次计算出来的 hash_A 保存到 node+0x30 / ptr_7+48。
因此更准确的判断是:sub_AB31A0 内部存在一组基于 hash_crc 的完整性/一致性校验。如果两份数据算出的 hash 不一致,就会跳出当前循环,后续很可能进入异常处理或告警路径。结合前面已经确认的 SystemAlert 弹窗链,这个校验失败点很可能是弹窗触发条件之一。
15.4 在 sub_AB2250 / sub_ABC9D0 里的作用
另一条链里,sub_AB2250 会先计算 hash,再通过 sub_ABC9D0 找到或创建节点,最后把 hash 存到节点偏移 +0x30:
0xAB2574 BL sub_AB9EA8
0xAB2578 MOV X23, X0
...
0xAB25B8 BL sub_ABC9D0
0xAB25C4 STR X23, [X8,#0x30]
结合前面对 sub_ABC9D0 的分析,可以推测结构关系大概是:
node = find_or_emplace(container, key);
node->hash = sub_AB9EA8(data, len);
15.5 逆向意义
sub_AB9EA8 负责提供“内容指纹 / CRC-like 校验值”。它本身不直接弹窗,但 sub_AB31A0 会用它比较两份数据是否一致。
和前面的函数串起来看:
sub_AB2250
-> sub_AB9EA8 // 计算 data hash
-> sub_ABC9D0 // 按 key 查找/创建节点
-> node+0x30 = hash // 保存当前 hash
sub_AB31A0
-> sub_DB8760 // 取来源 A base
-> sub_AB9EA8 // 计算来源 A hash_crc
-> sub_ABCFD8
-> sub_ABC9D0 // 找到/创建节点并保存 hash
-> sub_DB8774 // 取来源 B base
-> sub_AB9EA8 // 计算来源 B hash_crc
-> compare hash_A/hash_B
-> 不一致时 break,进入失败处理路径
所以 sub_AB9EA8 可以命名为:
calc_content_hash64
hash_buffer64
rule_payload_hash64
如果后面要动态验证,可以 hook 这个函数打印:
data pointer
length
return hash
调用方 LR / backtrace
重点看它是否只在规则表初始化和规则刷新路径里出现。如果是,就能进一步确认它是规则对象的内容 hash,而不是检测逻辑本体。
15.6 hash_A != hash_B 后的分支
sub_AB31A0 中这段反编译:
if (p_ptr_2 != p_ptr_1)
break;
对应的关键汇编是:
0xAB3C68 BL sub_DB8774
0xAB3C70 LDR X8, [X25,#0x10]
0xAB3C74 LDRSW X1, [X25,#0x50]
0xAB3C78 ADD X0, X0, X8
0xAB3C7C BL hash_crc
0xAB3C80 CMP X23, X0
0xAB3C84 MOV X8, X23
0xAB3C88 B.EQ loc_AB336C
0xAB3C8C LDR X8, [SP,#var_1B8]
0xAB3C90 LDR X8, [X8,#0x70]
0xAB3C94 CBZ X8, loc_AB3D9C
含义:
hash_A == hash_B -> 跳回 loc_AB336C,继续下一项循环
hash_A != hash_B -> 顺序落到 0xAB3C8C,进入失败/变更处理分支
不相等以后并不是立刻弹窗,而是先检查 a1 + 0x70:
callback_obj = *(a1 + 0x70);
if (!callback_obj)
goto loc_AB3D9C;
如果 callback_obj 存在,后面会做第二层去重/缓存判断:
0xAB3CB0 BL sub_ABD170
0xAB3CB4 CMP X0, X24
0xAB3CB8 B.EQ loc_AB3D00
...
0xAB3CF4 LDR X8, [X8,#0x30]
0xAB3CF8 CMP X8, X27
0xAB3CFC B.EQ loc_AB3D9C
也就是:
如果这次 hash_B 已经记录过 -> 跳到 loc_AB3D9C,不触发回调
如果没记录过,或者记录值不同 -> 进入 loc_AB3D00 更新记录
真正的回调调用点在:
0xAB3D74LDRX0, [X9,#0x70]
0xAB3D7CCBZX0, loc_AB7804
0xAB3D80LDRX8, [X0]
0xAB3D84LDRX8, [X8,#0x30]
0xAB3D88SUBX1, X29, #-var_48
0xAB3D8CADDX2, SP, #var_138
0xAB3D90ADDX3, SP, #var_148
0xAB3D94ADDX4, SP, #var_158
0xAB3D98BLRX8
伪代码可以写成:
if (hash_A != hash_B) {
callback_obj = ctx->field_70;
if (callback_obj) {
old = find_saved_hash(...);
if (!old || old->hash != hash_B) {
node = find_or_create(...);
node->hash = hash_B;
callback_obj->vtable[0x30 / 8](
callback_obj,
&key_or_name,
&desc_string,
&hash_A_low32,
&hash_B_low32
);
}
}
}
所以这条分支的意义是:
hash_crc 校验失败
-> 检查是否配置了回调对象 a1+0x70
-> 检查该失败项是否已经处理过
-> 没处理过则更新 hash 记录
-> 调用 callback_obj 的 vtable+0x30
结合之前动态栈里多次出现的 vtable+0x30 -> sub_36CE08/sub_369CA4 -> sub_61482C -> startActivity,这里很可能就是把“校验失败事件”派发给上层处理器的地方。弹窗不是在 CMP/B.NE 当场发生,而是在这个虚函数回调后面的处理链里发生。
15.7 动态验证结果:hash_crc 失败进入 SystemAlert
动态 hook 0xAB3C80 得到:
[sub_AB31A0 hash_crc compare]
hash_A/x23 = 0x608a672c064ca5cf
hash_B/x0 = 0x73a08eafdb92622d
equal = false
说明 sub_AB31A0 内部确实出现了两份数据 hash_crc 不一致。
随后 hook 0xAB3D98 BLR X8 得到:
[sub_AB31A0 callback BLR X8]
x8 = libkikypspro.so!0xabc344
rva = 0xabc344
动态验证进一步确认:0xAB3D98 的 X8 没有直接指向 sub_36CE08 或 sub_369CA4,而是先指向 sub_ABC344。
sub_ABC344 是一个桥接包装函数:
void sub_ABC344(ctx, key, desc, hash_A_low32, hash_B_low32)
{
obj = *(ctx + 0x30);
if (obj) {
obj->vtable[0x30 / 8](
obj,
&key,
&desc,
&hash_A_low32,
&hash_B_low32
);
}
}
关键汇编:
0xABC374 LDR X0, [X0,#0x30]
0xABC380 CBZ X0, loc_ABC3C0
0xABC384 LDR X8, [X0]
0xABC398 LDR X8, [X8,#0x30]
0xABC39C BLR X8
也就是说完整分发关系是:
sub_AB31A0
-> hash_crc 比较失败
-> 0xAB3D98 BLR X8
-> sub_ABC344
-> obj = *(ctx + 0x30)
-> obj->vtable+0x30
-> sub_36B564/sub_36CE08/sub_369CA4 等具体告警处理器
动态日志显示后面进入了 sub_61482C,并且参数就是弹窗文案:
[sub_61482C] enter
a1 = "Hook Detected!"
a2 = "Detected that the system has been hooked, or is running in an abnormal environment."
a3 = 3
lr = libkikypspro.so!0x36cc5c
随后进入 JNI startActivity:
sub_61482C
-> sub_689370
-> sub_38E8F4 CallVoidMethodV
-> startActivity(Landroid/content/Intent;)V
因此现在可以确认:
sub_AB31A0 的 hash_crc 校验失败
-> 经 sub_ABC344 桥接
-> 进入告警处理器
-> sub_61482C 接收并转发 Hook Detected / Malicious framework detected 文案
->startActivity(SystemAlert)
这里的弹窗不是 sub_AB31A0 直接创建的,而是 sub_AB31A0 产生校验失败事件,交给后面的虚函数回调链处理。
16. libkikypspro.so: sub_61482C
16.1 结论
sub_61482C 可以命名为:
native_start_system_alert
dispatch_alert_to_java
show_system_alert_activity
它不是最早的检测函数,也不是 SystemAlert.onCreate。它是 native 层的告警分发/启动函数,负责把上游检测器传来的告警标题和正文继续传给 sub_689370,最终通过 JNI 调用 Context.startActivity(Intent) 拉起 com.kikyps.kikypspro.SystemAlert。
16.2 静态分析依据
函数原型可整理为:
voidsub_61482C(char *title, char *message, int level_or_type);
入口处保存参数:
0x61484C MOV X19, X1 ; message
0x614854 MOV X20, X0 ; title
0x614864 MOV W21, W2 ; level/type
随后它会加锁并做状态检查:
0x614858 ADRL X1, mutex_
0x61486C BL sub_4C56F0
0x61488C BL sub_614484
0x614890 TBNZ W0,#0, loc_6148FC
如果全局告警上下文没有初始化,还会创建一批对象:
0x614898 CBZ qword_152BB00, loc_61493C
...
0x614950 BL malloc_like(0xA8)
0x614990 BL sub_E0E6F4(..., sub_616590)
核心调用在 0x6148D8:
0x61489C BL sub_10923E0
0x6148B8 BL sub_614758
0x6148C4 MOV X0, X21
0x6148C8 MOV X1, X22
0x6148CC MOV X2, X23
0x6148D0 MOV X3, X20 ; title
0x6148D4 MOV X4, X19 ; message
0x6148D8 BL sub_689370
所以 sub_61482C 本身没有直接操作 Java Intent,而是把 title/message 传给下一层 sub_689370。
16.3 动态分析依据
hook sub_61482C 后,运行时参数直接显示告警文案:
[sub_61482C] enter
a1 = "Hook Detected!"
a2 = "Detected that the system has been hooked, or is running in an abnormal environment."
a3 = 3
另一组:
[sub_61482C] enter
a1 = "Malicious framework detected"
a2 = "Illegal action detected!"
a3 = 3
这说明:
a1 = alert title
a2 = alert message
a3 = alert type / severity / code
同时动态栈显示它的上游来自不同告警处理器:
sub_AB31A0 hash_crc failed
-> sub_ABC344
-> sub_36B564 / sub_36CE08 / sub_369CA4
-> sub_61482C
例如:
sub_61482C
lr = libkikypspro.so!0x36cc5c
0x36cc5c 位于 sub_36B564 内部。
sub_61482C
lr = libkikypspro.so!0x36e4f0
0x36e4f0 位于 sub_36CE08 内部。
sub_61482C
lr = libkikypspro.so!0x36b3b8
0x36b3b8 位于 sub_369CA4 内部。
16.4 下游链路
sub_61482C 调用 sub_689370:
sub_61482C
-> sub_689370
动态日志里 sub_689370 收到的 arg3/arg4 正是 sub_61482C 的标题和正文:
[sub_689370] enter
arg3 = "Hook Detected!"
arg4 = "Detected that the system has been hooked, or is running in an abnormal environment."
随后:
sub_689370
-> sub_38E8F4
-> JNIEnv CallVoidMethodV
-> startActivity(Landroid/content/Intent;)V
-> com.kikyps.kikypspro.SystemAlert
结合动态参数和后续 startActivity 调用,可以把这条链路整理为:
sub_61482C 是当前样本中 native 层的统一告警入口。
上游检测器负责判断异常类型。
sub_61482C 负责接收 title/message/code。
sub_689370 负责把这些内容包装成 Java Intent 并调用 startActivity。
16.5 闭环分析过程
sub_61482C 的定位不是单独靠反编译猜出来的,而是通过动态栈、静态调用点和运行时参数三部分交叉验证出来的。
第一步,先从最终弹窗位置反推 native 调用栈。
hook JNI CallVoidMethodV,只在方法名为 startActivity(Landroid/content/Intent;)V 时打印 native backtrace,得到:
[sub_38E8F4 CallVoidMethodV wrapper] startActivity
lr = libkikypspro.so!0x68b1bc
native backtrace:
#0 libkikypspro.so!0x68b1bc
#1 libkikypspro.so!0x6148dc
#2 libkikypspro.so!0x36cc5c / 0x36e4f0 / 0x36b3b8
...
其中 0x6148dc 位于 sub_61482C 内部。这个地址非常关键,因为它不是函数入口,而是 sub_61482C 调用下一层函数返回后的地址。
第二步,静态确认 0x6148dc 的前一条调用。
反汇编 sub_61482C 可见:
0x6148C4 MOV X0, X21
0x6148C8 MOV X1, X22
0x6148CC MOV X2, X23
0x6148D0 MOV X3, X20
0x6148D4 MOV X4, X19
0x6148D8 BL sub_689370
0x6148DC ...
因此动态栈里的 0x6148dc 可以准确解释为:
sub_61482C 调用了 sub_689370,当前栈回溯点落在调用返回地址 0x6148dc。
第三步,确认 sub_61482C 的参数含义。
入口处参数保存逻辑:
0x61484C MOV X19, X1
0x614854 MOV X20, X0
0x614864 MOV W21, W2
调用 sub_689370 时:
0x6148D0 MOV X3, X20
0x6148D4 MOV X4, X19
说明:
sub_61482C arg0 -> sub_689370 arg3
sub_61482C arg1 -> sub_689370 arg4
sub_61482C arg2 -> 本地保存为 W21,作为告警类型/等级参与后续逻辑
动态 hook sub_61482C 后,运行时参数直接显示:
[sub_61482C] enter
a1 = "Hook Detected!"
a2 = "Detected that the system has been hooked, or is running in an abnormal environment."
a3 = 0x3
另一条:
[sub_61482C] enter
a1 = "Malicious framework detected"
a2 = "Illegal action detected!"
a3 = 0x3
所以 sub_61482C 的参数含义可以确定为:
voidsub_61482C(char *title, char *message, int type);
第四步,确认下游确实启动了 Java 弹窗。
sub_689370 进入时收到同样的字符串:
[sub_689370] enter
arg3 = "Hook Detected!"
arg4 = "Detected that the system has been hooked, or is running in an abnormal environment."
随后 sub_689370 内部调用:
0x68B1B8 BL sub_38E8F4
0x68B1BC ...
动态 hook sub_38E8F4 映射到 JNI 方法:
[sub_38E8F4 CallVoidMethodV wrapper] startActivity
method = startActivity(Landroid/content/Intent;)V
lr = libkikypspro.so!0x68b1bc
Java 层 hook 也确认最终 Intent 指向:
Intent {
cmp=com.kikyps.crackme/com.kikyps.kikypspro.SystemAlert
}
所以 sub_61482C -> sub_689370 -> sub_38E8F4 -> startActivity(SystemAlert) 这条下游链闭合。
第五步,确认上游来源。
sub_61482C 有多条上游告警处理器调用路径:
sub_36B564 + 0x16F8 -> sub_61482C
sub_36CE08 + 0x16E8 -> sub_61482C
sub_369CA4 + 0x1714 -> sub_61482C
动态日志中分别表现为:
lr = libkikypspro.so!0x36cc5c
lr = libkikypspro.so!0x36e4f0
lr = libkikypspro.so!0x36b3b8
其中 sub_AB31A0 的 hash_crc 失败路径已经验证到:
sub_AB31A0 hash_crc compare
equal = false
sub_AB31A0 callback BLR X8
x8 = sub_ABC344
sub_ABC344
-> obj->vtable+0x30
-> 告警处理器
-> sub_61482C
因此完整闭环为:
sub_AB31A0 做 hash_crc 一致性校验
-> hash_A != hash_B
-> sub_ABC344 桥接回调
-> sub_36B564 / sub_36CE08 / sub_369CA4 告警处理器
->sub_61482C(title, message, type)
->sub_689370(env, context, global, title, message)
-> sub_38E8F4
-> JNIEnv->CallVoidMethodV(startActivity)
-> SystemAlert
所以 sub_61482C 的最终定位是:
native 层统一告警分发入口。
它接收已经确定的告警标题、正文和类型,负责把告警事件送入 JNI 启动 Activity 的下游链路。
17. libkikypspro.so: sub_689370
17.1 结论
sub_689370 是 native 到 Java 的告警启动桥接函数。它的作用是:
接收 JNIEnv / Context / 全局 Java 对象 / title / message
-> 查找 Java 类和方法
-> 构造 Intent / 设置参数
-> 调用 Context.startActivity(Intent)
可以命名为:
build_and_start_system_alert_intent
jni_start_system_alert
start_alert_activity_jni
17.2 参数来源
sub_61482C 调用它的位置:
0x6148C4 MOV X0, X21
0x6148C8 MOV X1, X22
0x6148CC MOV X2, X23
0x6148D0 MOV X3, X20 ; title
0x6148D4 MOV X4, X19 ; message
0x6148D8 BL sub_689370
动态日志对应:
[sub_689370] enter
arg0 = JNIEnv*
arg1 = jobject / Context
arg2 = global helper/class/method holder
arg3 ="Hook Detected!"
arg4 = "Detected that the system has been hooked, or is running in an abnormal environment."
所以参数可以整理为:
void sub_689370(JNIEnv *env, jobject context, void *global, char *title, char *message);
17.3 JNI 行为证据
函数内部大量通过 JNIEnv 函数表调用 JNI API。
开头检查参数后,先通过 env->GetObjectClass(context) 类似的调用获取对象类:
0x6893C0 LDR X8, [X0]
0x6893CC LDR X8, [X8,#0xF8]
0x6893D0 BLR X8
随后多次检查 JNI 异常:
0x6893E4 LDR X8, [X8,#0x720]
0x6893E8 BLR X8
释放 local ref 的清理逻辑集中在:
0x689444 LDR X1, [SP,#var_68]
0x689448 LDR X8, [X8,#0xB8]
0x68944C BLR X8
0x108 偏移用于 GetMethodID 一类方法查找:
0x6896B0 LDR X8, [X19]
0x6896C4 LDR X8, [X8,#0x108]
0x6896C8 BLR X8
最终关键调用:
0x68B1A8 MOV X0, X19
0x68B1AC MOV X1, X22
0x68B1B0 MOV X2, X27
0x68B1B4 MOV X3, X26
0x68B1B8 BL sub_38E8F4
0x68B1BC ...
sub_38E8F4 是 JNI varargs 包装:
v3 = *(env->functions + 0x1F0);
v3(env, obj, method, va_list);
动态 hook 已经把它映射成:
CallVoidMethodV startActivity(Landroid/content/Intent;)V
17.4 和动态栈的对应关系
动态栈里:
[sub_38E8F4 CallVoidMethodV wrapper] startActivity
lr = libkikypspro.so!0x68b1bc
0x68B1BC 正好是:
0x68B1B8 BL sub_38E8F4
0x68B1BC ...
也就是 sub_689370 调用了 CallVoidMethodV(startActivity)。
同时 sub_689370 的参数保留了上游告警文本:
[sub_689370] enter
arg3 = "Hook Detected!"
arg4 = "Detected that the system has been hooked, or is running in an abnormal environment."
所以完整关系是:
sub_61482C(title, message, type)
->sub_689370(env, context, global, title, message)
-> JNI GetObjectClass / FindClass / GetMethodID
-> 构造 Intent / 设置 SystemAlert 参数
-> sub_38E8F4
-> JNIEnv->CallVoidMethodV(context, startActivity, intent)
17.5 函数定位
sub_689370 不负责检测,也不负责生成告警文案。告警文案在进入它之前已经确定。
它负责的是:
native 告警事件 -> Java Activity 启动
因此它是弹窗链路里最关键的 JNI 桥接层。
18. Frida 脚本: ok.js 闭环说明
18.1 脚本目标
ok.js 当前承担两个目标:
1. 等 libcrackme.so 自实现 dlopen 加载 libkikypspro.so 后,再对 libkikypspro.so 安装 hook。
2. 通过绕过关键检测结果,让程序继续运行,同时 hook sub_61482C 打印告警文案和调用堆栈。
这份脚本的核心不是一开始就直接 hook libkikypspro.so。原因是 libkikypspro.so 并不是普通系统 loader 直接加载的,而是由 libcrackme.so 内部的 sub_26538 自实现 dlopen-like wrapper 加载。
因此脚本先 hook 系统 android_dlopen_ext,等 libcrackme.so 加载完成:
function dlopen() {
Interceptor.attach(Module.getExportByName(null, "android_dlopen_ext"), {
onEnter: function (args) {
this.path = readStr(args[0]);
},
onLeave: function (retval) {
if (this.path.indexOf("libcrackme.so") >= 0) {
console.log("[dlopen] loaded " + this.path);
hookSub26538();
}
}
});
}
然后再 hook libcrackme.so + 0x26538:
var target = crackme.base.add(0x26538);
Interceptor.attach(target, {
onEnter: function (args) {
this.path = readStr(args[0]);
this.flags = args[1].toUInt32();
},
onLeave: function (retval) {
if (this.path.indexOf("libkikypspro.so") >= 0 && !retval.isNull()) {
var pro = Process.findModuleByName("libkikypspro.so");
...
}
}
});
这一步的意义是:
sub_26538("/.../libkikypspro.so", 2) 返回非空
-> libkikypspro.so 已经映射进进程
-> Frida 可以通过 Process.findModuleByName 找到 base
-> 后续所有 libkikypspro.so + offset hook 才是有效地址
18.2 为什么在 sub_26538 返回后 hook libkikypspro.so
动态日志已经验证:
[sub_26538]
path = /data/app/.../lib/arm64/libkikypspro.so
flags = 0x2
ret = 0xb400fee882a4f850
[libkikypspro.so]
base = 0x...
path = /data/app/.../lib/arm64/libkikypspro.so
也就是说,sub_26538 是 libcrackme.so 到 libkikypspro.so 的加载交接点。
如果脚本过早 hook libkikypspro.so + 0x61482C,模块还不存在,地址无法计算;如果等 sub_26538 返回后再 hook,就能稳定拿到:
var base = pro.base;
hook_message(base);
hook_crc(base);
sub_6CEE14(base);
hook_AEB528(base);
hook_AC7A90(base);
18.3 当前脚本的绕过点
脚本里真正参与当前绕过的点主要有四组。
第一组,hook_crc(base):
function hook_crc(base) {
Interceptor.attach(base.add(0xAB3C80), {
onEnter: function (args) {
this.context.x23 = this.context.x0;
}
})
}
0xAB3C80 是 sub_AB31A0 中比较两份 hash_crc 的位置:
0xAB3C7C BL hash_crc
0xAB3C80 CMP X23, X0
0xAB3C88 B.EQ loc_AB336C
寄存器含义:
X23 = hash_A
X0 = hash_B
脚本在比较前执行:
this.context.x23 = this.context.x0;
等价于强制:
hash_A = hash_B
这样 CMP X23, X0 一定相等,随后走:
B.EQ loc_AB336C
也就是跳回正常循环,不进入:
0xAB3C8C hash mismatch handler
-> sub_ABC344
-> 告警处理器
-> sub_61482C
这就是绕过 sub_AB31A0 hash_crc 校验触发弹窗的关键点。
第二组,sub_6CEE14(base) 下的返回值替换:
hook_6D3C44(base) -> retval.replace(0xF5)
hooK_74DE1C(base) -> retval.replace(0xF5)
hook_7503C0(base) -> retval.replace(0xF5)
hook_756F0C(base) -> retval.replace(0xF4)
hook_75B1D0(base) -> retval.replace(0xF4)
这些 hook 对应的是 sub_6CEE14 / sub_369CA4 这一类检测分支的下游判断函数。动态栈里这一支会进入:
sub_369CA4
->sub_61482C("Malicious framework detected", "Illegal action detected!", 3)
脚本把这些检测函数返回值改成期望的正常值,避免进入恶意框架告警路径。
第三组,hook_AEB528(base):
hook_AF0098(base) -> retval.replace(0xC2)
这属于另一条检测分支的返回值修正。它不是弹窗启动函数本身,而是让上游检测状态保持在“通过/正常”的值。
第四组,hook_AC7A90(base):
hook_11442C0(base) -> retval.replace(0xF4)
这一组对应动态栈里的另一条告警来源:
sub_AC7A90
-> sub_ACE184
-> sub_36CE08
->sub_61482C("Hook Detected", ...)
通过改写返回值,脚本阻断这条检测分支继续产生告警事件。
18.4 为什么 hook sub_61482C 能确认弹窗来源
脚本中的:
function hook_message(base) {
Interceptor.attach(base.add(0x61482C), {
onEnter: function (args) {
console.log("\n[sub_61482C]");
console.log("message = " + args[1].readCString());
console.log(
Thread.backtrace(this.context, Backtracer.FUZZY)
.map(DebugSymbol.fromAddress)
.join("\n")
);
}
});
}
sub_61482C 已经静态和动态确认是:
voidsub_61482C(char *title, char *message, int type);
所以 hook 它可以直接看到:
message = "Detected that the system has been hooked, or is running in an abnormal environment."
message = "Illegal action detected!"
同时 backtrace 能看到是谁调用了 sub_61482C:
sub_36CE08 / sub_369CA4 / sub_36B564
-> sub_61482C
这就是“通过堆栈看谁调用了 0x61482C”的方法。
对应的动态堆栈截图:

19. 总结
本次分析的核心不是单独定位某一个可 hook 的函数,而是把 Garuda Defender native 层的告警展示链路和环境检测链路分别闭环。
告警展示链路可以分成两段。第一段是检测结果触发后的 native 告警入口:上游检测器命中后,通过 sub_61482C 接收告警标题、内容和类型,再经过 sub_689370、sub_38E8F4 调用 JNI startActivity,最终启动 com.kikyps.kikypspro.SystemAlert。第二段是 SystemAlert 启动后的弹窗构造流程:RegisterNatives 注册到 libcrackme.so 的 wrapper,wrapper 再通过自定义 resolver 解析到 libkikypspro.so + 0x37A754,后续进入 sub_68BD10,并由 sub_64B840 构造和显示 AlertDialog。
因此,sub_61482C 更适合看作当前样本 native 层的统一告警入口,sub_689370/sub_38E8F4 负责把告警结果送回 Java 层,sub_68BD10/sub_64B840 则负责 SystemAlert 页面内的弹窗内容构造和展示。这几个点的定位都不是只靠 IDA 伪代码判断,而是结合了运行时参数、JNI 调用日志、返回地址 lr、native backtrace 和静态 callsite 交叉验证。
环境检测链路则从 pthread_create 开始。libkikypspro.so + 0x11AAFBC 是线程模板入口,它通过 LDR X8, [X8,#0x10] / BLR X8 从任务对象中取出真实执行函数。动态记录 X8 后,可以定位到 0x911970,再进入 sub_81DDF0。sub_81DDF0 初始化并注册了 14 个环境检测任务,这些任务不应简单全部归为 root 检测,而应理解为覆盖 root、root-hide、Hook、注入、运行时特征和异常路径等多个检测面的任务集合。
在这 14 个检测任务中,0x826C60 已经可以通过动态证据闭环到 root/root-hide 框架检测。它在当前环境中返回 0x697,同时字符串解密 hook 捕获到 magisk、zygisk、kernelsu、apatch、shamiko、riru、/data/adb/ 等特征;将该返回值修正为 0x64 后,主页面 root 检测状态恢复正常。0x82BD98 和 0x83B774 同样在当前环境中返回非 0x64,但具体检测对象还需要继续结合字符串、文件访问、maps/smaps 扫描或返回值传播路径确认。
从绕过验证角度看,0x64 在当前样本和当前环境中是可验证的通过状态码。将命中的异常检测函数返回值修正为 0x64 后,App 可以进入正常主界面,并且 root、Frida 等环境检测结果被压制。这个结论的边界也需要明确:它说明当前版本、当前运行环境下的检测聚合逻辑接受 0x64 作为通过值,但不代表所有版本、所有检测函数都必然使用完全相同的返回语义。
整体来看,Garuda Defender 的 native 防护不是单点判断,而是由多层结构组成:字符串运行时还原、线程任务调度、检测任务表、虚表回调、native 告警入口、JNI Activity 启动、Java AlertDialog 展示。分析这类样本时,单纯静态看伪代码很容易被混淆符号、间接调用和 IDA 反编译误差误导;更可靠的方法是把动态 hook、返回地址、参数日志和静态 callsite 一起使用,逐层确认”谁创建任务、谁执行检测、谁聚合结果、谁触发告警、谁最终展示 UI”。
20. Hermes Agent Skill:把这次分析沉淀为可复用能力
20.1 问题:变体迭代下的重复劳动
前面 1-19 章的分析过程对一个 21MB 的混淆 so(libkikypspro.so)覆盖得已经比较细,但代价也很明确:手工定位 wrapper、跟踪 resolver、追 sub_61482C 的上游 callers、识别 pthread_create 的线程模板、抓 sub_81DDF0 注册的 14 个检测任务,整个过程几乎全是人工。
问题在于 GarudaDefender 这类商业 RASP 不会停留在一个版本。每次版本更新,符号被重新混淆,函数被重新分布,关键偏移(0x37A754、0x68BD10、0x64B840、0x61482C、0x689370、0x11AAFBC、0x911970、0x826C60)几乎全部变化。如果每个变体都从零重做一遍 1-19 章的工作,分析成本会随着版本数线性增长。
但本文 1-19 章实际上已经显露了一个事实:变化的是偏移,不变的是行为模式。
不变的特征
RegisterNatives 在 libcrackme.so 中只是 wrapper,
真正业务在 libkikypspro.so
自定义 resolver(类 dlsym)通过 hash 字符串解析符号,
形态固定为 "wrapper -> resolver -> real func"
native 告警入口具有的汇编/行为特征:
入口处 pthread_mutex 加锁 + 状态字段检查
至少 10+ 个调用方(TBNZ/CBZ 后跳入)
传入参数中 a1/a2 是字符串、a3 是小整数 type code
pthread_create 创建的检测线程使用统一模板,
通过 vtable+0x10 间接调用真实任务
检测任务表通过连续 STP 写入函数指针对,
最后由 register(table, count) 一次注册
字符串解密函数指针存放在 .data 段固定槽位,
调用形态为 "ptr_str(args[0]) -> 解密后的 C 字符串"
检测函数的"通过状态码"在该样本族中保持稳定(0x64)
这些行为特征不会随版本改变。把这些特征写成可执行的方法论,就是 Hermes Agent Skill 的意义。
20.2 笔记 vs Skill:差别在于”是否可执行”
如果把第 1-19 章压缩成一份普通笔记,最自然的写法是:
v4.4.0:
SystemAlert.onCreatenative = libcrackme.so + 0x1B15C
resolver = libcrackme.so + 0x26F78
realonCreate = libkikypspro.so + 0x37A754
alertdispatcher = libkikypspro.so + 0x68BD10
showdialog = libkikypspro.so + 0x64B840
unified alertentry = libkikypspro.so + 0x61482C
hash_crc compareCMP = libkikypspro.so + 0xAB3C80
threadtemplate = libkikypspro.so + 0x11AAFBC
env checkregistrar = libkikypspro.so + 0x81DDF0
rootdetector = libkikypspro.so + 0x826C60
bypass statuscode =0x64
这种笔记对于本版本可以直接 hook,但对下一个版本完全没有指导作用——所有偏移都会变。
把同样的信息写成 Skill 后,描述方式会发生根本变化:
[Skill 节选: 定位 native 统一告警入口]
特征:
函数体足够大(前文 sub_61482C 体量明显高于普通 helper),
入口处对全局 mutex 调用 lock helper,
在多个上游告警处理器中以 BL 形式被调用(>10 个 caller)。
定位步骤:
1. IDA MCP: xrefs_to(pthread_mutex 系列符号或对应 lock helper)
2. func_query: 过滤 "size > 0x100 且 caller 数 >= 10" 的候选
3. 对每个候选 decompile,确认形态为
sub_X(a1: string, a2: string, a3: int)
内部 BL 一个 JNI 包装函数
4. 动态验证:hook 候选地址,观察是否打印告警标题/正文
特征签名:
入口处的 ARM64 序列大致为
SUB SP, SP, #...
STP X28..X19, [SP,#...]
MOV X19/X20 = X1/X0
MOV W21 = W2
ADRL X1, <mutex>
BL <lock helper>
上述序列可作为版本无关签名搜索锚点。
注意这里没有任何具体地址。Hermes Agent 拿到这段 Skill,对任何变体都可以按步骤执行:搜 mutex 系列符号 → 拿 xrefs → 过滤函数体大小和 caller 数 → 反编译形态匹配 → Frida 验证。最后输出的是这个版本里 sub_61482C 等价物的实际偏移。
20.3 针对 GarudaDefender 系列的 Skill 结构
把 1-19 章的方法论拆成 Skill 时,建议按以下结构组织:
1. 架构假设
- libcrackme.so + libkikypspro.so 双 SO 模型
- libcrackme.so 提供 RegisterNatives wrapper + 自定义 dlsym
- libkikypspro.so 承载所有真实检测/告警/字符串还原
2. 入口定位方法
- hook RegisterNatives 拿 SystemAlert/KikyPS 类的 native 注册表
- hook libcrackme.so 内部 dlopen wrapper(本文档中是 sub_26538)
等待 libkikypspro.so 加载完成
3. 告警链路定位方法(对应本文 5/9/10/16/17 章)
- 找 native 统一告警入口(特征:mutex + 大函数 + 多 caller +
a1/a2 字符串 a3 整型)
- 它的下游是 JNI varargs 包装器(特征:从 vtable 偏移 0x1F0 取函数指针)
- 再下游是 CallVoidMethodV(startActivity)
- SystemAlert.onCreate 的真实入口由 RegisterNatives + resolver 解出
4. 完整性校验定位方法(对应本文 13/15 章)
- 找含 hash_crc 比较的函数(特征:BL <hash> 后紧跟 CMP X?, X0 + B.EQ)
- 该 hash 函数的固定常量包括 0x9FB21C651E98DF25 / 0x165667919E3779F9 等
- 比较失败的分支会经一个桥接函数(特征:从 ctx+0x30 取对象,
然后 vtable+0x30 间接调用),再进入告警处理器
5. 环境检测链路定位方法(对应本文 18.8 章)
- hook libc pthread_create
- 过滤 start_routine 落在 libkikypspro.so 范围内的调用
- 该 start_routine 是线程模板,特征:从对象 vtable+0x10 间接调用
- 在 BLR X8 前打印 X8 并按 RVA 去重,得到所有任务真实入口
- 其中负责注册检测任务表的函数特征:
连续 N 次 "STP <ctx_func>, <check_func>, [stack/obj+offset]"
最后 BL <register>(table, count)
count 即检测项数(本样本族目前为 14)
6. 状态码协议
- 在该样本族中,检测函数返回 0x64 = 通过
- 非 0x64(如 0x697 / 0x413 / 0x25c)= 命中
- 验证方式:把命中的检测函数 retval 替换为 0x64,
观察主页面 root 检测和告警弹窗是否消失
7. Frida 脚本生成模板
- dlopen 等待 libcrackme.so 加载
- hook libcrackme.so 自实现 dlopen,等 libkikypspro.so 加载
- 在告警入口下 hook(打印 title/message + backtrace)
- 在 hash_crc CMP 处把 X23 改为 X0(或等价的强制相等)
- 在每个检测任务入口下 hook,retval != 0x64 时 retval.replace(0x64)
8. 字符串解密 hook 方法
- .data 段固定槽位保存解密函数指针
- 在该样本中是 base + 0x15264F0
- 在变体中需要重新定位:搜索"指针被 LDR 后立刻 BLR 且 args[0] 在 hook 后
可读出可见英文 ASCII"的函数
- hook 出口可枚举所有运行时还原的检测特征字符串
9. 已知陷阱
- 任务真实入口 X8 不一定落在 IDA 自动识别的函数起始地址上
(样本中存在 "X8 = sub_X + 4" 的情况,需以动态记录为准)
- SystemAlert 弹窗是分两段产生的:先 startActivity 再 onCreate
两段不能互相替代,绕过任意一段都不够
- hash_crc 失败不立即弹窗,先经桥接函数 + 二次去重判断,
不能只 hook 比较点本身
10. 版本差异速查表
- v4.4.0 偏移见本文档;新版本由 Agent 按上述方法重新定位后追加
每一节都是”该如何重新做这件事”,不是”这次的答案是什么”。
20.4 Agent 在新变体上的执行轨迹
假设拿到 GarudaDefender 的下一个变体(暂称 v4.5.x),加载这套 Skill 后 Hermes Agent 的执行序列大致如下:
Phase 1 — 加载与初始化
terminal:adb install <new.apk>
terminal:unzip -l <apk> | grep "lib/arm64"
terminal:frida -U -f <pkg> --pause (拿 base 用)
Phase 2 — 双 SO 模型确认
IDA MCP:open libcrackme.so / libkikypspro.so
IDA MCP:list_imports,确认 libkikypspro.so 不被系统直接 dlopen
Frida:hook RegisterNatives,拿到 SystemAlert.onCreate 注册地址
(Skill 不假设它仍是 0x1B15C,由这一步实测得出)
Frida:hook android_dlopen_ext + libcrackme.so 内部 dlopen wrapper,
记录 libkikypspro.so 真实 base
Phase 3 — 告警入口定位
IDA MCP:xref_query(pthread_mutex_lock 等价 helper)
IDA MCP:func_query(min_size=0x100, caller_count>=10)
IDA MCP:对每个候选 decompile,按 Skill 中的形态匹配
(a1/a2 字符串、a3 小整型、内部 BL JNI 包装)
Frida:hook 候选地址,观察是否打印 "Hook Detected!" 一类字符串
→ 输出 v4.5.x 中 sub_61482C 等价物的偏移
Phase 4 — 完整性校验定位
IDA MCP:insn_query(mnem="BL", 后续 CMP X?, X0 + B.EQ 模式)
IDA MCP:func 内部包含常量 0x9FB21C651E98DF25 → 命中 hash 函数
IDA MCP:xrefs_to(hash 函数) → 找到比较点
Frida:在比较点 hook,确认 X23/X0 是两份 hash 值
→ 输出 v4.5.x 中 0xAB3C80 等价物的偏移
Phase 5 — 检测任务表枚举
Frida:hook libc pthread_create,过滤 start_routine ∈ libkikypspro.so
Frida:在 start_routine 内部 BLR X8 前打印 X8,按 RVA 去重
→ 拿到所有任务真实入口
IDA MCP:对每个任务入口看其上游,找出包含 "连续 STP 写入函数指针对 +
BL register(table, count)" 模式的函数
→ 输出 v4.5.x 中 sub_81DDF0 等价物 + 检测项数(可能仍是 14,
也可能因版本差异变为 12/16/...)
Phase 6 — 状态码与绕过验证
Frida:对每个检测任务 hook onLeave,记录 retval 分布
Frida:命中(非 0x64)的项替换为 0x64,看主页面状态变化
→ 输出 v4.5.x 实际命中的检测项清单
Phase 7 — 脚本生成与归档
write_file:按 Skill 中的脚本模板,把 v4.5.x 偏移填入,生成 ok.js
terminal:frida -H ... -f <pkg> -l ok.js,验证主页面恢复
skill_manage:在 Skill 的"版本差异速查表"中追加 v4.5.x 一行
(仅追加偏移,方法论部分不需要改)
整个流程不需要逆向人员逐条下指令。Skill 加载后,Agent 按方法论自己跑完。
20.5 Skill 让方法论持续累积
第 19 章总结里提到:”分析这类样本时,单纯静态看伪代码很容易被混淆符号、间接调用和 IDA 反编译误差误导;更可靠的方法是把动态 hook、返回地址、参数日志和静态 callsite 一起使用。”
这句话的重要程度高于任何具体偏移。具体偏移只对一个版本有效,”动态 hook + 返回地址 + 参数日志 + 静态 callsite 交叉验证”这套方法论对整个 RASP 家族都有效。Skill 系统的价值就是让这种方法论能被 Agent 直接执行,而不是停留在文章结论里。
每次分析新变体时遇到的新陷阱(比如 18.8.1 节里”任务真实入口可能落在函数中间”),都可以追加到 Skill 的”已知陷阱”小节。Skill 因此随每次分析变得更完善,下一次分析的起点更高。
把第 1-19 章的工作过程做一次方法论编码,对该 RASP 家族的所有未来版本都是一次投入永久收益的事。这也是把这次分析记录留下来的额外价值——它不只是一次绕过的过程描述,更是一份可以转化为 Agent 可执行能力的素材。
20.5.1 版本漂移下 Skill 的实际表现
商业 RASP 在版本迭代时通常会改三种东西:符号被重新混淆(同一个 mutex 加锁 helper 在 v4.4.0 是 sub_4C56F0,在 v4.5.x 可能完全是另一个偏移和符号)、函数布局被编译器内联策略重排(sub_61482C 等价物可能被拆成两段,也可能被合并进上层调用者)、检测项数量微调(14 个环境检测任务可能变成 12 个或 16 个)。但这三种变化都没有动到行为模式本身:全局 mutex 加锁 helper 仍然存在,且仍然被告警入口调用;告警入口仍然接收 (string title, string message, int type) 三个参数;检测任务表仍然通过”连续 STP 写入函数指针对 + BL register(table, count)“模式注册;通过状态码可能从 0x64 换成 0x65,但用”批量统计返回值找众数”的方法依然能直接定位它。
locate-rasp-alert-entry 这一类 Skill 编码的就是上面这些行为模式,而不是任何一个具体偏移。所以版本变化并不会让 Skill 失效,只会让”在新偏移上重新落地”这一步多跑一次。从 Agent 视角看,跨版本分析的工作量从”重新理解整个样本”压缩成了”按已知方法论重新对地址表”。这是 Skill 化最直接的收益。
如果 Garuda 系列在某个未来版本彻底重构防护架构(比如换掉自定义 dlsym 跳板、改用线程池而不是线程模板),那么对应的 Skill 就需要更新。但这种架构级变化在商业 RASP 里发生频率很低,通常间隔多个版本才出现一次。在两次架构级变化之间,Skill 都能持续生效。
20.5.2 Skill 之间的协同
7 个 Skill 不是孤立模块,而是相互调用形成一张方法论图谱:
locate-rasp-alert-entry
└─ 内部依赖 cross-validate-static-dynamic 验证候选
enumerate-detection-task-table
└─ 内部依赖 trace-thread-template-handoff 拿到任务真实入口
bypass-with-stable-status-code
└─ 内部依赖 enumerate-detection-task-table 输出的检测函数列表
identify-hashcrc-checkpoint
└─ 内部依赖 hook-string-decryptor-slot 还原比较点附近的特征字符串
这种结构意味着 Agent 在执行一个 Skill 时,会按依赖关系自动调起其它 Skill。研究者只需要给一个高层目标(例如”绕过这个新版本的 Hook Detected 弹窗”),Agent 会自己把它拆解成”先 enumerate task table → 再 locate alert entry → 再 bypass via status code”的执行序列,并在每一步用 cross-validate 做交叉验证。
这种协同也带来一个边际效益:单独添加一个新 Skill,往往不只是增加了它自己的能力,还会被现有 Skill 调用,从而提升整个图谱的覆盖度。比如新增一个”识别字符串解密函数指针在 .data 段的位置”的辅助 Skill,会立刻让所有依赖字符串特征的上游 Skill 在新版本上更稳定。
20.5.3 第 N 次分析的成本曲线
传统逆向工作流下,分析第二个、第三个 RASP 变体的成本和分析第一个差不多——经验只在研究者脑子里,无法直接复用。Skill 化之后,成本曲线会发生明显变化:
第 1 次分析 GarudaDefender v4.4.0
→ 从零提取 7 个 Skill,研究者主导
→ 耗时基线 100%
第 2 次分析另一个商业 RASP(比如 Doraemon 系列)
→ 7 个 Skill 中可复用 5 个(商业 RASP 共性)
→ 新增 2-3 个该 RASP 特有的 Skill
→ 耗时降到 50-60%
第 3 次回到 Garuda v5.0
→ 加载已有 7 个 Skill,Agent 自动跑出新版本偏移
→ 研究者只需审核 + 处理 1-2 个新陷阱
→ 耗时降到 20-30%
第 N 次(同家族第 N 个版本)
→ 几乎全自动,研究者只参与异常分支
→ 耗时趋近于"运行一次完整 Frida 验证"的固定成本
也就是说,Skill 不只是一次性沉淀,而是会指数级压缩未来同类工作的成本。同一套 Skill 复用得越多,它覆盖的边界条件越完整,下一次分析的起点就越高。这跟传统逆向”每个版本都要重头来一遍”的成本结构完全不同。
20.5.4 Skill 的边界:哪些环节适合固化,哪些不适合
并不是所有方法论环节都适合写成 Skill。可以固化的是机械性可验证的部分——”找含 mutex 加锁的函数”、”在 BL 后下断点”、”批量 hook 检测函数返回值”——这些都有明确的输入输出和验证方法。
但有些环节高度依赖研究者的直觉判断:在十几个候选函数里挑哪个先反编译、字符串解密 hook 输出的几百条字符串里哪些是真特征哪些是干扰、动态栈里哪条分支值得先深入下钻——这些判断很难写成 Skill,也不应该强行写。Skill 系统设计良好的边界,是把可固化的机械环节交给 Agent,把判断环节留给人。
本文 1-19 章的过程里,Hermes 自主沉淀的 7 个 Skill 全部落在前者范围内;后者那部分,我自己保留了完全控制权。这种分工不是为了”AI 不抢人的工作”,而是因为两类工作的最优执行者本来就不一样:机械验证 Agent 比人快几个数量级且不会犯困,而模式判断在当前阶段仍然是人类研究者的优势项。Skill 化做对的标志,不是”什么都能让 Agent 跑”,而是”该让 Agent 跑的全跑了,剩下的研究者真的需要思考”。
#
看雪ID:秋落
https://bbs.kanxue.com/user-home-662006.htm
*本文为看雪论坛精华文章,由 秋落 原创,转载请注明来自看雪社区
第十届安全开发者峰会【议题征集】-欢迎投稿
往期推荐
安卓逆向基础知识之frida Hook
2025 强网杯和强网拟态部分题解
在逆向分析方面-unidbg真的适合 MCP 吗?
AI静态分析,内核模块隐藏 Frida 特征,绕过linker私有结构遍历崩溃链
某安全so库深度解析
球分享
球点赞
球在看
点击阅读原文查看更多
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 秋落 秋落《我们绕过了 GarudaDefender 整套 Frida 检测,但这已经不是重点了》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。







![[工具推荐]OpenClawSecSkills是一个专门为网络安全人员/渗透测试工程师/红蓝对抗团队整理的Skills集合](/images/random/titlepic/3.jpg)


评论