首页
社区
课程
招聘
[讨论][原创]在逆向分析方面-unidbg真的适合 MCP 吗?
发表于: 6小时前 389

[讨论][原创]在逆向分析方面-unidbg真的适合 MCP 吗?

6小时前
389

MCP(Model Context Protocol)是当下 AI 工具链的热门话题。当 unidbg 社区引入 MCP 功能后,不少人觉得这是个好方向——让 AI 直接操作调试器,下断点、读内存、单步执行,听起来很美好。

事实上,在 unidbg 官方加入 MCP 之前,笔者就已经尝试过给 unidbg 做 MCP 接入。结果发现问题比想象中多得多,最终放弃了这条路线,转而采用 Claude Code 的 Skill(自定义命令)机制来辅助逆向分析。

本文记录这个过程中的思考和踩坑,探讨为什么 unidbg 这类模拟器场景下,MCP 可能并不是最佳选择。

设想是这样的:unidbg 启动后暴露一组 MCP 工具,AI 可以:

逆向工程师只需要对话:"帮我看看这个函数第三个参数指向什么结构体",AI 自动下断点、读数据、推理分析。

后来 unidbg 官方确实做了这件事——一个完全自研的 HTTP+SSE 服务器(约 2600 行代码),暴露了 50+ 个调试工具:

实现得很完整,本质上是一个 AI 驱动的 GDB

但当我们把它放到实际的逆向分析工作流中一评估,就发现了一系列结构性问题。

用过 unidbg 的人都知道,让一个 SO 跑起来最痛苦的不是逆向分析本身,而是补环境——手动模拟 SO 依赖的所有 Java/Android 环境。

unidbg 不是真机,它只模拟 ARM CPU。当 native 代码通过 JNI 回调 Java 层时,unidbg 会抛出异常告诉你:"这个 JNI 方法我不认识,你得自己写 mock。"

这是一个典型的循环:

一个典型的网络请求签名 SO,最终需要 mock 的 JNI 接口清单可能是这样的:

每个 mock 还不是简单返回 null 就行。比如 SharedPreferences->getString() 需要根据 key 返回特定的值(密钥、配置等),Headers->name()/value() 需要维护一个完整的请求头列表并按索引返回。有些 mock 之间还有状态依赖——Buffer->writeString() 写入的数据,后面 Buffer->readByteArray() 要能读出来。

MCP 的工具集全部是运行时调试工具——read_memory、set_register、add_breakpoint 等等。但补环境需要的是:

1. 修改 Java 源码

MCP 没有任何工具能编辑 .java 文件。当 SO 崩溃在一个未实现的 JNI 方法上时,你需要在继承 AbstractJni 的测试类里加一个 case 分支。这是纯粹的代码编辑操作,不是调试操作。

2. 触发编译

改完代码后需要 mvn compile 或 IDE 增量编译。MCP 不接入构建系统。

3. 读取编译/运行报错

unidbg 的报错信息打印在 Java 的 stdout/stderr 上,不是在调试器控制台里。MCP 看不到这些输出。

4. 理解 JNI 语义

补环境不仅是"返回一个值",还需要理解 SO 期望的语义。比如:

这需要理解 OkHttp 的 API 设计,不是简单的内存读写能解决的。

5. 反复重启

补环境的循环是"改一个 mock → 重启整个模拟器 → 看下一个报错"。每次都是完整的进程生命周期。MCP 连接的是一个运行中的调试会话,一旦进程结束,MCP 会话也就断了。

需要的是一个能读报错→改代码→触发编译→重新运行的循环。这恰好是 AI 代码编辑工具(Claude Code、Cursor 等)的核心能力,而不是 MCP 调试工具的能力。

SO 跑通后进入逆向分析阶段。核心工作是"下断点捕获数据→验证算法假设"。

但 hook 点经常选错,这是常态:

最终写出来的代码可能是这样的:

或者更常见的:

unidbg 模拟器每次运行都是从头开始:初始化 → 加载 SO → JNI_OnLoad → 调用目标函数 → 结束。整个生命周期可能只有几秒。错过一个断点,就得重头再来。但 MCP 没有"重启模拟器"的能力。

而代码是声明式的——callCount == 0 写一次,改个数字重跑 3 秒搞定。

每次迭代成本极低,而且之前所有的 mock 环境、初始化逻辑都在代码里,不会丢失。

逆向不是"看数据",而是"提出假设→验证→修正"的循环。

比如你怀疑 SO 里有一段魔改 AES,验证方式是:

然后还有 InvMixColumns、AES-CBC 解密、PKCS#7 验证... 总计 200 行纯算法代码。

AI 通过 read_memory 拿到轮密钥的 hex dump 后,需要在"脑中"做 GF(2^8) 乘法、矩阵运算、S-box 查表... 这不现实。最终 AI 还是会说:"我帮你写个 Python 脚本验证吧"——又绕回了代码生成的路子

MCP 工具集里没有"执行任意计算"的工具。它能读数据,但不能处理数据。

unidbg 的 trace 输出量极大。一个包含自定义虚拟机的 SO,一次函数调用可能产生数十万条指令

MCP 的 trace_code 实现将每条指令都作为一个 JSON 事件推入无界队列:

然后 poll_events 一次性全部拼成字符串返回:

10 万条指令 × 每条 ~300 字节 = 30MB JSON。两个问题:

写文件 + 离线过滤,天然支持任意大小的 trace 数据。

Claude Code 的 Skill 是一种自定义命令机制。在项目目录下创建 .claude/skills/<name>/SKILL.md,定义一个 prompt 模板。使用时输入 /skill-name 触发 AI 按模板生成代码。

核心区别:MCP 让 AI 操作调试器,Skill 让 AI 写代码。

/hook [地址] [描述] — 断点捕获代码生成

/trace [范围] [关注点] — Trace 捕获 + 分析脚本

/verify [算法假设] — 算法验证脚本

/jni [报错信息] — JNI Mock 自动补全

以"捕获 AES 轮密钥"为例:

MCP 方式(交互 8 轮,最终还是要写代码):

Skill 方式(1 轮):

MCP 的产出是一次性操作——"在某地址读 X2 寄存器"执行完就消失了。

代码的产出是永久资产——一个验证脚本不仅是工具,更是算法还原的完整文档。一个典型项目可能积累近 10 个 Java 调试脚本和 30+ 个 Python 验证脚本,每个都能独立重跑,合在一起就是整个算法还原过程的知识沉淀。

代码是声明式的——"我要在第 1 次调用时读 X2 指向的 0xF0 字节"写一次就行,反复执行结果一致。

MCP 是命令式的——每次都要手动走一遍"下断点→continue→poll→读数据"流程。走错一步就废了,而且没有重来的机会。

和 GDB/Frida/IDA 不同,unidbg 是模拟器,每次运行都是从头开始。MCP 适合的是长生命周期进程调试——目标进程持续存在,断点错了可以改,状态随时可查。unidbg 的"短生命周期、反复重跑"特性,天然适合代码驱动而不是交互驱动。

公平起见,MCP 并非一无是处。它适合:

unidbg 逆向的核心循环是"写代码→跑→看结果→改代码",从最初的补环境到最终的算法验证,每一步都是代码驱动。AI 最大的价值不是替你操作调试器,而是替你写代码。

对于 unidbg 这种"短生命周期、需要补环境、反复重跑、需要算法验证"的场景,让 AI 生成代码(Skill)比让 AI 操作调试器(MCP)更合适。

把 AI 当作一个极其高效的代码生成器,远比把它当作一个远程调试操作员更有价值。

运行 → 崩溃: "callObjectMethodV not implemented: okhttp3/Interceptor$Chain->request()"
→ 补上这个 mock → 重新编译运行
→ 崩溃: "callObjectMethodV not implemented: okhttp3/Request->url()"
→ 补上这个 mock → 重新编译运行
→ 崩溃: "callObjectMethodV not implemented: okhttp3/HttpUrl->toString()"
→ ...(重复 20-30 次)
运行 → 崩溃: "callObjectMethodV not implemented: okhttp3/Interceptor$Chain->request()"
→ 补上这个 mock → 重新编译运行
→ 崩溃: "callObjectMethodV not implemented: okhttp3/Request->url()"
→ 补上这个 mock → 重新编译运行
→ 崩溃: "callObjectMethodV not implemented: okhttp3/HttpUrl->toString()"
→ ...(重复 20-30 次)
// 网络库调用链
"okhttp3/Interceptor$Chain->request()"
"okhttp3/Request->url()"
"okhttp3/Request->method()"
"okhttp3/Request->headers()"
"okhttp3/Request->body()"
"okhttp3/Request->newBuilder()"
"okhttp3/Request$Builder->addHeader()"
"okhttp3/Request$Builder->build()"
"okhttp3/HttpUrl->toString()"
"okhttp3/HttpUrl->encodedPath()"
"okhttp3/HttpUrl->encodedQuery()"
"okhttp3/Headers->size()"
"okhttp3/Headers->name()"
"okhttp3/Headers->value()"
 
// IO 缓冲区
"okio/Buffer-><init>()"
"okio/Buffer->writeString()"
"okio/Buffer->readByteArray()"
"okio/Buffer->clone()"
"okio/Buffer->read()"
 
// Android 系统 API
"android/content/Context->getSharedPreferences()"
"android/content/SharedPreferences->getString()"
 
// 文件系统模拟
"/proc/self/status"  → 需要伪造 TracerPid: 0(反调试)
"/proc/self/cmdline" → 需要返回正确的包名
 
// 总计可达 200-300 行 Java mock 代码
// 网络库调用链
"okhttp3/Interceptor$Chain->request()"
"okhttp3/Request->url()"
"okhttp3/Request->method()"
"okhttp3/Request->headers()"
"okhttp3/Request->body()"
"okhttp3/Request->newBuilder()"
"okhttp3/Request$Builder->addHeader()"
"okhttp3/Request$Builder->build()"
"okhttp3/HttpUrl->toString()"
"okhttp3/HttpUrl->encodedPath()"
"okhttp3/HttpUrl->encodedQuery()"
"okhttp3/Headers->size()"
"okhttp3/Headers->name()"
"okhttp3/Headers->value()"
 
// IO 缓冲区
"okio/Buffer-><init>()"
"okio/Buffer->writeString()"
"okio/Buffer->readByteArray()"
"okio/Buffer->clone()"
"okio/Buffer->read()"
 
// Android 系统 API
"android/content/Context->getSharedPreferences()"
"android/content/SharedPreferences->getString()"
 
// 文件系统模拟
"/proc/self/status"  → 需要伪造 TracerPid: 0(反调试)
"/proc/self/cmdline" → 需要返回正确的包名
 
// 总计可达 200-300 行 Java mock 代码
// SO 调用 chain.proceed(request) 期望拿到 Response
// 如果返回 null,后续代码就 NPE 了
// 必须返回一个合法的 Response mock
case "okhttp3/Interceptor$Chain->proceed(Lokhttp3/Request;)Lokhttp3/Response;":
    return responseClass.newObject(null);
// SO 调用 chain.proceed(request) 期望拿到 Response
// 如果返回 null,后续代码就 NPE 了
// 必须返回一个合法的 Response mock
case "okhttp3/Interceptor$Chain->proceed(Lokhttp3/Request;)Lokhttp3/Response;":
    return responseClass.newObject(null);
debugger.addBreakPoint(module.base + 0xABCDE, new BreakPointCallback() {
    int callCount = 0;
    @Override
    public boolean onHit(Emulator<?> emulator, long address) {
        if (callCount == 0) {  // 只在第一次调用时捕获
            RegisterContext ctx = emulator.getContext();
            long x2 = ctx.getLongArg(2);
            byte[] keyCtx = emulator.getBackend().mem_read(x2, 0xF0);
            // 解析轮密钥...
        }
        callCount++;
        return true;
    }
});
debugger.addBreakPoint(module.base + 0xABCDE, new BreakPointCallback() {
    int callCount = 0;
    @Override
    public boolean onHit(Emulator<?> emulator, long address) {
        if (callCount == 0) {  // 只在第一次调用时捕获
            RegisterContext ctx = emulator.getContext();
            long x2 = ctx.getLongArg(2);
            byte[] keyCtx = emulator.getBackend().mem_read(x2, 0xF0);
            // 解析轮密钥...
        }
        callCount++;
        return true;
    }
});

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 30
支持
分享
最新回复 (13)
雪    币: 7
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
感谢分享
6小时前
0
雪    币: 14
活跃值: (604)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
感谢分享 
5小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
666
4小时前
0
雪    币: 269
活跃值: (1482)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
6
4小时前
0
雪    币: 212
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6
6
4小时前
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
666
4小时前
0
雪    币: 180
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8
有道理
4小时前
0
雪    币: 290
活跃值: (1209)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
好帖
3小时前
0
雪    币: 209
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10
666
3小时前
0
雪    币: 266
活跃值: (460)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
11
1
2小时前
0
雪    币: 104
活跃值: (7762)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12
tql
2小时前
0
雪    币: 244
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
13
太牛了学习
1小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
14
666
6分钟前
0
游客
登录 | 注册 方可回帖
返回