rea-reverse

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

基于 REA 6.7.0发布者 rea-reverse更新于 约 6 分钟读完
本页目录

如果 bundle 带了 source map,原始文件通常就躺在 sourcesContent 数组里,一条命令就能逐字节写回磁盘。没有 map 也不要紧:webpack 构建会保留一张以数字 ID 为键的模块表,Wakaru 这类拆包工具能把它切回“一个模块一个文件”。唯一找不回来的是局部变量名:字符串、属性名和导出名能熬过压缩,其余的都成了 t、e、n。

下面在一个自己搭的三文件 webpack 项目上把两条路都走一遍,每个结果都能和真实源码对照。中间还会用到 REA(第三方开源分析工具),在不运行代码的前提下梳理 chunk、模块工厂函数和网络请求。请只分析自己的代码、开源代码或已获授权的代码。

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

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 模式默认会把 ES 模块合并到同一个作用域,
  // 关掉它,才能看到每个模块各自的工厂函数。
  optimization: { concatenateModules: false },
};

分别构建带 map 和不带 map 的版本:

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

webpack 5.112.2 + webpack-cli 7.3.1 构建不带 map 的版本时输出:

asset main.js 2.75 KiB [emitted] [minimized] (name: main)
asset 68.chunk.js 205 bytes [emitted] [minimized]
runtime modules 6.35 KiB 8 modules
cacheable modules 535 bytes
  ./src/index.js 230 bytes [built] [code generated]
  ./src/api.js 191 bytes [built] [code generated]
  ./src/export.js 114 bytes [built] [code generated]

动态 import() 让 export.js 单独成了一个 chunk:68.chunk.js。dist-plain/main.js 的开头长这样:

(()=>{"use strict";var t={672(t,e,o){async function n(){const t=await fetch("/api/v1/todos");if(!t.ok)throw new Error("HTTP "+t.status);return(await t.json()).items}o.d(e,{y:()=>n})}};const e={};function o(n){ /* …webpack runtime… */

var t={672(...){...}} 就是模块表:api.js 成了工厂函数 672,它导出的 loadTodos 被改名成了 y。chunk 文件用 self.webpackChunkbundle_lab.push([[68],{68(n,e,i){…}}]) 注册了工厂 68。index.js 没有自己的工厂,webpack 把它作为入口直接内联进了 runtime。下面所有工具靠的都是这些数字 ID 和这个 push 调用。

真实的线上构建一般开着 concatenateModules,模块已经被合并进同一作用域,能找回的边界会少很多。

第 2 步:有 source map,直接取回原文件

有 map 时这条路最快。Wakaru 1.14.0 自带 extract 子命令(npm install wakaru 会装上 macOS 和 Linux 的可执行文件):

wakaru extract dist-maps/main.js.map -o from-map

写出的文件:

from-map/bundle-lab/src/api.js
from-map/bundle-lab/src/index.js
from-map/bundle-lab/webpack/bootstrap
from-map/bundle-lab/webpack/runtime/define property getters
…(另外 7 个 webpack/runtime/* 文件)

和真实的 src/api.js、src/index.js 做 diff,没有任何差异:注释、变量名原样都在。export.js 不在里面,因为它在 chunk 自己的 map(68.chunk.js.map)里。每个 .map 都要提取,不能只提主文件的。

不想装工具,可以用浏览器版的 JS bundle 与 source map 还原工具:它从 sourcesContent 重建同样的目录树并打包成 .zip,也能把 line:column 或整段堆栈映射回原文件,全程本地运行、不上传。

如果 map 里没有 sourcesContent,它只能给出文件名和位置,给不了代码。这时就当作没有 map,走第 3 步。

第 3 步:没有 map,用 Wakaru 拆模块表

把主 bundle 和它的 chunk 一起传进去,跨 chunk 的 require 才能解析:

wakaru dist-plain/main.js dist-plain/68.chunk.js --unpack=strict --provenance -o unpacked

输出 entry.js、module-672.js、module-68.js 和 provenance.json(记录每个模块在原输入里的字节范围)。拆出来的文件原样如下:

// unpacked/entry.js
import { y } from "./module-672.js";
document.querySelector("#export").addEventListener("click", async ()=>{
    const t = await y();
    const { toCsv } = await require.e(68).then(()=>require("./module-68.js"));
    console.log(toCsv(t));
});

// unpacked/module-672.js
export async function y() {
    const t = await fetch("/api/v1/todos");
    if (!t.ok) {
        throw new Error(`HTTP ${t.status}`);
    }
    return (await t.json()).items;
}

// unpacked/module-68.js
export function toCsv(n) {
    return n.map((n)=>[
            n.id,
            JSON.stringify(n.title)
        ].join(",")).join("\n");
}

和第 1 步对比:结构全回来了,三个模块、静态 import、懒加载 chunk、fetch 和它的错误分支都在。toCsv 保住了名字,因为它是 chunk 导出对象上的属性名。loadTodos 没保住:webpack 早把这个导出缩成了 y,bundle 里没有任何地方记得原名。把 y 改回 loadTodos 得靠你自己,/api/v1/todos 这个字符串通常就是线索。

--unpack=strict 只做结构识别(webpack、browserify、esbuild);--unpack=auto 对作用域提升的 bundle 会加启发式兜底;--unpack=inspect 保留更细的边界,但结果不一定能执行。Rollup、Vite、esbuild 的生产构建通常把所有代码提升进共享作用域,字节里已经没有边界,任何工具都没法确定地还原。

第 4 步:用 REA 梳理 chunk、模块和请求

拆完的文件适合人读。要跨文件提问(“哪个模块发了这个请求?”“哪个源文件变成了这个 chunk?”),REA 可以在不执行代码的情况下为 bundle 建一张静态图。装好 REA 6.7.0(命令名 rea,见 安装与接入)后:

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

进度信息走 stderr,stdout 是干净的 JSON。记录不小:这个 3 KB 的 bundle 产出了 1,350,372 字节。数一下找到了什么:

jq -r '.normalized_result.graph.nodes[].kind' plain-evidence.json | sort | uniq -c
   1 artifact
   2 javascript-asset
   2 javascript-chunk
   4 javascript-module

两个 chunk 是 webpack-bootstrap@0(main.js 里的 runtime)和 webpackChunkbundle_lab:68。四个模块是两个文件加上工厂 672 和 68;还有一条 loads 边,属性是 "kind":"bundler-entry-module","module_key":"672","resolution_status":"resolved",表示入口调用了工厂 672。这和第 1 步手工读出来的一致,而且没人执行过 bootstrap。

带 map 的构建多出 2 个 source-map 节点、2 条 maps_to 边和 12 个 source-module 节点,对应 map 里的每个原始源名:webpack://bundle-lab/./src/api.js、./src/export.js、./src/index.js,以及 9 个 webpack/runtime/*。REA 只记录名称和摘要,不存 sourcesContent 正文,要代码还得用第 2 步。

大 bundle 别整份读,先投影出摘要:

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

把 view 换成 {kind: "page", collection: "modules", offset: 0, limit: 32} 就能分页看模块。注意摘要里 statistics.modules 数的是什么:这次是 2(解析的两个文件),不是图里的 4 个模块节点。

把 Wakaru 的输出喂回去。 分析第 3 步的 unpacked/ 目录,得到 5 个模块、一条指向 ./module-672.js 的 static-import 边、一条指向 ./module-68.js 的 require 边、一条 "mechanism":"call:fetch" 的 calls 边,以及 /api/v1/todos 的 endpoint 节点。endpoint 只有拆包后才作为独立节点出现;在原始 bundle 里,它只体现在语义图中,是一个 fetch 请求加一个 .json() 响应消费者。

REA 也能用 rea recover-javascript-sources 替你调用 Wakaru,并给输入做快照、在 manifest 里记录字节范围。但 6.7.0 里这个适配器只支持 Linux x64,在 macOS arm64 上直接拒绝:

{"code":"capability_unavailable","category":"unsupported_provider","message":"Provide an absolute REA_WAKARU_COMMAND pointing to a Wakaru ^1.13.0 Linux x64 binary (verified with 1.14.0); no tool is installed by REA. …"}

其他平台上,就像第 3 步那样直接跑 Wakaru,再分析它的输出目录。

第 5 步:追踪请求数据流向哪里

trace-javascript-semantics 沿着分析时一并保存的语义图追踪值的流动。用 endpoint 做种子:

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 > trace.json
jq -c '.normalized_result | {status, summary}' trace.json
jq -c '.normalized_result.nodes[] | {kind, label, at: .identity.source_range.start}' trace.json
{"status":"ambiguous","summary":{"total_seed_matches":2,"traversed_nodes":3,"traversed_relations":1,"traversed_functions":1,"traversed_modules":1,"relevant_unknowns":0}}
{"kind":"response","label":"json","at":{"line":1,"column":149}}
{"kind":"call-site","label":"call","at":{"line":1,"column":149}}
{"kind":"request","label":"fetch","at":{"line":1,"column":70}}

有两点要读对。这里的 ambiguous 只表示种子匹配到了两个节点(请求和它的响应消费者),不是说追踪结果不可靠。另外,追踪停在了 .json():它用一条 consumed-by 关系把第 70 列的 fetch 和第 149 列的响应读取连起来,但没有顺着 .items 走出函数、走进 toCsv。缺一条关系,不能证明数据没有流向别处。其他种子类型有 literal、property、event、function、boundary-field、semantic-node、application-node;其他方向有 backward-provenance、callers、ownership。

第 6 步:拿源码树对照 bundle

手上有源码树(开源项目的发布 tag,或自己以前的 checkout)时,compare-source-to-bundle 会给每个源文件打分,看它落到了 bundle 的哪里:

rea import-reference-source "$PWD" --json > reference.json
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 > stb-maps.json
jq -c '.normalized_result.summary' stb-maps.json

分别用 maps-evidence.json 和 plain-evidence.json 各跑一次:

unchanged duplicated unknown
带 map 3 2 3
不带 map 0 2 6

带 map 时,每个 src/*.js 都是 unchanged、置信度 exact、得分 195:exact-source-digest(100)+ source-map-original-path(70)+ basename-match(20)+ language-extension(5)。不带 map 时三个文件全是 unknown,一个候选都没有。源码的哈希不可能和压缩后的字节对上,所以这项检查离不开 map。

坑: 导入时只会跳过 dist/、node_modules/ 这类常见的生成目录。我们的 dist-maps/ 和 dist-plain/ 也被当成“源码”导了进去,表里那两行 duplicated 就是它们:每个 bundle 文件在被分析的构建里和自己的字节匹配了两次,一次是 asset 节点,一次是 module 节点。请把 import-reference-source 指向 src/ 或一份干净的 checkout。它还要求 Linux 或 macOS,原生 Windows 会返回 unsupported_host。

要比较 bundle 自己的两个版本,用 rea compare-application-versions。

大 bundle

每个源文件在单独的 worker 里分析,默认 1024 MiB V8 堆、300000 ms。超出预算的文件会被标为明确的 unknown,其余照常分析。我们在 Electron 逆向分析 里分析一个 13 MB 的真实 bundle 时就撞上了。用 --max-heap-mb 和 --analysis-timeout-ms 调高限额,并且先看摘要里的 limitations,再相信那些计数。

在 agent 里通过 MCP 做 JS 逆向

搜“js逆向 mcp”的人,多半想让 Claude Code、Cursor、Codex 这类 agent 直接调工具去读、拆、追踪 JS,而不是把几 MB 的压缩代码粘进对话框。执行 npx rea-agents setup 后,上面的操作都会变成 MCP 工具(analyze_javascript_application、inspect_analysis_view、trace_javascript_semantics、compare_source_to_bundle 等)。可以这样提问:

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

核对回答时:模块边界要能在应用图里找到,数据流要来自语义追踪。agent 对某个压缩函数“大概在干什么”的猜测,不算证据。如果需要看页面运行时实际加载了什么、发了什么请求,再配一个驱动浏览器的 MCP,两者可以同时接入。