JS 逆向实战:如何还原 webpack 打包后的源码(附 MCP 用法)
本页目录
如果 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,两者可以同时接入。