dsh-rea — 逆向分析
REA(github.com/morluto/rea,npm 包 rea-agents,MIT 许可)的 DeepSeek Harness 插件。
免责声明
本项目仅供学习与研究使用。使用者如将其用于其他用途,须自行承担全部相关责任;作者不承担任何责任。
- 本项目提供的是一套逆向分析方法与工具编排,目的是学习二进制分析、样本分析与对抗测试的原理。它不针对任何特定的第三方软件,也不提供破解许可、规避技术保护措施或绕过授权的指引。
- 使用者须自行确保使用行为合法合规,包括但不限于遵守所在地法律法规、软件许可协议与最终用户协议、数据与隐私保护规定。未经授权分析他人软件、提取或传播他人数据、规避技术保护措施等行为,可能构成违法或侵权。
- 使用者因使用、修改、分发本项目(含衍生作品)所产生的一切后果与责任,由使用者自行承担。作者不承担任何责任,包括但不限于直接或间接损失、数据丢失、法律责任及第三方索赔。
- 本项目按「现状」(AS IS)提供,不附带任何明示或暗示的担保,包括适销性、特定用途适用性与不侵权担保。
- 如果你不同意上述任何一条,请立即停止使用并删除本项目及其全部副本。
> 版本说明:本声明自 1.7.1 起写入 README,并随该版本的发布包一并分发。1.7.1 只包含这段声明与版本号变更,没有功能改动;1.7.0 及更早版本在功能上相同,但发布包内不含这段显式声明。凡从本仓库或官方 Release 获取 1.7.1 及之后版本,即视为已阅读并知悉上述声明。
它做什么
- 工具:把 REA MCP 服务器(
rea mcp)的完整工具表注册进 Agent 工具表,公开名为mcp__rea__(与 Claude Code / Codex 同形)。工具的输入 schema 原样沿用,REA 新增/下线工具时会自动重新同步。 - 延迟加载:207 个工具的 schema 标记为
deferLoading,不进请求体,只在模型真的要用某个工具时才加载。常驻开销从 194 KB(≈4.9 万 token)降到 1.4 KB(≈341 token),工具一个不少、照样能调。 - 检索入口:
rea_catalog是一个几百字节的常驻工具,按关键词在 207 个工具里搜名字/描述——因为 schema 延迟了,模型没法直接从工具表里读它们,这个工具就是它的索引。无参调用列出全部 207 个精确工具名(约 6.9 KB),带关键词时给出命中项的一行描述。 - 技能:把 REA 自带的
reverse-engineer-anything技能及其references/参考文档安装到~/.dsh/skills/reverse-engineer-anything/,按 REA 版本号打戳,版本没变就不重拷。 - 环境侧栏:侧栏入口显示运行时、Java/JDK、Ghidra、JADX、Apktool、WABT、Kuna/Rizin 的实际状态;面板内可填写并保存所有绝对路径,Ghidra/JADX/Apktool/WABT 还提供按需准备按钮和进度。
- 就绪检查:
rea_doctor运行逆向服务自己的mcp doctor --json,报告 MCP 子进程能否启动、服务端状态与可用分析引擎。只读,不打开任何目标。 - 一屏状态:
rea_status用一次调用说清"有没有打开目标、绑了哪个 provider、哪些引擎装好了、因此现在能做什么"。207 个工具里有 55 个依赖"当前文档",在 headless 引擎下没有目标就会空转——先看一眼比撞一次墙便宜。 - 结果落盘:单次结果超过
maxResultChars(默认 24 000 字符)时写入~/.dsh/rea-artifacts/<会话>/,模型拿到开头摘要和路径,再用rea_read_artifact分页读。binary_session这类 70 KB 的结果不再挤爆一轮对话。 - 变更留痕(1.3.0 起 fail-closed,1.4.0 补齐旁路):不再靠硬编码名单——已知只读首词之外的新工具默认审计。
gdb_console、rizin_debug_command、cutter_command、observe_native_calls、observe_web_execution这 5 个命令逃生口在 1.4.0 纳入审计;1.5.0 起guard覆盖全部 15 个插件自有工具,路径授权改为按值形态判定(不再依赖键名白名单)。上游destructiveHint/openWorldHint作为保守下限信号,不当权威分类器(207 个里 202 个readOnlyHint=false)。157 个只读工具允许并发调用,并由maxConcurrentReadTools限流;真实装配共 227 工具 + 6 路由。 - 完整 schema 渐进展开(1.4.0):后台独立子进程缓存全部完整输入/输出 schema 与注解(约 3 MB 内存、零上下文开销)。
rea_catalog({tool, path, includeOutput})按 JSON Path 逐字段展开;165 KB 的compare_web_captures默认只给约 1 KB 轮廓。 - 结构化结果与工作流(1.4.0):每次调用把
evidence_id/ 置信度 / 覆盖度 / provider 写进跨会话索引,用rea_query_results复用;rea_batch_triage批量初筛、rea_diff_hint粗配、rea_fuzz_harness生成模板、rea_run_playbook用可信脚本把多次往返压成一次调用。 - 静态对抗初筛:
rea_triage(path)纯本地、不执行样本:整体与分块熵、PE/ELF/Mach-O 节区与入口/导入异常、13 类壳与混淆器签名、字符串与单字节 XOR 探测、可选 YARA,输出带证据的 packed / obfuscated / unpacked 判定与下一步建议。 - 证据出口:
rea_export_report把 Evidence bundle 转成 Markdown 报告与 SARIF 2.1.0,覆盖度、未解未知清单、推断边界都写进产物,可归档或接 CI。 - 产物治理:结果默认保留 7 天并有容量上限;
sensitiveArtifacts让完整结果只回摘要、不落盘;rea_purge_artifacts手动清理。 - 分级提示:反编译/重建类结果强制标注「推断,非源码」,写操作结果标注「已审计」。
- 版本更新:侧栏可检查 GitHub 新版本并一键安装;下载走多层镜像,任一失效自动换下一个,按 Release 公布的 SHA-256 校验(可用
pinnedSha256钉死摘要)。侧栏会列出每个镜像的成功/失败原因与当前安全策略。
为什么不合并工具
207 个平铺的工具名确实又多又杂,但合并会改变模型看到的世界:REA 的工具是按「一个工具一个职责 + 严格输入契约」设计的,合并成一个 rea_run(tool, input) 式的总入口意味着模型每次都要先猜对工具名和参数结构,而它现在是从 schema 里读到的。所以选择保留 207 个工具、用延迟加载解决体积——这是宿主的原生机制(deferLoading 一路通到 DeepSeek 的 defer_loading 字段),不是插件自己的 hack。
rea_catalog 的输出里,每一行已经是可以直接调用的 mcp__rea__* 全名;前缀只加一次。
运行时怎么放
rea-agents 6.7.0 连同完整依赖闭包(136 个包)打成一个 vendor/rea-runtime.tgz(28 MB,带 SHA-256 校验)。首次启用时解包到 ~/.dsh/rea-runtime/,之后 MCP 子进程用宿主自己的 node 以绝对路径直接跑,调用时不经 npm/npx、不需要网络。
之所以用「压缩包 + 首次解包」而不是把 node_modules 原样放进插件:
- 插件目录从 124 MB 降到 28 MB;
- 解包位置在 DSH 主目录而不是工作区 —— 工作区沙箱被观测到会把外部写入的大目录回滚掉,那会让插件某天悄悄坏掉;
- 依赖闭包完整。REA 有大量惰性 require(
saxes→xmlchars、pino→pino-std-serializers……),按「运行时实际 import 过谁」裁过一版,第一次调用就崩;按package.json依赖闭包裁,也漏。只有全量闭包能让 207 个工具都不坏。
解包是原子的:校验 → 解到同级 staging 目录 → 确认 rea-agents/scripts/rea.mjs 在 → 删除旧目录并改名。任何一步失败都保留上一版运行时,不会出现「解包崩了、207 个工具全没」的状态。
实测
1.0.3 时的一次 207 工具全量回归(走解包后的运行时):
| 分类 | 数量 | 含义 |
|---|---|---|
| 跑通 | 14 | 真出了结果(Go/Rust 元数据、.NET 清单、PE 资源等) |
| 参数校验拒绝 | 144 | 工具执行了,按契约拒绝空参数 |
| 无打开目标 | 27 | 工具执行了,报告「没有目标」 |
| 参数不合契约 | 5 | 同上 |
| 缺本机引擎 | 17 | 需要 Ghidra / Hopper / IDA / JADX / Kuna / Apktool |
| 缺依赖包 | 0 | 没有任何工具因为少包而坏 |
握手耗时:冷启动约 13 s,之后约 2.4 s(1.1.0 复测)。
Native 引擎侧栏
插件本体不会携带 Ghidra,因此仍保持约 28 MB。侧栏会自动检查当前环境:
| 引擎 | 版本 | 准备方式 |
|---|---|---|
| Ghidra | 12.1.4 | 侧栏一键下载(约 543 MiB),校验后解包到 ~/.dsh/rea-engines/ghidra/ |
| JADX | 0.7.1(headless MCP jar) | 侧栏一键下载 |
| Apktool | 3.0.3 | 侧栏下载 jar 并生成启动器(Windows 为 .bat,其他平台为可执行 shell 脚本) |
| WABT | 1.0.42 | 仅 Windows x64 有固定版本;其他平台按钮会说明原因并只接受手填路径 |
| Kuna/Rizin | 用户提供 | 只接受绝对路径:项目没有统一的自动安装包 |
| IDA / Hopper / JEB | 商业授权 | 只接受路径 |
Java 探测会枚举 JAVA_HOME、各发行版的安装目录(C:\Program Files\Java、Eclipse Adoptium、Microsoft、Amazon Corretto、Zulu、BellSoft,macOS 的 /Library/Java/JavaVirtualMachines,Linux 的 /usr/lib/jvm),并用 java -XshowSettings:properties 反查 PATH 上那个 java 真正属于哪个 JDK。只有带 javac 的完整 JDK 才算就绪——Ghidra 不能在 JRE 上启动。
下载全部固定版本 + SHA-256;.part 落盘、校验通过才改名,失败不会留下截断的归档。Ghidra 解包成功后删除归档释放 543 MB;解包失败则保留归档,重试不必重新下载。
各引擎与 Java 的许可证和发行版通知归各自项目。
常驻开销实测
| 配置 | 常驻 schema | token / 每请求 |
|---|---|---|
deferCatalog: false(全量广告) |
194 KB | 48 560 |
deferCatalog: true(默认) |
1.4 KB | 341 |
延迟的 207 个工具仍然可调,rea_catalog 可按关键词检索(javascript 命中 22 个,无参列出全部 207 个名字)。
配置(cordis.patch.yml 的 config)
| 键 | 默认 | 说明 |
|---|---|---|
schemaProfile |
compact |
主子进程广告的 input schema 档位。full 会让 207 个工具的 schema 约 652 KB,不进模型上下文但会放大注册表。1.4.0 起完整定义由后台独立探测缓存,不必改这里。 |
outputSchemaProfile |
off |
主子进程不广告 output schema;后台探测子进程用 full 取回完整输出 schema 供 rea_catalog({includeOutput:true}) 按需查看。 |
catalogProbe |
true |
激活后在后台起一个短生命周期子进程,缓存全部完整输入/输出 schema 与安全注解(约 3 MB 内存,零上下文开销)。失败只降级为 compact 模式,不影响激活。 |
imageCapture |
true |
把 REA 返回的 image / 二进制 resource 块写入产物目录并返回路径,不再只给占位文本。 |
toolCallTimeoutMs |
900000 |
单次调用超时。REA 首次起 Ghidra/Hopper 可能几分钟。取消会话时请求会被 notifications/cancelled 撤回,不用等满超时。 |
maxBufferBytes |
10485760 |
stdio 单条消息上限(REA 自己的下限就是 10485760,传更小会被抬回下限)。 |
logLevel |
info |
子进程日志级别。 |
deferCatalog |
true |
把 207 个 REA 工具的 schema 延迟加载(不进入请求体)。置 false 恢复成全部常驻广告——只在确实需要模型直接看见全部 schema 时才这么干,代价是每请求多约 4.8 万 token。 |
enabled |
true |
置 false 时不派生子进程、不注册工具。 |
maxResultChars |
24000 |
单次工具结果的字符预算(4000–200000)。超出部分落盘,返回摘要 + 路径,用 rea_read_artifact 分页。 |
allowDeviceMutations |
false |
是否允许 4 个会删改设备的操作。1.3.0 起破坏性操作按名字推断(uninstall/remove/delete/kill/wipe/overwrite/push/install/stop + 设备名词),上游新增的同类工具也会自动被拦。 |
allowedTargets |
[] |
会话级授权根目录。非空时,工具参数里任何字符串值(含数组与一层嵌套)只要看起来是绝对路径就必须落在这些根下,否则拒绝;键名白名单已在 1.5.0 移除。空 = 不限制。注意这是字符串级授权,不解析符号链接/junction/8.3 短名,不是 OS 隔离。 |
sensitiveArtifacts |
false |
开启后超大结果只返回摘要,完整内容不写磁盘(rea_read_artifact 无法分页,这是刻意的取舍)。 |
maxConcurrentReadTools |
6 |
只读工具并发上限(1–32)。REA 的 stdout 是单条分帧通道,无界并发会撑爆响应缓冲。 |
resultIndexMaxBytes |
8388608 |
跨会话 results.jsonl 容量上限;超出按最旧滚动,写入串行化并带 prevHash 哈希链。 |
artifactRetentionDays |
7 |
旧会话产物保留天数(1–365),同时用于结果索引的过期清理。 |
artifactMaxBytes |
268435456 |
旧会话产物总容量上限(字节),超出按最旧删除。 |
triageMaxBytes |
67108864 |
rea_triage 单次读取上限(1 MB–512 MB),超出部分截断并标注。 |
yaraRulesDir |
空 | 可选,指向含 .yar/.yara 的目录;未安装 yara 或未配置时报告跳过,不算失败。 |
pinnedSha256 |
空 | 64 位十六进制摘要。设置后,Release 公布的摘要与它不符就拒绝安装(下载前),下载字节也按它校验。 |
maxBackups |
2 |
自更新保留的 .bak-* 份数(0–10),避免每次更新累积 30 MB 备份。 |
updateMirrors |
[] |
额外追加的下载镜像前缀,排在自带镜像之后,不会替换官方直连。 |
networkEgressPolicy |
warn |
对上游标了 openWorldHint/destructiveHint 的 108 个工具的策略:allow 不干预、warn 记录审计、deny 直接拒绝。用于分析者侧的外联暴露控制,默认不破坏正常调用。 |
trustedManifestKey |
空 | minisign 公钥文本。设置后只接受验签通过的 manifest 来源(不再把 GitHub Release API 当兜底),验签失败或无 .minisig 时检查更新直接失败,绝不静默降级;侧栏显示实际生效的信任级别。配了公钥就必须发布 update.json.minisig。 |
playbookDir |
~/.dsh/rea-playbooks |
可信 playbook 脚本目录,只接受简单 .mjs 文件名。 |
playbookTimeoutMs |
120000 |
单个 playbook 的墙钟上限(1s–600s)。 |
playbookMaxCalls |
200 |
单个 playbook 允许发起的 REA 调用次数上限(1–2000)。 |
batchTriageLimit |
50 |
rea_batch_triage 单次最多处理的文件数(1–500)。 |
1.8.10 一条路由把整个插件按住了
1.8.3 给 /rea/fs/list 同时注册了 GET 和 POST 两个 exact 路由,本意是「宿主只认 GET 时也不会 405」。宿主按路径去重:
webserver: duplicate exact route "/rea/fs/list"
插件在加载阶段就抛错,面板和工具全都不出现。1.8.3 之后的所有版本都带着这个洞,包括刚发的 v1.8.9——单测覆盖的是处理器,不是「注册表里能不能有两条同路径」。
改法
一条路由,两个方法:
{
path: '/rea/fs/list',
methods: ['GET', 'POST'],
async handler(request, response) {
const overGet = String(request?.method ?? 'GET').toUpperCase() === 'GET';
const target = overGet
? new URL(String(request?.url ?? '/'), 'http://localhost').searchParams.get('path') ?? ''
: String((await readRequestJson(request))?.path ?? '');
...
},
}
三点连带调整:
- 注册时不传
method。传了的话,只认单个方法的宿主会把另一半挡掉;方法校验移到guardRequest,它现在接受数组。 guardRequest对GET/HEAD直接放行,不再要求content-type: application/json——那是给跨站 POST 用的,不适用于只读查询。- 日志里的方法标签改成
GET|POST,出错时不会打成undefined。
回归测试
test/fs-browse.test.js 新增两个用例,其中第一个用的假注册表会在同路径第二次注册时抛错,复刻宿主行为:
the plugin registers each path once, which is what the host enforcesthe single listing route answers GET and POST, and refuses the rest(GET 走查询串、POST 走 JSON、PUT 得 405)
顺带把整张路由表打出来核了一遍:8 条,无重复,/rea/fs/list 是唯一一条不声明单方法的。316 个测试通过。
1.8.9 一个 238 MB 的 APK
上一轮是 Windows 安装包,这一轮是 Android:app-complete-arm64-v8a-release.apk,238,305,217 字节。第一份报告同样错得干脆:
verdict: possibly-obfuscated (confidence 0.45)
anti-analysis markers: peb-flags, cpu-hypervisor, sandbox-tools
next steps: Do not trust decompiler output yet: locate the OEP / unpack before static analysis.
一个 APK 没有 OEP。三件事同时错了:
| 错在哪 | 为什么错 |
|---|---|
| 把 deflate 当加密 | APK 是 ZIP,条目本来就是压缩的,熵 8.0 是预期的,不是混淆 |
| 把 PE 的反分析标记套上去 | peb-flags / cpu-hypervisor 是 Windows 特征,在 ZIP 里命中纯属字符串巧合 |
| 只读了前 64 MB | ZIP 的索引在文件末尾,读头等于什么都没读 |
三处改动
| 模块 | 做了什么 |
|---|---|
lib/android.js |
用文件句柄 seek 到尾部找 EOCD,读中央目录:652 条记录、v2 签名块、dex 数量、ABI 分布、未压缩体积。不整文件读,不解压,不执行 |
lib/axml.js |
自己解析二进制 AndroidManifest.xml:字符串池(UTF-8/UTF-16)、start element、类型化属性。不需要 apktool,也不需要 JVM |
lib/triage.js |
档案类格式不再跑 PE 判据:反分析标记、XOR 探针、未解析高熵区全部跳过;新增 verdict android-package |
顺带修掉两个真实的显示缺陷:regionnull(高熵区没有 RVA 时拼出了 null),以及档案报告里重复三遍的 not parsed。
现在的报告
verdict: android-package (confidence 0.9)
android: ai.deepseek.harness.mobile 0.2.1-rc.209-complete (209) | minSdk 26 targetSdk 28
| 1 dex, 15 native libs [arm64-v8a:15] | 652 entries, 252.6 MB uncompressed | signature v2+
permissions: 25 (INTERNET, ACCESS_NETWORK_STATE, REQUEST_INSTALL_PACKAGES, VIBRATE, RECORD_AUDIO, ...)
exported components: activity ...MainActivity; service ...PhoneControlService; receiver ...ProfileInstallReceiver
manifest flags: allowBackup, cleartextTraffic
experiments:
1. Decode classes*.dex with jadx and the manifest/resources with apktool - the manifest says this is
ai.deepseek.harness.mobile 0.2.1-rc.209-complete, then feed the 15 native libraries back into rea_triage
why: the dex is compressed bytecode and the manifest is binary XML, so neither appears in a string pass;
the 15 .so files are ELF and are the part this tool can already read
confirm with: offline: jadx -d and apktool d -s ; no engine state is needed
关于这个包本身,报告里能直接读出三件事:targetSdk 28(远低于 Play 当前要求)、cleartextTraffic(release 构建仍允许明文)、REQUEST_INSTALL_PACKAGES + QUERY_ALL_PACKAGES + 一个导出的 PhoneControlService。这些不是我猜的,是 manifest 的字节。
边界
- dex 没有反编译。
AndroidManifest.xml我能自己解,Dalvik 字节码不能——那需要 jadx(或 baksmali),而jadx-headless-mcp是 MCP 服务端,不是 CLI,没法像 Ghidra 那样直接驱动。 - 15 个 .so 没有分析。它们是 arm64 ELF,
rea_triage能读,但需要先解压出来——本轮没有写解压器,也没有装 apktool。 apktool 3.0.3/jadx 0.7.1仍是"可安装但未验证"的引擎,上一轮文档里的判断不变。
1.8.8 拿一个真实的 28 MB 安装包试了一遍
前面十一代对抗全是自造样本。这一轮换真东西:Xingchen-1.0.13-windows-amd64-compatible-setup.exe,28,583,971 字节。第一个结论就是错的。
它说「可能加壳,去找 OEP」
verdict: likely-packed-or-protected (confidence 0.65)
entropy: overall 7.996 | high-block ratio 0.971
packers: none matched
真实情况:Inno Setup 6.7.0 安装包。.text 是 Delphi 写的普通 stub,最后 27,533,347 字节是 zlb 压缩载荷,落在最后一个节之后,加载器根本不会映射它。
熵 7.996 量的是那 27 MB 压缩数据,不是代码;「97% 的块是高熵」同理。于是报告把「这个文件大部分是压缩字节」读成了「代码被加密了」,再给出「别信反编译,先找 OEP」——一条不存在路。
五个缺陷
| 缺陷 | 现象 | 改法 |
|---|---|---|
| 容器没被识别 | 没有任何 packer 签名命中,却判定"可能加壳" | 新增 lib/installer.js:按签名识别 Inno / NSIS / InstallShield / 7z / RAR / ZIP / PyInstaller / Electron,并读出版本号 |
| 熵算错了对象 | 全文件熵把载荷算进代码 | 增加 mapped image 熵(最后一个节为止),判定改用镜像熵 |
| 28 MB 里 5 个巧合 | 随机 dword 撞上字典哈希,被报成「5 个从哈希解析出的导入」 | 哈希命中必须成簇:≥3 条且相邻 ≤64 字节;D6–D10 的表是连续 dword 数组,一条不少 |
| 「120 sampled」其实是前 120 条 | 采样停在大文件开头,也就是 DOS 头与 .text 起始 |
32 个窗口均匀覆盖全文 |
| 伪代码里混日志前缀 | 12:33:24 INFO ProbeAddress.java> 出现在 C 代码第一行 |
逐行剥离 Ghidra 的 log4j 装饰(整行删除会删掉代码,前缀只出现在每段第一行) |
现在的报告
verdict: installer-with-payload (confidence 0.9)
entropy: overall 7.996 ... | mapped image 7.003 over 1050624 bytes
container: Inno Setup installer 6.7.0 - 27533347 bytes of Inno Setup "zlb" compressed blocks after file offset 0x100800
toolchain: Delphi / C++ Builder (sections .itext + .didata/.edata)
strings: 120 sampled across 32 windows (ascii)
imports: 5 DLLs / 150 APIs (150 named)
experiments:
1. Extract the 27533347 bytes of Inno Setup "zlb" compressed blocks ... (Inno Setup installer 6.7.0),
then run rea_triage on each PE in the result
why: the payload is compressed archive data the loader never maps, so no function-level
work on this Delphi / C++ Builder stub can describe the application
confirm with: offline: 7z x or innoextract - nothing in this tool surface executes the payload
container 与 toolchain 是两个新字段;nextSteps 对这类样本的第一句改成「这是容器,不是被保护的二进制」。
反编译本身是通的
用 1.8.7 的第一方引擎,直接反编译入口 RVA 0xb1e60:
decompile_address rva 0xb1e60
engine: ghidra-local, cached (11.4s)
function: entry @ 004b1e60
verdict: decompiled
void entry(void)
{
LPCWSTR lpLibFileName;
...
uStack_28 = *in_FS_OFFSET;
*in_FS_OFFSET = &uStack_28;
首次分析 28 MB 用了 125 秒,项目缓存后 11.4 秒。Delphi 的 SEH 初始化、LoadLibrary 调用都在,说明引擎对真实编译器产物没有水土不服。
没做到的
- 载荷没有解开:机器上没有
innoextract,也没有7z。Inno 6 的zlb块头不是裸 zlib(zlb\x1a之后既不是78 9c也不是 raw deflate),硬解会得到一个自造的、半对的提取器——比不做更糟。报告现在直接说「先解包」,并把这一步排在实验第一位。 - 因此里面那个应用没有被分析。安装器 stub 的导入表、反调试标记说的都是 Inno 的运行时,不是星尘客户端本身。
1.8.7 引擎跑不起来,那就自己开一个
1.8.6 结尾写着第五道门堵不上:Ghidra 必须在 -projectPath 里建 .gpr / .rep,而那个路径在 REA 的私有运行时里,参数由 REA 传、目录由 REA 建、DACL 由 REA 装。结论是对的,方向是错的。 当时只想把 REA 的那条路修通,没问一句:为什么一定要走 REA?
同一个 Ghidra,换个启动者
.tools/ghidra_12.1.4_PUBLIC/support/analyzeHeadless.bat 一直都在。绕开 REA 直接调它,项目目录放在插件自己可写的位置,五个门一个都不存在:
| 门 | REA 的路径 | 本插件自己启动 |
|---|---|---|
| tmp / cache / config | 私有运行时内,JVM 不可写 | _JAVA_OPTIONS 指到自己的目录 |
| ghidra.log | REA 用 -log 固定 |
换成不带 include 的 log4j 配置,只写 stderr |
| 项目目录 | -projectPath 由 REA 指定 |
自己指定 |
拦路的不是权限,是三件小事,每一件都值得记下来:
- Ghidra 拒绝任何以点开头的路径元素(
Path element starting with '.' is not permitted),所以~/.dsh/...一律不能用——这是它自己的规则,不是 REA 的; - 安装路径含非 ASCII 时 log4j 起不来:
generic.log4j.xml用相对 href include 兄弟文件,jar:file:URL 被百分号编码后 include 失败,启动即中止; -process后面不能跟项目名:它接的是可选的程序名,传项目名会让 Ghidra 去找一个不存在的程序然后中止。
坏消息是第一次分析 360 KB 的 notepad.exe 要 219 秒;好消息是项目按文件版本缓存,之后每次 10–12 秒。
两个新工具
rea_probe_address 现在有两档:引擎可用时真的读文件并命名,否则退回节表判断;输出里的 engine: 行说明是哪一档。
probe_address 0x409800 (kind=code)
rva: 0x9800 | section: .text | executable: yes
engine: ghidra-local, cached (11.2s)
name: tls_callback_0
verdict: code - 0x9800 is executable code in .text; named tls_callback_0
rea_decompile_address 是新的,也是这个项目第一次不靠别人的进程产出伪代码:
decompile_address rva 0x9800
engine: ghidra-local, cached (12.2s)
function: tls_callback_0 @ 00409800
verdict: decompiled
void __fastcall tls_callback_0(int param_1) { ... }
TLS 回调在 Ghidra 里通常只有符号没有函数体(batch_decompile 会报 "no procedure exists"),所以工具在需要时先反汇编、建函数,再反编译——这正是分析员手动做的那一步。
规划层:优先用自己的引擎
providers 里多了一个 ghidra-local。规划器优先选它,不是当兜底:
> 被包裹的 provider 的「就绪」是对一个插件看不见的沙箱的猜测,第一次调用才是验证。1.8.6 的 D6 就死在这次验证上——它按「REA 的 Ghidra 就绪」发了探针,探针当场失败。自己启动的引擎要么跑通,要么当场说明原因。
效果:D6 从 89.0% 回到 100%,D7/D8/D9 的 verify 全部是引擎档并解析出 tls_callback_0。
第十一代:导入表齐全,但是假的
十代全 100% 意味着这套阶梯不再有区分度。D10 换了个方向:把导入表填满,让它看起来什么都有。
26 个真实的 kernel32 / user32 / advapi32 导出,样本一个都不调用,紧挨着 D6 的 ROR13 真表。工具里有这么一行:if (named >= 24) return imports; ——「有 24 个具名导入就不必再翻哈希表了」。D10 就从这里走过去:api 1/9、behaviour 4/6、84.4%。
改法是承认那句话不成立:导入目录被填满是「名字被写过」的证据,不是「名字被写全了」的证据。现在无论目录多满都扫哈希表,两套来源分开计数并标注 overlappingSources:
imports: 3 DLLs / 35 APIs (26 named, 9 from hashes - the two sources name different functions)
规划里也多一步:Decide which import source the loader walks——两套都是真实导出,只有调用它们的代码能决定。
评分:编造与「读到诱饵」不是一回事
D9 的 22 条噪声里,12 条是它自己种下的诱饵,2 条是从那些诱饵 URL 派生的 host,剩下 8 条就是控制代也有的良性片段(pdb、UA)。工具一条都没编造,它只是把文件里的东西读了出来。
旧算法把这 22 条一起算进 precision,扣了 42.1%。现在防守方像 seal() 一样用 bait: 声明自己种了哪些值,评分分开记:
| 指标 | D9 |
|---|---|
| noise | 22 |
| ambiguous(防守方种的) | 12 |
| fabricated(工具自己编的) | 10(8 条基线 + 2 条派生) |
| precision | 100% |
暴露在诱饵下仍然单列显示(bait 12 (planted by the fixture)),只是不再记到工具账上。
十一代结果
| 代际 | api | behav | plan | verify | prec | score |
|---|---|---|---|---|---|---|
| D0–D4 | 9/9 | 6/6 | – | – | 100% | 100% |
| D5 | 9/9 | 6/6 | yes | – | 100% | 100% |
| D6–D9 | 9/9 | 6/6 | yes | 引擎档 | 100% | 100% |
| D10 | 9/9 | 6/6 | yes | 引擎档 | 100% | 100% |
均值 100%,honesty failures 0。
这套阶梯到此为止
十一代全满分,说明这批代际已经测不出更多东西了——继续加同类的变形只会继续满分。表里那个「weakest D1」是并列时的排序产物,不是信号。
真正还值得测的是另外几条轴:真实加壳样本(UPX / Themida)、真实语言的编译器产物(Go / Rust 的运行时结构)、以及动态路径(Frida 那一路至今没有任何一代覆盖)。这些不是「再来一代 D11」能解决的,得先有对应的样本与判据。
遗留
- Android 侧:
jadx0.7.1 /apktool3.0.3 /wabt1.0.42 都是可下载安装的托管引擎(pinned + sha256),但本机一个都没装,也没有 APK 样本——所以不是「不支持」,是「接口在、执行仍走 REA、从未被验证」。 ghidra(被包裹的那个)在这台机器上依然跑不起来;现在它只是不重要了,不是修好了。
1.8.6 那个缺口堵上了,后面还有一道
1.8.5 结尾说引擎还剩一个缺口:ghidra.log 写不进去。这一轮先把它堵上了——然后撞上了第五道门。
日志缺口:堵上了
Ghidra 的日志 appender 写在 generic.log4j.xml 里,而 log4j.configurationFile 系统属性优先于它。换一份不写文件的配置(只留 stderr),日志路径是否可写就不再是启动前提。验证后错误确实消失了。
第五道门:项目目录
下一行错误是:
java.io.IOException: 拒绝访问。
at java.io.File.createNewFile(File.java:1045)
at ghidra.framework.data.DefaultProjectData.init(DefaultProjectData.java:299)
at HeadlessAnalyzer.processLocal(HeadlessAnalyzer.java:439)
Ghidra 要在 -projectPath 里建 .gpr / .rep,而那个路径同样在 REA 的私有运行时里。这一层没有系统属性可以覆盖:参数是 REA 传的,路径是 REA 建的,DACL 是 REA 装的。到这儿为止,前四道门(temp / cache / config / log)都能用 launch.properties 挪开,第五道不能。
所以换方向——不再试着把引擎撬起来,而是让 verify 不需要它。
verify 的两档
新增 lib/probe.js 与自有工具 rea_probe_address:读文件、查节表,回答两个问题。
| 断言类型 | 问的问题 | 判据 |
|---|---|---|
code |
这个地址是可执行代码吗 | 落在 IMAGE_SCN_MEM_EXECUTE 的节里 |
pointer |
这个 dword 指向可执行代码吗 | 指针值落在可执行节 |
规划器在引擎不可用时不再删掉探针,而是换执行者:
confirm with: inspect_address_context at RVA 0x9800 ... [ghidra: unavailable - ...]
probe: probe_address @ 0x409800 (code) [local structural check]
报告里同时保留 blocked 原因,所以「谁在检查」和「为什么不是引擎」都写在明面上。
结果
| 代际 | verify | 得分 |
|---|---|---|
| D6 | NO(声称引擎、引擎当场死) | 89.0% |
| D7 / D8 | local(结构自证) |
100% |
| D9 | local |
92.8% |
十代均值 98.0%,weakest D6,precision floor 42.1%,honesty failures 0。
这不是免费分
本地探针会失败。测试里把 .rdata 里的派发表地址当成 code 去探,得到 verdict: not-executable,verify 记 0;把 .rdata 里的普通 dword 当成 pointer,得到 not-a-code-pointer。只有地址真的对得上断言类型才得分。
引擎档仍然更严:tier: engine 的探针必须解析出符号名,返回 available 但没名字一律 0。
诊断:五个位置
classifyEngineFailure 现在按位置分类:tmp / cache / config / log / project。前四个是「装错了」,第五个是「这台机器上跑不了」,措辞不同。
1.8.5 那句「盐不可达」是错的
1.8.4 把 D8 记成「盐被密封,9 个 API 名没有任何静态方法能恢复」,并把它当作诚实的能力边界写进了文档。这一轮把它推翻了:盐一直在文件里,只是工具读到了钥匙却没有用。
三处断点,一条链
D8 的配置放在 cfg=deflate+xor;key=2b8d41f0; 后面。手工打开只有三步:读 header 里的 key、解 DEFLATE、用 key 异或。
工具前两步都能做,唯独第三步没做:容器解压出来的字节是高熵,而 XOR 阶段只在第 1 层跑、且只试单字节 key。于是 salt=0x5d3a91c7 看不见,哈希表的 9 个名字解不出,报告写「算法未知」。
改动
| 位置 | 改了什么 |
|---|---|
lib/strings.js |
容器阶段读取 cfg=…xor…;key=;,用声明的 key 解 inflate 结果后再挖一层。限制 hex 2–8 字节、只在前 4 KiB 找,避免把无关文本里的 key= 当成指令 |
lib/api-hash.js |
buildHashIndex / recoverHashedApis 支持 salt:每个名字多一条 hash ^ salt 索引,算法标 +salt;resolveImports 透传 |
lib/structure.js |
structureProfile(bytes, { salt }),输出 hashedApis.salt |
lib/triage.js |
先解字符串、提取 salt=0x…,再解析导入与结构;隐藏字符串列表优先保留 cfg= / salt= 行,不让截断把它丢掉 |
lib/plan.js / lib/analysis-tools.js |
报告写 mul31-xor-2f with salt 0x5d3a91c7,而不是把 +salt 当成算法名 |
结果
D8:api 1/9 → 9/9,behaviour 4/6 → 6/6,82.5% → 100%。
十代均值 97.9%(上一轮 95.9%),weakest 变成 D6 的 89.0%(唯一一次 verify 假声明),precision floor 42.1%,honesty failures 0。历史代际一分未变——D0–D5、D7 仍是 100%。
引擎:四个缺口,三个能绕,一个不能
顺手把 verify 为什么长期 n/a 查到了底。Ghidra 的 JVM 在 REA 的私有运行目录里要四个位置:tmp、cache、config、ghidra.log。
- 前三个可以用 Ghidra 自己的
application.tempdir/application.cachedir/application.settingsdir覆盖——逐个验证过,错误会依次推进(temp → cache → config); - 第四个不行:REA 用
-log /ghidra.log传给 analyzeHeadless,Ghidra 据此setApplicationLogFile,覆盖任何-DlogFilename。
REA 在启动 JVM 前还会清空 JAVA_TOOL_OPTIONS / _JAVA_OPTIONS / JDK_JAVA_OPTIONS,所以不改它的打包产物就进不去。classifyEngineFailure 现在会说出具体是哪个目录(tmp / cache / config / log),而不是笼统的「上游 Windows 限制」。验证完已把引擎配置原样恢复。
边界
- 这条链只在样本自己声明了 key 或 salt 时生效。没有声明的盐仍然不可达——那是真的不可达,不是没试。
verify仍是n/a(原因见上),健康账本继续按实际情况降级。
1.8.4 只罚漏报的评分,看不见「满嘴胡话」
1.8.3 收尾时留了一句:下一步做 D9 之前,得先给评分加精确率,因为九维评分只罚漏报,不罚乱报。这一轮补上了,D9 也真的把那个缺陷照了出来。
第十个维度:precision
评分逻辑从 CLI 脚本抽成 scripts/duel/score.mjs——纯函数,11 个单测,不再靠跑 90 秒的对抗去推断规则。
extra = max(0, noise - D0基线 - 3)
precision = 1 / (1 + extra / 8)
- 基线来自 D0:控制代是明文、无保护,它产生的 8 条噪声(域名、pdb 片段、UA)就是"良性闲聊"的天花板,不是手写的期望表。
- 容差 3:控制代本身会波动,解码通道也会合理多出一两条(URL 顺带出 host、email 顺带出域名)。
- 权重 0.12,其余九维 ×0.88:零额外噪声的代际分数一分不变,新维度不能悄悄改写历史序列。
继续对抗:D9 诱饵代
D8 把信息藏起来;D9 反过来——所有东西都可恢复,但假货比真货多:
- 12 条明文诱饵 IoC(3 URL / 3 email / 3 IP / 3 域名),格式全部合法,一条都不在真值集里;
- 第二张哈希表,用 CRC32 算的 10 个「字典里存在、样本从不调用」的 API;
- 其余保持 D6 的可达结构(ROR13 真表、TLS 回调、24 项派发表)。
结果:召回全满(endpoint 4/4、api 9/9、behaviour 6/6),precision 42.1%,总分 91.9%。同样一份报告,旧的九维评分会给它 100%。
D9 照出的三处真实缺陷
| 缺陷 | 现象 | 改法 |
|---|---|---|
| 明文诱饵被标成 "confirmed" | url (3 confirmed, 4 recovered):3 条 confirmed 全是诱饵,真正从配置解码出来的 4 条反而挂着 "verify manually"。风险判断正好颠倒 |
改为 observed in plaintext / decoded;每条明文加 — unverified;报告明说「明文可见不是确认,静态提取分不清活跃 C2、投放诱饵和死字符串」 |
| 高熵区的乱码被当成 email | J@nC.NDH、ID@qo.HIplXO、bido@ihl.xml 每一代都被报成 email IoC |
email 规则加 TLD 白名单,域名部分必须小写。真实地址一个没删(analytics@orbit-app.example 仍在),每代噪声 9 → 8 |
| 混合算法被说成一个算法 | 19 names matched, and ror13 fits those——19 个名字实际横跨 ror13 与 crc32 两张表 |
多算法时改成「按算法分组,至少一组是诱饵」;structure: 行补 mixed algorithms: likely separate or decoy tables, names unverified |
结果
| 代际 | endpoint | indicator | api | behav | plan | verify | prec | score |
|---|---|---|---|---|---|---|---|---|
| D0–D4 | 5/5 | 6/6 | 9/9 | 6/6 | – | – | 100% | 100% |
| D5 | 4/4 | 5/5 | 9/9 | 6/6 | yes | – | 100% | 100% |
| D6 | 4/4 | 5/5 | 9/9 | 6/6 | yes | NO | 100% | 89.0% |
| D7 | 4/4 | 5/5 | 9/9 | 6/6 | yes | – | 100% | 100% |
| D8 | 4/4 | 5/5 | 1/9 | 4/6 | yes | – | 100% | 82.5% |
| D9 | 4/4 | 5/5 | 9/9 | 6/6 | yes | – | 42.1% | 91.9% |
十代均值 95.9%,weakest D8 82.5%,precision floor 42.1%,honesty failures 0。
为什么 D9 不是最弱,以及为什么这不矛盾
D9(91.9%)高于 D8(82.5%)。D8 缺的是 api+behaviour(合计 0.28 权重)——认不出 API 是根本性的;D9 缺的是判断力(0.12 权重)——把诱饵当真很危险,但它确实看见了那些字节。
加权总分把「能力」和「判断力」混成一个数,这是加权评分的固有局限。所以 precision floor 单列报告,不靠总分传达:一个「满嘴胡话但全都提到」的工具,不该因为总分更高而看起来更好。
遗留边界
- D8 的盐仍不可达:确认过工具输出里从未出现
salt=0x...,9 个 API 名没有任何静态方法能恢复。api 1/9是能力缺口,不是评分缺陷。 - API 诱饵没进加权分数:D0 的导入表定义了 API 名,哈希代际没有同源基线,强行合并会引入不可比的基线。改用「报告诚实度 + 10 个回归测试」覆盖,precision 只衡量 IoC 噪声。
- verify 仍是
n/a:确认 REA 在启动 JVM 前会显式清空JAVA_TOOL_OPTIONS/_JAVA_OPTIONS/JDK_JAVA_OPTIONS并拒绝-Dos.*注入,所以不改第三方打包产物就无法塞入-Dapplication.tempdir。健康账本继续按实际情况降级,而不是伪造一次成功。
1.8.3 引擎健康账本、评分自证,以及面板上那五个没有开关的设置
这一轮由三个问题推动:面板里的安全策略只有状态没有开关、引擎「就绪」但一跑就死、对抗评分把防守方自己封死的东西算成分析员的漏检。
面板:五个设置从「只能看」变成「能改」
之前的五行安全策略读的是宿主配置行,插件没有写它的权限,于是面板只能显示「设备写操作 关闭」——开关存在,但没接到任何东西上。
现在插件自己拥有一份 ~/.dsh/rea-engines/policy.json:
- 只接受五个字段,未知字段直接报错(打错一个字母却「保存成功」,比报错更危险);
- 逐字段校验:路径必须是绝对路径、保留天数 1–365、外联策略只能是
allow|warn|deny; - 原地生效:
guardTool闭包读的是同一个 config 对象,所以保存后下一次工具调用就开始执行新策略,不需要重启子进程(重启会杀掉正在跑的反编译); - 写入走临时文件 + rename,避免半截 JSON 导致策略静默回落到「不限制」;
- 优先级:面板 > 配置行,因为面板是人刚做过的、看得见的动作。
顺带修掉三个界面缺陷:
| 现象 | 原因 | 改法 |
|---|---|---|
| 打开「设备写操作」后绿灯灭了 | 用琥珀色表示「已放宽」,而琥珀色变量在该主题下没定义,圆点直接消失 | 统一语义:绿=开/已配置,灰=关;标签列固定 145px,开关与其它控件左对齐 |
| 授权目标范围是个输入框 | 最初只能手打绝对路径 | 换成文件夹选择器(/rea/fs/list,只返回目录名,不读文件内容),失败时可退回手填 |
| 外联策略是原生下拉框 | `` 用系统控件渲染,在深色面板里像别的程序的表单 | 换成三段式分段控件,与其它控件同一套样式 |
「就绪」是一个断言,得有证据
面板说 Ghidra 就绪,实际每次分析都死在:
java.io.IOException: Failed to create directory: ...\rea-ghidra-\tmp\-ghidra
用 REA 自带 CLI(rea decompile)复现同样的失败——不是本插件的集成问题:REA 在 Windows 上用私有 DACL 建运行目录,JVM 无法在其中创建 Ghidra 需要的 -ghidra 临时目录。
修补上游需要改三份被打包的产物(dist/ghidra、dist/cli-runtime、dist/mcp),升级即失效,所以没有这么做。改的是断言本身:
- 新增
lib/engine-health.js:工具层从错误信封里取出provider_id,把失败压成一行原因(jvm-scratch-denied/jdk/timeout/native-unsupported)并记账; providerReadiness()合并账本:本次会话里失败过的 provider 一律不再声称就绪,原因写明「上游 Windows 限制;不是 JDK 版本或安装问题」;- 修掉一个让探针活过引擎死亡的漏洞:
inspect_address_context不在PROVIDER_OF_CONFIRM里,TLS/派发表步骤从来没被打上 provider 标签,于是引擎挂了探针照发; - 规划器在 provider 不就绪时删掉
probe:行并写入blocked,报告不再给出一个跑不了的实验。
评分自证:被封存的值不该算漏检
D5–D8 把 https://api.orbit-app.example/device/register 和唯一的水印封进「运行时派生密钥」的容器里,然后 honesty 维度为「有没有承认这块打不开」计分。可同一批值又被 recall 扣了一次——一处不可能,罚两遍,任何分析员都拿不到那 4–8%。
现在防守方用 seal() 声明自己封了哪些字面量,评分按「这一代真正可达的天花板」计算:
seal()同时进tracked和sealed,assertCoverage校验「声明封存的必须真的被编码过」,防守方不能虚报;- 该代际的可达集合 = D0 天花板 − 封存值;水印全部被封时该维度记
null(权重重新分配),而不是白送 0; - 控制代 D0 不变,天花板仍由攻击方自己的 D0 结果定义。
本轮结果
| 代际 | endpoint | indicator | anti | wm | api | behav | plan | verify | score |
|---|---|---|---|---|---|---|---|---|---|
| D0–D4 | 5/5 | 6/6 | 8/8 | 1/1 | 9/9 | 6/6 | - | - | 100% |
| D5 | 4/4 | 5/5 | 8/8 | - | 9/9 | 6/6 | yes | - | 100% |
| D6 | 4/4 | 5/5 | 8/8 | - | 9/9 | 6/6 | yes | NO | 87.5% |
| D7 | 4/4 | 5/5 | 8/8 | - | 9/9 | 6/6 | yes | - | 100% |
| D8 | 4/4 | 5/5 | 8/8 | - | 1/9 | 4/6 | yes | - | 79.6% |
九代均值 95.9%,honesty failures 0。D6 的 verify NO 是正确的一次扣分:它是在账本还空的时候做出的「就绪」断言,引擎当场死亡;D7/D8 在同一会话里已经不再声称就绪,探针被撤下,维度转为不可测(-)而不是记 0。
D8 仍是 79.6%:盐被密封,9 个 API 名字没有任何静态方法能恢复,报告只能说「哈希表存在、算法未知」,api/behavior 的真实缺口就摆在那里。
遗留边界
- JVM 临时目录失败是上游缺陷,本插件不会伪造一次成功;报告写的是「这条路现在跑不了,原因是 X」。
- 面板改策略不重启子进程,因此已经建立的分析会话沿用旧配置,直到下一次重启——这是刻意的取舍。
- 下一步对抗目标是 D9(带诱饵的哈希表),前提是先给评分加上精确率:现在只罚漏报,不罚乱报。
1.8.2 装了引擎之后:从「会说」到「跑得通」
1.8.1 把「确认路径不可用」写进了报告,但没有回答另一半问题:装上引擎以后,那些确认步骤真的跑得通吗。这一版把 Ghidra 12.1.4 + JDK 21 装进工作区 .tools/(不进 C 盘、不进 node_modules),让攻击方第一次真的执行自己写的实验。
新增 verify 维度(权重 0.12,由引擎判定)
报告现在为每条带地址的实验输出一行机器可读的探针:
confirm with: inspect_address_context at RVA 0x9800 - Ghidra resolves the callback's name from the TLS directory ...
probe: inspect_address_context @ 0x409800
攻击方读这一行、真的调用 REA,比对返回里的符号名。评分不看措辞,看解析结果:
| 代际 | plan | verify | 结果 |
|---|---|---|---|
| D0–D5 | -/yes | - | 无结构探针,权重重分配 |
| D6 / D7 | yes | yes | tls_callback_0,8–20 s/次 |
| D8 | yes | yes | tls_callback_0,盐仍不可解 |
九维权重相应调整为 0.13/0.13/0.10/0.10/0.04/0.14/0.14/0.10/0.12,八代均值 94.5%(D0–D4 100%、D5 92.5%、D6/D7 93.4%、D8 76.3%),honesty failures 0。
装引擎过程中暴露的四个真实缺陷
| 缺陷 | 表现 | 修法 |
|---|---|---|
| JDK 版本误报(你在面板上看到的那个) | engines: jdk ok (17.0.8) \| ghidra missing,可 JDK 17 对 Ghidra 12 毫无用处:启动器直接退出 JDK 21+ (64-bit) could not be found |
新增 javaRequirement():JDK 就绪 = 存在 + 是完整 JDK + 主版本 ≥ Ghidra 要求。面板显示「JDK 17.0.8 版本过低,Ghidra 需要 21+」 |
| 保存的引擎路径被环境变量盖掉 | 侧栏里配了 JDK 21,子进程实际仍拿到 JAVA_HOME=...jdk-17(PASSTHROUGH_ENV_NAMES 在 ENGINE_ENV_OVERRIDES 之后写入) |
调换优先级:保存的路径最后写,显式配置永远赢过继承值 |
TLS 回调用 batch_decompile 必然失败 |
回调由数据指针引用,Ghidra 不建函数,调用返回 invalid_request: No procedure exists at 0x409800 |
改为 inspect_address_context(Ghidra 能解析出 tls_callback_0),并在文案里说明「要伪代码得先建函数」 |
| 报告不给镜像基址 | 只给 RVA,任何引擎调用都要 VA,读者得自己猜基址 | structure: 行输出 image base 0x400000 \| entry RVA 0x9100,探针行直接给 VA |
本地引擎的安装位置与两个环境约束
- 引擎装在
/.tools/(已加入.gitignore),GHIDRA_INSTALL_DIR与JAVA_HOME通过rea-engines配置指向它;C 盘只有一份 100 字节的路径配置。 - Ghidra 12.1.4 要求 JDK 21+(11.2 起就提了),JDK 17 只能配 Ghidra 11.1 及更早。
- 若宿主把子进程限制在工作区内(本机就是),必须把
TMP/TEMP/APPDATA也指到工作区,否则 REA 的私有运行目录会让 JVM 写java_home.save时被 ACL 拒绝,而 Ghidra 会把它误报成「找不到 JDK」。
1.8.1 让「确认路径」先证明自己存在
1.8.0 的实验列表写着 confirm with: batch_decompile / load_frida_script / execute_rizin_command,看上去像指令,实际是许愿:这台机器上六个确认路径一个都跑不了。rea_status 早就知道这件事,只是 plan 生成时没去问它——这是 1.8.0 的方法论缺陷,不是环境问题。
本机实测(rea_status,2026-11-05):
engines: jdk ok (17.0.8) | ghidra missing | jadx missing | apktool missing | wabt missing | rizin missing
providers: native-macos, rea-artifact-graph, rea-dotnet-static
blocked: android decompilation (needs JADX); native function analysis (needs Ghidra + full JDK)
也就是说:open_binary 能用(535 ms 读出 pe/x86/sha256),REA 自己的 PE/ELF/.NET 解析与 artifact graph 能用,原生反编译一步都走不了。拿它去调 inspect_with_objdump 或 execute_rizin_command,返回的是:
capability_unavailable / unsupported_provider
"reason": "Unable to run configured command (ERR_REA_WINDOWS_NATIVE)"
1.8.1 的改动只有一件事:把这条缝搬到报告里。
| 位置 | 现在长什么样 |
|---|---|
| triage 报告新增一行 | engines: 0/6 confirmation paths ready \| unavailable: objdump (not on PATH), rizin (not on PATH), gdb (…), frida (…), ghidra (Ghidra not installed), jadx (JADX jar not configured) |
| 每条实验末尾 | confirm with: load_frida_script … [frida: unavailable - frida not on PATH] |
| 没有探测时的诚实默认 | report.providers === null,报告写「not probed」,不是「全部可用」 |
实现:lib/engines.js 加 providerReadiness()(where/which 只定位、不启动引擎,Ghidra 走既有检测),lib/plan.js 把每条实验的 confirm 首词映射到 provider 并附 ready,rea_triage / rea_batch_triage 每次调用重新探测——中途装了 Ghidra 就会立刻显示 ready。
关于「能不能完整逆向并反编译」,直说:
- 装了 Ghidra 的原生反编译产出是 C 伪代码,不是源码。类型、结构体、宏、内联、优化后的变量复用都回不来;被混淆/虚拟化的代码连伪代码都不成立。
- 本机现在连 Ghidra 都没有,所以更弱:深度止步于本地静态层(字符串/结构/行为/假设),一条指令都没被反汇编。
- 安装通道可达(Ghidra 资产返回 302 而不是 502),但需要 JDK 21(本机是 17.0.8)与约 500 MB 下载。这一步会显著改变机器状态,需你确认后再做。
1.8.0 从勘查员到独立分析师:D6–D8
1.7.0 把「逆向能力」变成了数字,但五个维度全是字符串召回——那测的是勘查员。独立分析师还要回答三件事:这东西由什么构成、它是干什么的、下一步做什么实验能定论。1.8.0 把这三件事做成维度,并让 Defender 造出对应的靶标。
| 维度 | 问的问题 | 权重 |
|---|---|---|
api |
导入表被抽掉后,还能不能叫出 API 名字 | 0.15 |
behavior |
能不能说出它在干什么(6 类行为) | 0.15 |
plan |
能不能给出点名 hook 与动作的下一步实验 | 0.13 |
字符串维度相应让出权重(0.25/0.25/0.2/0.2/0.1 → 0.15/0.15/0.10/0.12/0.05),因为「说不清结构」不该再被字符串分数掩盖。
| 代际 | Defender 加了什么 | 首测 | 现在 |
|---|---|---|---|
| D6 | 抽掉导入目录 + ROR13 哈希表 + TLS 回调 + 24 项调度表 | 92% | 92% |
| D7 | 哈希换成私有乘法族(imul 0x1f / xor / seed 0x2f) |
72% | 92% |
| D8 | 哈希加盐,盐封在不可解区 | 72% | 72% |
| 八代均值 | 93.7% |
D6 不算盲测:结构模块先写、靶标后写,写法受能力影响。D7 才是干净对照——先测出 api 0/7、behavior 4/6,再把五个固定算法扩成「乘数 × 运算 × 种子」的参数族,复测 7/7。D8 保留为新边界:盐不在文件里,任何算法族都对不上,报告只能说「算法未知,盐可能被封存」。
新增能力:
| 能力 | 做法 |
|---|---|
| 结构重建 | lib/structure.js:IAT 存在性、TLS 回调(运行在入口点之前)、调度表 code pointer 连片(控制流平坦化)、节区异常(W+X、raw=0、重叠) |
| 行为归因 | lib/plan.js:6 类行为带证据与置信度;哈希得来的名字标 (hash),最高只给 low |
| 实验设计 | 按「单位成本买到多少信息」排序,每条点名 API/RVA、预期结果,以及用哪个 REA 工具确认 |
| 哈希算法族 | 五个固定算法 → 「乘数 31/33/131/1313/65599 × 加或异或 × 四个种子」共 80 个变体 |
本地层只出假设,REA 出确认。引擎(objdump / Rizin / Ghidra / JADX / Frida)本来就是 REA 的,插件不重造;但 triage 必须在没有引擎、没有打开目标、没装 Java 的机器上先回答「值不值得送进反编译器」。所以每条实验都带 confirm 字段:封存区用 load_frida_script,TLS 回调用 batch_decompile,调度表用 execute_rizin_command;报告里也写明「这些是静态假设,不是发现」。
真实样本打回了三个假阳性。 八代靶标全过之后,用 1.8.0 扫了三份真实二进制(7zr.exe / PuTTY / sqlite3.dll,npm run fetch-corpus && npm run corpus-scan),结构层当场出错——而且错在「说得很像真的」:
| 问题 | 真实表现 | 修法 |
|---|---|---|
| 把编译器的 switch 跳转表当成控制流平坦化 | 7zr.exe 报出 33 张「调度表」,全是 switch 表;plan 的第一条建议直接指向其中一张(「记录 RVA 0x7c2e0 处间接跳转的索引」) |
改为先数数量:代码指针表 > 3 张时只登记、不指控,并写明「这个数量本身就是编译器 switch 表的特征」;≤ 3 张才作为 confidence: low 的候选,why 里明说 switch 表形状相同、只有解码循环能区分 |
| 把未初始化数据当成结构异常 | .data 虚拟大小是原始大小的 31 倍 → 报异常。这是每个 C 程序都有的 BSS |
只对可执行节膨胀报异常(自解压 stub 才会这样),数据节膨胀是常态 |
| 报告自相矛盾 | behavior.summary 写「无法从静态证据归因」,families 却列了三个 ATT&CK 编号 |
families 改为从 findings 推导;ATT&CK 能力提示仍留在它自己的字段里,不再混进行为结论 |
这三条合成靶标测不出来:Defender 造的 PE 里只有一张表,因为「一张表」正是它想让你发现的东西;真实软件里表是按编译器习惯成批出现的。修完之后 7zr.exe 的结论是「28 张代码指针表,数量本身说明是 switch 表,不指控平坦化」,PuTTY 的 4 条行为全部标 import-table/low|medium 并附证据名。
方法与全部数字见 docs/duel.md,可用 npm run duel 复现。
1.7.1 使用声明(仅文档变更)
本版不包含任何功能改动,只在 README 顶部加入「免责声明」一节,并同步更新版本号与 update.json:
- 明确本项目仅供学习与研究使用;
- 明确使用者若用于其他用途,须自行承担相关责任,作者不承担任何责任;
- 说明使用者有义务自行确保行为符合法律法规与所分析软件的许可协议;
- 说明本项目按「现状」提供,不附带担保。
发布包内的 README.md 与仓库一致,因此安装 1.7.1 即已包含上述声明。需要核对时可直接查看发布包根目录的 README.md,首个小节即该声明。
1.6.0 加固阶梯:把「逆向能力」变成可测的数字
1.5.0 的解码通道(单字节 XOR / 双相位 UTF-16 / base64)在同一个靶标稍微加固之后几乎全灭。与其再写一轮评审,不如把对抗测试做成可复现的阶梯:6 个层级包含完全相同的 ground truth,只换保护方式,逐级测召回。
| 层级 | 保护方式 | 1.5.0 | 1.6.0 |
|---|---|---|---|
| L0 | 全明文 | 100% | 100% |
| L1 | 奇偏移 UTF-16LE + 单字节 XOR | 100% | 100% |
| L2 | 4 字节重复密钥 XOR + 长度前缀 + 嵌套 base64 | 8.2% | 100% |
| L3 | RC4 配置块(密钥写在旁边)+ 栈字符串 + 反序 | 8.2% | 100% |
| L4 | L3 + 导入表只剩序号 + 32 位 API 哈希表 | 0% | 100% |
| L5 | L4 + 每份样本唯一的归属水印 | 5% | 100% |
| 均值 | 36.9% | 100% |
新增能力(lib/crypto-recovery.js、lib/api-hash.js):
| 能力 | 做法 |
|---|---|
| 多字节 XOR(2–4 字节密钥) | crib dragging:用 https://、HKLM\、Mozilla/ 等已知前缀反推密钥。8 字节 crib 对 L≤4 的每个密钥位给出两次以上约束,随机命中率约 2⁻³²,不靠「密钥像不像」做判断 |
| RC4 / 密钥 XOR 配置块 | 512 字节块熵区定位 → 从附近可打印串取候选密钥(cfg=rc4;key=X 会切成整行、key=X、X 三个候选)→ 逐字节对齐搜索 → 语义锚点复核 → 64 字节滑动窗口判定明文边界 |
| 栈字符串 | 连续 3 段以上短立即数,取最长可读拼接(否则只会得到 https://telemetry.) |
| 反序字面量 | 正读不通、反读读通才报 |
| 噪声块打捞 | 相邻 XOR 记录的终止符本身会解码成可打印字节,整段粘连后被整段评分否决;改为从长噪声串里按 URL/域名/注册表/路径/邮箱/IPv4 形态打捞 |
| API 哈希表 | ROR13 / CRC32 / djb2 / FNV-1a / sdbm × 约 200 个 API 名建索引,按 4 字节对齐查询;只在具名导入 < 24 时扫描。命中标 evidenceSource: api-hash + confidence: low:名字在表里 ≠ 这个 API 被调用 |
| 归属水印检测 | 反追踪的真正盲点不是「收集我的 IP」,而是「每份拷贝不一样」:canary=、UUID、16–48 位十六进制、随机子域。不联网也能指认是哪个分析者的拷贝流出。检出后给出「记录为指标 + 分享前脱敏」的处置,并明确声明「找不到不等于没有」 |