rea-reverse

JS 逆向实战:用 REA 还原 webpack 打包后的源码(含 JS 逆向 MCP 用法)

基于 REA 6.4.0更新于 约 10 分钟读完
本页目录

线上的 JavaScript bundle 往往是一整行、变量全被改名的代码。想回答“哪个模块发了这个请求”“2.3 版到底改了什么”,得先把模块、import 关系和调用关系找回来。REA(morluto 维护的开源项目)只读本地文件,从不执行它们:有 source map 就读 source map;没有的话,从语法里恢复 webpack 的 chunk 和模块工厂函数;再配上你自己提供的 Wakaru 可执行文件,还能把 bundle 拆成一个个派生模块。每一步结果都是 Evidence,你和 agent 都能逐条核对。

适合谁读: 排查自家线上构建产物的前端开发者、想确认“到底发布了什么”的维护者,以及想看清打包器如何改写代码的学习者。需要会用 npm 和终端。

你会学到:

  1. 构建一个能和源码逐条对照的小 bundle
  2. 通过 REA 的应用图读取 source map
  3. 没有 source map 时恢复 chunk 和模块边界
  4. 用 recover-javascript-sources 调用 Wakaru 解包(仅 Linux x64)
  5. 用 trace-javascript-semantics 追踪数据流
  6. 用 compare-source-to-bundle 给 bundle 和源码树打分对照
  7. 在 Claude Code 等 AI agent 里通过 MCP 跑完整个 JS 逆向流程

范围说明。 本文只处理已经在你磁盘上的文件。从线上页面抓取脚本、把单个压缩位置映射回源码、把网络请求和发起它的代码对上,请看 用 REA 追踪网站 API 调用。Electron 的 ASAR、preload 和 IPC 请看 Electron 应用分析。只分析你自己的代码、开源代码或已获授权的代码,参见 REA 的 DISCLAIMER.md。

什么是“JS 逆向 MCP”,REA 在其中的位置

搜“js逆向 mcp”的人通常想要的是:让 Claude Code、Cursor、Codex 这类 agent 直接调用工具去读、拆、追踪 JS 代码,而不是把几 MB 的压缩代码粘进对话框。MCP(Model Context Protocol)就是 agent 调用外部工具的协议。

常见的搭配有两类:

  • 驱动浏览器的 MCP(比如 Chrome DevTools 方向的方案):适合看运行时,页面实际加载了什么、发了什么请求。
  • REA:除了浏览器捕获(见网站 API 那篇),它的强项是对本地 JS 文件做离线静态分析,产出可复核的 Evidence。本文讲的就是这一部分。

两者可以同时接入同一个 agent,并不冲突。第 8 步给出了具体的 MCP 工具名和提示词。

先选路线

你手里有什么 REA 能给你什么 对应章节
bundle + .map 文件 原始源文件名和摘要,并和生成文件关联起来 第 2 步
webpack / Rspack bundle,没有 map chunk 注册、模块工厂、import、接口地址 第 3 步
任意单个 bundle,没有 map,Linux x64 主机 Wakaru 拆出的派生模块,再进入同一张图 第 4 步
某个版本的原始源码树 文件到 bundle 的打分映射 第 6 步

这两种工具都还原不了原始变量名。Wakaru 会把语法改写得更易读,但 REA 把结果标记为 derived(派生)Evidence:原始命名和运行时等价性仍然是未知的。

前置条件

  1. Node.js 和 npm,以及 REA 6.4.0 或更新版本。用 npm install --global rea-agents 安装 CLI,或者在每条命令前加 npx -y rea-agents@latest。要在 Claude Code / Cursor / Codex 里用,运行 npx rea-agents setup 后重启 agent(见 安装与接入)。
  2. 不需要原生分析引擎。 JS 静态分析用不到 Ghidra、Hopper 或 IDA。
  3. 仅第 4 步需要: Linux x64 主机(虚拟机或容器都行)、Wakaru ^1.13.0(REA 用 1.14.0 验证过),以及 util-linux 的 prlimit。在其他平台上 REA 会直接拒绝启动恢复。
  4. 仅第 6 步需要: Linux 或 macOS。在原生 Windows 上导入历史源码会返回 unsupported_host。
  5. 用来切分大 JSON 结果的 jq。

第 1 步:构建一个能对照的 bundle

先拿自己有源码的 bundle 练手,REA 的每个结论都能对着源码检查。自己写一个小项目就够了:

mkdir bundle-lab && cd bundle-lab
npm init -y
npm install --save-dev webpack webpack-cli
mkdir src

src/api.js:

export async function loadTodos() {
  const response = await fetch("/api/v1/todos");
  if (!response.ok) throw new Error("HTTP " + response.status);
  return (await response.json()).items;
}

src/export.js:

export function toCsv(todos) {
  return todos.map((t) => [t.id, JSON.stringify(t.title)].join(",")).join("\n");
}

src/index.js:

import { loadTodos } from "./api.js";

document.querySelector("#export").addEventListener("click", async () => {
  const todos = await loadTodos();
  const { toCsv } = await import("./export.js");
  console.log(toCsv(todos));
});

webpack.config.js:

module.exports = {
  entry: "./src/index.js",
  output: { filename: "[name].js", chunkFilename: "[name].chunk.js", clean: true },
  // Production mode normally concatenates ES modules into one scope.
  // Turning it off keeps per-module factories so you can see them.
  optimization: { concatenateModules: false },
};

构建两份,一份带 source map,一份不带:

npx webpack --mode production --devtool source-map --output-path "$PWD/dist-maps"
npx webpack --mode production --no-devtool --output-path "$PWD/dist-plain"

为什么要构建两份: 同一份代码,有 map 和没 map 的对比,最能看清 REA 每一层各补了什么。动态 import() 会让 webpack 为 export.js 单独生成一个 chunk,正好可以观察 chunk 拆分。真实的生产构建通常开着 concatenateModules(模块合并),模块边界在打包时就被合掉了,能恢复出的边界会更少。

第 2 步:带 source map 读取 bundle

分析目录,并把 Evidence 存成文件:

rea analyze-javascript-application "$PWD/dist-maps" --json > maps-evidence.json

进度信息走 stderr,stdout 是干净的 JSON。不要整份读完,先投影出摘要:

jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' \
  maps-evidence.json > maps-summary.json
rea inspect-analysis-view maps-summary.json

用同样的方式,配合 view: {kind: "page", collection: "modules", offset: 0, limit: 32} 分页浏览模块。完整写法见 Electron 那篇。

有了 source map,图里会多出这些信息:

  • REA 解析每条本地 sourceMappingURL 声明及其指向的 map,生成 source map 节点和原始源文件节点,用 maps_to 边连起来。

  • 记录原始源文件名(如 webpack://bundle-lab/./src/api.js)和可选的内容摘要。图里不保存 sourcesContent 原文,需要原文时自己去读 map。

  • 想看单个条目,用 item 视图,填入在模块分页里看到的精确路径或引用:

    jq -c '{source: {kind: "inline", evidence: .},
            view: {kind: "item", collection: "modules",
                   selector: {path: "PASTE_EXACT_PATH_FROM_PAGE"}}}' \
      maps-evidence.json > one-module.json
    rea inspect-analysis-view one-module.json

map 损坏或读不了时,会被记为状态为 unavailable 的 unknown 范围,而不会被当成“源码不存在”的证据。

第 3 步:没有 map 时恢复模块边界

再分析不带 map 的那份:

rea analyze-javascript-application "$PWD/dist-plain" --json > plain-evidence.json

即使没有 map,REA 仍会把每个文件当作纯文本解析(不执行),并恢复:

  • webpack 和 Rspack 的 chunk 注册与模块工厂(只要它们以 AST 字面量形式出现)。在这个项目里,主 bundle 的模块表和独立的 export.js chunk 预期会各自成为节点。
  • 静态 import、动态 import()、CommonJS require、worker 和 service worker。
  • 接口地址、路由、存储和第三方库特征。 /api/v1/todos 这个字面量会成为一条 endpoint 观测。6.4.0 起还能识别 tRPC 过程调用和具名 GraphQL 操作。tRPC 的路由名和过程名以属性名的形式保留下来,压缩不会改掉它们,所以即使代码被压缩过也读得出来。

对比两份摘要:模块数量和 import 边应该和源码基本吻合,文件名和变量名则对不上。下一步就是处理这个问题。

关于打包器。 webpack 和 Rspack 会保留可见的模块表。Rollup、Vite、esbuild 的生产构建通常把所有模块提升(hoist)到共享作用域里,字节里已经没有模块边界了,REA 和 Wakaru 都没法可靠地把它找回来。

第 4 步:用 Wakaru 拆出模块(Linux x64)

recover-javascript-sources 会调用你提供的 Wakaru 可执行文件,并在校验后发布结果。REA 不负责安装 Wakaru。6.4.0 的适配器只支持 Linux x64,在其他平台上会拒绝启动。

安装 Wakaru,REA 的 恢复文档 给了两种方式:

curl -fL https://github.com/pionxzh/wakaru/releases/download/v1.14.0/wakaru-linux-x64.tar.gz | tar -xz wakaru
export REA_WAKARU_COMMAND="$PWD/wakaru"

或者:

npm install [email protected]
export REA_WAKARU_COMMAND="$(realpath node_modules/@wakaru/cli-linux-x64/wakaru)"

环境变量要指向平台二进制,不要指向 node_modules/.bin 里的 Node.js 包装脚本。REA 每次都会用 util-linux 的 prlimit(默认 /usr/bin/prlimit)限制资源;如果它在别的位置,把 REA_JAVASCRIPT_PRLIMIT_COMMAND 设成它的绝对路径。

对主 bundle 执行恢复:

rea recover-javascript-sources "$PWD/dist-plain/main.js" "$PWD/recovered-main" \
  --extraction-mode structural --rewrite-level standard --json > recovery.json
  • 输出目录必须事先不存在,两个路径都必须是绝对路径。
  • --extraction-mode 可选 structural(默认,对应 Wakaru --unpack=strict)、heuristic(--unpack=auto)、inspection(--unpack=inspect,可能产出不可执行的片段)。
  • --rewrite-level 可选 minimal、standard(默认)、aggressive。级别越高改写越多,但无论哪一级,运行时等价性都是未知的。

发布出来的目录包含:inputs/bundle.js(输入的精确快照)、modules/(派生模块,以及 Wakaru 生成的 map)、reports/stdout.json 和 reports/provenance.json(引擎自己的报告)、manifest.json。每个模块都记录了它在原始输入中的 UTF-8 字节范围,任何恢复出来的函数都能对回原始字节。

然后把恢复出来的模块再送回应用图:

rea analyze-javascript-application "$PWD/recovered-main/modules" --json > recovered-evidence.json

恢复结果里也直接带有一个 analysis_input 对象,就是为这一步准备的。

需要知道的限制: 单个 worker,1 GiB 地址空间上限,120 秒超时,输入最多 64 MiB,输出最多 128 MiB 或 10000 个条目。超出任何一项都会干净地失败并回滚输出。结构化拆分可能退化成单个文件,REA 自己的测试样本里,esbuild 作用域提升后的 bundle 就仍是一个格式未知的脚本。6.4.0 中这个命令只接受一个输入文件。仓库文档里那个把多个 code-split chunk 一起恢复的 --additional-path 选项只在 main 分支上,npm 6.4.0 里没有。

第 5 步:在 bundle 内部追踪数据流

模块边界告诉你代码在哪,trace-javascript-semantics 告诉你值怎么流动。它查询的是 analyze-javascript-application 在结构图之外另存的一张语义关系图。这张图覆盖词法绑定、属性读写、闭包、能唯一确定目标的本地调用、Promise 链、定时器、事件发射器、请求构造和响应消费等关系。

从保存的 Evidence 构造输入:

node --input-type=module -e '
import { readFileSync, writeFileSync } from "node:fs";
const application = JSON.parse(readFileSync("plain-evidence.json", "utf8"));
writeFileSync("semantic-input.json", JSON.stringify({
  application,
  query: {
    seed: { kind: "endpoint", value: "/api/v1/todos" },
    direction: "forward-influence"
  }
}));'
rea trace-javascript-semantics ./semantic-input.json --json

种子(seed)类型有 literal、endpoint、property、event、boundary-field、function(按指纹)、semantic-node、application-node。方向(direction)有 backward-provenance(这个值从哪来)、forward-influence(它影响了什么)、callers 和 ownership。这里从 endpoint 出发正向追踪,应该能从 fetch 请求走到读取 .items 的代码。

结果怎么看:status 取值为 found、no-match、ambiguous、partial、unsupported。resolved 只表示静态目标唯一,不代表这条路径真的执行过。含糊的动态边默认排除,需要时设置 include_ambiguous_dynamic_edges: true。找不到某条关系,也不能证明对应的行为不存在。

第 6 步:用源码验证 bundle

手里有源码树时(你自己的、开源项目的发布 tag、或某个旧版本),可以量化 bundle 和它的对应程度。先把源码树导入成历史参照:

rea import-reference-source "$PWD" --json > reference.json

导入会记录文件哈希和 import 关系,遵守 .gitignore,并默认跳过 node_modules/、dist/ 等常见生成目录。然后对比:

node --input-type=module -e '
import { readFileSync, writeFileSync } from "node:fs";
const reference = JSON.parse(readFileSync("reference.json", "utf8"));
const application = JSON.parse(readFileSync("maps-evidence.json", "utf8"));
writeFileSync("stb-input.json", JSON.stringify({ reference, application }));'
rea compare-source-to-bundle ./stb-input.json --json

每个源文件都会得到一个状态:unchanged、modified、removed、split、merged、duplicated 或 unknown,并附上依据的信号和权重:源文件摘要完全一致(100)、source map 原始路径(70)、当前路径完全一致(60)、路径后缀(40)、文件名(20)、语言扩展名(5)。分别拿 maps-evidence.json 和 plain-evidence.json 各跑一次,就能看出映射有多依赖 source map。判定 removed 需要两边的清单都完整,覆盖不完整时,“不存在”只会记为 unknown。

如果要比较 bundle 自身的两个构建版本(而不是源码对 bundle),用 compare-application-versions,见 Evidence、快照与版本对比。

第 7 步:处理大 bundle

6.4.0 起,每个 JS 源文件都在独立进程里分析,各有自己的资源预算:

rea analyze-javascript-application "$PWD/dist-plain" \
  --max-heap-mb 4096 --analysis-timeout-ms 600000 --json > plain-evidence.json

默认值是每个源文件 1024 MiB 的 V8 old-space 和 300000 毫秒。某个源文件超出预算时,REA 保留已经收集到的事实,把这个文件标为明确的 unknown,然后继续分析其余文件。分析超大 bundle 时,先看摘要里的 limitations,再决定信不信那些计数。

第 8 步:在 agent 里用 MCP 做 JS 逆向

执行 npx rea-agents setup 之后,上面的操作都成了 MCP 工具:analyze_javascript_application、inspect_analysis_view、recover_javascript_sources、trace_javascript_semantics、compare_source_to_bundle、compare_application_versions、trace_application_feature。可以直接这样提问:

用 REA 分析 /absolute/path/bundle-lab/dist-plain 这个 JavaScript 应用。
先根据摘要告诉我模块和 chunk 结构,再从 endpoint "/api/v1/todos"
做 forward-influence 语义追踪。每个结论都附上 Evidence ID,
并列出所有 unknown 或 partial 的部分。

如果连接的服务端支持保留引用(retained references),agent 可以传 {"kind":"retained-evidence","evidence_id":"..."},不必每次都传完整记录。恢复工具报 capability_unavailable 时,要么服务端所在主机不是 Linux x64,要么服务进程看不到 REA_WAKARU_COMMAND。这个变量要设在 MCP 服务端的环境里,只在你自己的 shell 里设置是不够的。

核对 agent 的回答时,按 REA 的老规矩来:模块边界要能在应用图里找到,数据流要来自语义追踪,和源码的对应关系要来自 compare-source-to-bundle。agent 对某个压缩函数“大概在干什么”的猜测,不算 Evidence。

常见问题与排错

recover-javascript-sources 返回 capability_unavailable。 检查三点:主机是 Linux x64;REA_WAKARU_COMMAND 是指向平台二进制的绝对路径;prlimit 在 /usr/bin/prlimit,或在 REA_JAVASCRIPT_PRLIMIT_COMMAND 指定的位置。

提示 “Unsupported Wakaru version”。 REA 接受 ^1.13.0。这个范围内的其他版本也能用,结果里会注明报告解析器只用 1.14.0 验证过。

恢复结果只有一个模块。 bundle 做过作用域提升,或者格式不被识别。可以试试 --extraction-mode heuristic;如果还是一个文件,说明字节里已经没有模块边界了。

CLI 把 --additional-path 当成未知选项拒绝。 这个选项只在仓库 main 分支上,6.4.0 里请逐个 chunk 分别恢复。

看不到 source map 节点。 构建产物里没有 sourceMappingURL 注释,或者 map 文件不在 bundle 旁边。把 .map 文件复制到被分析的目录里。

Windows 上 import-reference-source 失败。 它需要安全的不跟随符号链接的文件打开方式,只在 Linux 和 macOS 上可用,请换到这两种主机上运行。

下一步