首页
社区
课程
招聘
[原创]从死亡现象反推检测点:一次检测链路定位方法论
发表于: 57分钟前 59

[原创]从死亡现象反推检测点:一次检测链路定位方法论

57分钟前
59

前言

本文主要记录实习期间在 Frida 反检测分析过程中的一些思路与实践经验。文章不会涉及具体游戏、厂商或敏感实现细节,更多是对分析流程、定位方法以及个人理解的阶段性总结。

在分析加固 Native 检测逻辑时,最麻烦的往往不是某一个 API Hook,而是:

  • 控制流被 OLLVM 打散;
  • 关键字符串被加密;
  • 部分逻辑运行在 VM 内;
  • 检测线程数量多;
  • 崩溃、退出、杀进程路径混在一起;
  • 很难判断某个函数到底是“检测点”还是“处置点”。

因此,与其一开始就试图完整还原所有混淆函数,不如先抓住一个更稳定的目标:

进程最后是怎么死的?

也就是先找“死亡出口”。

只要能稳定记录到死亡出口的调用方、线程、调用栈和附近上下文,就可以从结果往前倒推:

谁触发了死亡
→ 谁设置了风险标志
→ 风险标志来自哪个检测项
→ 检测项采集了什么输入
→ 使用了什么判断方式

1. 什么是“死亡出口”

这里的“死亡出口”不是检测逻辑本身,而是检测命中之后最终执行的处置路径。

常见出口包括:

kill / tgkill
exit / _exit
abort
raise / pthread_kill
syscall(SYS_kill / SYS_tgkill / SYS_exit)
SIGSEGV / SIGBUS / SIGABRT
主动制造非法访问
ART / odex 内跳转到崩溃路径

这些位置的特点是:
无论前面的检测逻辑如何混淆,最终大概率要通过这些出口让进程退出、崩溃或卡死。

所以先 Hook 它们,可以快速得到“检测链路的尾部”。


2.为什么不直接从检测点开始找

在 OLLVM 或 VM 场景下,直接找检测点会遇到几个问题。

2.1 控制流噪声太大

例如一个简单逻辑:

if (check_frida()) {
    kill(getpid(), SIGKILL);
}

经过混淆后可能变成:

dispatcher
case 0x123:
case 0x456:
case 0x789:
opaque predicate
indirect branch
state variable

如果没有动态锚点,很难判断哪一段才是真正的业务判断。

2.2 API 调用不等于检测点

例如看到 open("/proc/self/maps"),只能说明它读了 maps。

但它可能是:

正常调试信息
崩溃收集
模块枚举
反注入检测
VM 环境初始化

所以单个 API 命中不能直接等于检测点。

2.3 死亡出口更稳定

不管检测方式是:

端口探测
maps 扫描
线程名扫描
时间检测
文件检测
ptrace 检测

最终通常还是会流向:

kill / exit / abort / crash

所以先抓出口,能更快拿到回溯入口。


3.Hook 死亡出口时要记录什么

Hook 死亡出口时,不只是简单打印一句“调用了 kill”。

真正有价值的是记录完整上下文。

建议记录字段如下:

时间戳
线程 ID
线程名
API 名称
API 参数
returnAddress
调用方模块
调用方 RVA
backtrace
当前已加载模块
死亡前最近的敏感 API 记录

例如 tgkill

API: tgkill
pid: xxx
tid: xxx
sig: SIGKILL
caller: libxxx.so + 0x123456
thread: detect_worker
backtrace:
  libxxx.so + 0x123456
  libxxx.so + 0x234567
  libxxx.so + 0x345678
  libc.so   + 0x...

这时最重要的是:

caller = module + RVA

因为它可以直接回到 IDA 里定位。


4.returnAddress 的作用

returnAddress 是动态反推静态位置的核心。

比如 Hook 了 libc 的 connect,命中时拿到:

returnAddress = 0x7a12345678

通过模块基址归一化:

RVA = returnAddress - moduleBase

得到:

libxxx.so + 0x345678

然后在 IDA 中跳转到该 RVA,看附近的逻辑:

BL connect
CMP 返回值
CBZ / CBNZ
写风险字段
调用上报函数
进入 kill / abort 分支

这样就完成了从动态 API 命中到静态代码位置的映射。


5.backtrace 的作用

returnAddress 只能看到直接调用者,但检测逻辑通常有多层封装:

detect_thread_entry
  → detect_dispatcher
    → check_frida_port
      → socket_connect_wrapper
        → connect

如果只看 returnAddress,可能只能看到 wrapper。

而 backtrace 可以帮助区分:

底层 API 包装函数
检测子项函数
检测调度函数
检测线程入口

例如:

connect hit:
  libxxx.so + 0x111111  // connect wrapper
  libxxx.so + 0x222222  // check local port
  libxxx.so + 0x333333  // detect dispatcher
  libxxx.so + 0x444444  // thread entry

这样还原时就不会误把 wrapper 当成检测主逻辑。


6. 需要 Hook 哪些观测边界

6.1 加载边界

dlopen
android_dlopen_ext

用途:

确认检测模块何时加载
确认加载顺序
确认哪个 so 加载后开始死亡
确认检测逻辑是否在 init/init_array 中触发

6.2 线程边界

pthread_create
clone

用途:

记录 start_routine
定位检测线程入口
区分加载期线程和运行期线程
判断检测是否周期执行

重点记录:

start_routine = module + RVA
caller = module + RVA
arg = xxx

6.3 文件读取边界

open
openat
read
pread64
fopen

重点路径:

/proc/self/maps
/proc/self/status
/proc/self/cmdline
/proc/self/fd
/proc/self/task/*/status

用途:

判断是否读取进程映射
判断是否扫描线程名
判断是否枚举 fd
判断是否读取调试状态

6.4 字符串搜索边界

strstr
strcasestr
strcmp
strncmp
memcmp
memmem

用途:

捕获 needle
判断搜索目标
定位字符串检测函数

但要注意:

strstr 没命中,不代表没有字符串检测。

它可能使用:

自写搜索
hash 比较
VM 内比较
加密字符串解密后比较

6.5 网络边界

socket
connect
send
recv

用途:

识别本地端口探测
识别检测上报
识别心跳类通信

典型情况:

connect 127.0.0.1:27042
connect 127.0.0.1:27043

可归类为本地调试端口探测候选。

6.6 时间边界

clock_gettime
gettimeofday
nanosleep
usleep
sleep

用途:

判断检测线程轮询周期
判断是否存在反调试时间差检测
判断是否存在卡顿检测

7. 从死亡出口反推检测点的流程

整体流程可以概括为:

1. Hook 死亡出口
   ↓
2. 记录死亡 API 的 caller 和 backtrace
   ↓
3. 得到 module + RVA
   ↓
4. 回到 IDA 定位处置函数
   ↓
5. 找处置函数的调用者
   ↓
6. 找风险标志或状态字段
   ↓
7. 找风险字段的写入点
   ↓
8. 回溯输入来源
   ↓
9. 结合动态 Hook 证据验证

最终目标不是只知道“进程被 kill 了”,而是还原成:

检测输入:读取 /proc/self/maps
检测方式:搜索 frida/gum 等特征
判断条件:命中后设置 risk_flag
处置方式:risk_flag 非零后进入 tgkill
静态位置:libxxx.so + RVA
动态证据:open/read/strstr/tgkill 调用栈

8. 一个典型反推例子

假设运行时记录到:

tgkill(SIGKILL)
caller = libxxx.so + 0xA100
backtrace:
  libxxx.so + 0xA100
  libxxx.so + 0x9F20
  libxxx.so + 0x8B40

回到 IDA:

0xA100 附近是 tgkill 调用
0x9F20 是风险处置函数
0x8B40 是检测调度函数

继续找 0x9F20 的输入:

if (risk_flag != 0) {
    tgkill(pid, tid, SIGKILL);
}

再找 risk_flag 写入:

risk_flag |= check_port();
risk_flag |= check_maps();
risk_flag |= check_thread_name();

再结合动态日志:

connect 127.0.0.1:27042
caller = libxxx.so + 0x7010

静态看 0x7010

sock = socket(AF_INET, SOCK_STREAM, 0);
ret = connect(sock, "127.0.0.1:27042");
if (ret == 0) {
    risk_flag |= PORT_DETECTED;
}

最后可以写:

检测点:libxxx.so + 0x7010
检测方式:本地端口探测
检测目标:127.0.0.1:27042
处置点:libxxx.so + 0xA100
证据链:connect 命中 → risk_flag 写入 → tgkill

这就是完整闭环。


总结

这类检测还原的关键不是“Hook 到一个 API 就下结论”,而是建立证据链:

输入来源
→ 检测判断
→ 风险标志
→ 上报/处置
→ 死亡出口

“死亡出口”提供的是尾部锚点;
returnAddress 提供动态到静态的映射;
backtrace 提供调用链层级;
单变量实验提供验证。

只有当动态观测和静态数据流能够互相印证时,才适合把某个点写成明确检测点。否则应标为候选或部分验证。

这篇文章是在我实习过程中针对某个保护壳进行绕过时的思路和方法,真正准备秋招中,回复大家的评论会有点慢。希望对大家有帮助。

声明:文档由AI辅助生成,所有的方法与思路皆为一次实战后的总结


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

最后于 53分钟前 被permanent011编辑 ,原因:
收藏
免费 0
打赏
分享
最新回复 (1)
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
感谢分享
20分钟前
0
游客
登录 | 注册 方可回帖
返回