ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebCrack实战:sourcemap缺失时如何还原webpack混淆代码

WebCrack实战:sourcemap缺失时如何还原webpack混淆代码

前阵子接手一个历史遗留项目的线上包,Sourcemap早丢了,构建链路的插件又升级了好几轮。整个文件就一行,六万多个字符,模块全部塞进一个webpack runtime里,变量名用完a用b,用完b用c。说好听点叫“混淆地狱”,说难听点就是不把代码压成人干不罢休。后来我把这个bundle丢给WebCrack跑了二十分钟,模块边界和依赖关系基本全回来了。这篇就把我从接触到上手,再到处理各种奇怪bundle的完整实战过程,以及中间踩过的坑,一并记下来。

WebCrack严格来说不是“破解网站”的破解器,准确叫法是打包产物反混淆还原器。它专门解析webpack、webpacker、parcel这类构建结果,把套在IIFE里的模块拆开,把运行时里一堆含义不明的辅助函数还原成常规状态,把字符串数组、简单编码函数尽可能解回明文。适合三类人:一类是像我这样弄丢sourcemap又必须维护线上代码的开发者,一类是做授权范围内安全分析的工程师,另一类是单纯想搞懂自己项目构建产物是怎么长出来的前端。它救不了所有混淆,但能把“满屏天书”变成“能勉强读下去的地狱求生指南”,这一步就是质变。

1. WebCrack到底在做一件什么事

1.1 为什么好好的代码会被压成天书

很多人第一次打开生产环境的webpack产物,脑子是懵的。其实代码变成那样,不是构建工具坏了,而是发布流程里普遍会做“压缩+混淆”。webpack本身负责把多个模块合并成一个文件,模块源码会被包在一个个函数里,外面套一个统一的模块加载器,也就是runtime。

不混淆的情况下,这个结构其实还算可读。一个简化后的webpack产物长这样:

(() => { var __webpack_modules__ = { 0: function (module, exports, require) { module.exports = function (price) { return price * 2; }; }, 1: function (module, exports, require) { const double = require(0); console.log(double(21)); }, }; var __webpack_module_cache__ = {}; function __webpack_require__(moduleId) { if (__webpack_module_cache__[moduleId]) { return __webpack_module_cache__[moduleId].exports; } var module = (__webpack_module_cache__[moduleId] = { exports: {}, }); __webpack_modules__[moduleId](module, module.exports, __webpack_require__); return module.exports; } __webpack_require__(1); })();

一旦接上Terser这类压缩插件,情况就变了。模块id变成数字索引,模块函数名、参数名全部压缩成单字母,runtime里的__webpack_require__被改成e、n之类的短名。再叠加字符串编码、数组打包,人眼基本没法看。

混淆地狱的本质,不是代码逻辑真的消失了,而是代码里的“语义标签”被系统性地抹掉了。模块还是那些模块,函数还是那些函数,但变量名、模块名、字符串常量都变成了难以解读的密文。仓库里的货没丢,只是外箱标签全撕了,编号也洗牌了。

WebCrack要做的,就是把这些标签重新贴回去。它不靠蒙,也不靠硬猜,而是用一套结构化的方式来拆解打包产物。

1.2 WebCrack的核心拆解思路:AST、模块边界与运行时还原

WebCrack不是做文本替换,它先把代码解析成AST,也就是抽象语法树。AST会把每个语法元素变成节点,函数调用、变量声明、表达式全变成一棵有父子关系的树。反混淆器拿到这棵树以后,就能做结构化匹配:找到那个包裹模块表的IIFE,找到runtime入口函数,找到模块注册表容器。

这一步是正则很难替代的。正则只看到一段字符串,而AST看到了“谁是谁的返回值”“谁能引用谁”。比如压缩后的代码是e[1],你可能不知道这个e是什么,但AST会告诉你,e的初始值来自一个对象字面量,那个对象字面量就是模块表。

正常流程下来,大致分七步:

  1. 解析JS代码生成AST。
  2. 定位打包器runtime的入口,通常是IIFE。
  3. 识别模块表容器和每个模块函数的边界。
  4. 剥离runtime包装,把每个模块函数独立提取。
  5. 对局部变量、函数名做规范化重命名。
  6. 解码可识别的字符串数组和编码函数。
  7. 格式化并输出,把模块id映射成可读路径。

这里必须说清楚一个边界:变量名的原始含义是没法无中生有的。压缩器把userName变成a以后,这个语义信息已经完全丢失。WebCrack能恢复的是结构,恢复不了原始命名。所谓“源码重生”,更准确的理解是“可读的重建”——从一团乱麻里还原出模块结构、依赖关系和逻辑顺序,但注释、原始命名、未压缩前的目录结构,大概率回不来了。

2. 工具选型与上手流程

2.1 为什么不是格式化工具,也不是正则

网上常见的一类建议是用js-beautify先排版,再用正则慢慢把关键部分抠出来。这个方案在代码量小、混淆程度低的时候确实能用,但一旦遇到webpack runtime,就非常吃力。js-beautify只会加缩进和换行,它不知道哪个函数是模块表,哪个调用是入口,更不会把n.d、n.o这些runtime辅助函数识别出来。

也有人会走“正则硬抠”路线。比如匹配function(e,t,n){...},试图把模块函数体抽出来。这个思路在混淆特征固定时有效,但webpack版本升级、插件加一层自定义混淆之后,特征一变化,正则就全线崩溃。正则擅长处理“流式文本”,不擅长处理“嵌套结构”,而所有JS代码本质上都是嵌套结构。

还有一类方案是Sourcemap映射。如果构建时留下了.map文件,用source-map库直接还原,质量远高于任何反混淆工具。但Sourcemap不是每个项目都有,尤其是历史遗留系统、外包交接、长期失修的线上存量代码,map文件经常是残缺的。这时候WebCrack就成了很现实的替代选择。

用一张表来对比会更清楚:

方案能干什么不能干什么
js-beautify排版缩进不拆模块,变量名还是天书
正则提取快速匹配固定串混淆特征变化后易碎,难处理嵌套
Sourcemap精确还原原始代码没有map就彻底失效
WebCrack解析AST、拆模块、还原结构不能恢复注释、原始变量名、原始目录

所以我的建议很直接:有Sourcemap优先用Sourcemap,没有Sourcemap就把WebCrack当第一选择。

2.2 安装、运行与输出结构

使用WebCrack的前置条件就是Node.js环境,建议16以上,新版工具对18、20的支持会更好。安装方式用npx最省事,不需要往项目里装一堆依赖:

npx webcrack dist/bundle.js -o ./cracked

-o指定输出目录。如果输入的是目录,工具会尝试把目录下所有JS文件都过一遍,输出目录会尽量维持对应的相对路径结构。具体参数每个版本会有差异,使用前先看一眼帮助信息:

npx webcrack --help

跑完之后去cracked目录里看,最大的感受就是:原来那一行天书,变成了一堆有函数边界、有缩进、有模块注释的代码。有些版本还会额外生成模块索引文件,方便按id查找。

这里说个实操细节:输出目录如果已经存在,里面可能残留上次的结果,最好先清空。有些版本提供了清理参数,没有的话就手动删掉旧目录,避免后面分析的时候被旧文件干扰。

2.3 一条可直接复制的标准操作流程

第一次跑WebCrack,不要直接拿生产大文件试,很容易因为体积太大、混淆花样太多而失败。标准流程应该是小步快跑:

  1. 先确认是不是webpack/parcel产物。搜索__webpack_require__是最快的特征检查,但压缩后可能只剩短变量名。更可靠的是看开头是不是IIFE,里面有没有一个模块注册表结构。
  2. 备份原始bundle。反混淆输出可能不完整,原文件是唯一的底牌。
  3. 跑一次WebCrack,生成还原结果。
  4. 用编辑器打开结果,先别管细节,看整体模块边界清不清楚。
  5. 用prettier或eslint二次格式化,把长表达式展开,提升阅读体验。
  6. 全局搜索业务关键词,比如API地址、报错文案、URL常量,这些字符串能帮你快速定位到具体模块。

注意:安全审计场景建议在隔离环境里跑这个流程。虽然WebCrack本身是静态分析工具,但目标bundle可能内嵌了环境检测、反调试逻辑,不排除读取文件过程中触发额外行为。静态分析原则上不会执行目标代码,但这个“不执行”并不是百分之百的保证,尤其是在有插件机制的工具链里。隔离环境是习惯,不是教条。

3. 从混淆到源码:一个可复现的实战拆解

3.1 准备一个教学用的“迷你bundle”

为了讲清楚WebCrack的还原过程,我准备了一个教学用的mini bundle。它高度模拟了webpack产物的特征,但不是真实产物,结构上做了大幅简化和缩水。重点是让你理解模块表、缓存、入口调用这些概念。

// 教学环境模拟产物,结构完全简化 !function (e) { var t = {}; function n(r) { if (t[r]) return t[r].exports; var o = (t[r] = { i: r, l: !1, exports: {} }); return e[r].call(o.exports, o, o.exports, n), (o.l = !0), o.exports; } n.d = function (e, r, t) { n.o(e, r) || Object.defineProperty(e, r, { enumerable: !0, get: t }); }; n.o = function (e, r) { return Object.prototype.hasOwnProperty.call(e, r); }; n(0); })({ 0: function (e, t, n) { var r = n(1); console.log("订单实付", r("原价100")); }, 1: function (e, t) { t.exports = function (priceText) { var price = Number(priceText.replace("原价", "")); return price * 0.85; }; }, });

这段代码里有两个核心模块:模块0是入口,它调用了模块1的函数;模块1暴露一个计算折扣价的函数。外层的n函数就是runtime,t是模块缓存,e是模块注册表,n(0)则是入口调用。

真实webpack产物里,模块表通常是一个对象,每个模块是一个函数,函数参数一般就是module、exports、require。压缩之后变成e,t,n、r、o之类的短名,结构不变但可读性归零。

3.2 跑通WebCrack并理解每一轮还原

对这段教学bundle执行WebCrack之后,期望得到的效果是类似这样的重建结果:

const module0 = function (module, exports, require) { const mod = require(1); console.log("订单实付", mod("原价100")); }; const module1 = function (module, exports) { exports = function (priceText) { const price = Number(priceText.replace("原价", "")); return price * 0.85; }; }; module0();

实际输出里,模块名不一定是module0,可能是__0之类根据运行时分析得到的编号。但关键点在于:

第一,外层IIFE被剥离了。模块不再被一个巨大的匿名函数包着,每个模块有了独立的位置。第二,runtime辅助函数n、t、o之类不再顶着天书般的名字,而是被整理成相对清晰的调用关系。第三,模块之间的require(1)调用被保留,依赖顺序肉眼可读。

到了这一步,整个bundle的基本骨架就立起来了。你再去看代码,虽然变量名可能还是短名,但已经能从模块边界、require调用、字符串常量推断出业务逻辑。

跑完WebCrack以后,我会习惯性地再叠加一步格式化:

npx prettier --write cracked/

prettier可以把长表达式拆行,让函数的嵌套关系更清楚。WebCrack已经拆好的模块,经过prettier整理后,几乎接近正常的JS代码阅读体验。

3.3 更硬核的混淆:字符串数组与控制流平坦化

字符串数组是另一类高频混淆,核心作用是把业务字符串抽到一个数组里,运行时用下标去取。看起来像是:

var _0x4f3e = ["订单实付", "原价"]; console.log(_0x4f3e[0], x(_0x4f3e[1]));

WebCrack能解一部分通用模式,但我实测下来,遇到自定义编码函数时,还是得自己补刀。我常用的办法是写一个几十行的Babel插件,先定位字符串数组,再把所有引用它的地方替换成明文。

下面是一个可以直接改来用的Babel处理脚本:

const fs = require("fs"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; const generate = require("@babel/generator").default; const t = require("@babel/types"); const code = fs.readFileSync("bundle.js", "utf8"); const ast = parser.parse(code, { sourceType: "unambiguous", }); let stringTable = null; traverse(ast, { VariableDeclaration(path) { const decl = path.node.declarations[0]; if ( decl && decl.init && decl.init.type === "ArrayExpression" && decl.id.type === "Identifier" ) { stringTable = decl.init.elements.map((el) => el.value); path.remove(); } }, MemberExpression(path) { if ( stringTable && path.node.object.type === "Identifier" && path.node.object.name === "_0x4f3e" && path.node.property.type === "NumericLiteral" ) { const idx = path.node.property.value; path.replaceWith(t.stringLiteral(stringTable[idx])); } }, }); fs.writeFileSync("bundle.clean.js", generate(ast).code);

控制流平坦化是另一个噩梦。它会把顺序执行的逻辑全部塞进一个while循环加switch的分发结构里。混淆前的代码可能是:

const a = 1; console.log(a);

混淆后变成:

let state = 1; while (state) { switch (state) { case 1: const a = 1; state = 2; break; case 2: console.log(a); state = 0; break; } }

还原思路就是顺着state的初始值和每次赋值,把case分支串联起来,最终展开成原始的顺序逻辑。这个算法可以写成Babel插件,但难点在于识别分支里的副作用、循环控制、异常跳转。WebCrack能覆盖一部分打包器自带特征,但遇到定制型控制流平坦化,没有一键魔法。我的建议是:先把字符串数组和模块边界恢复,再针对控制流特征手写还原转换,优先级从低到高,能还原多少是多少。

4. 高级玩法:自定义还原脚本与批量处理

4.1 用Node API写自动化还原脚本

CLI适合手头单个文件,但做分析时常常要反复实验。WebCrack同样提供了Node API,可以把它嵌进自己的处理流水线。大致调用方式如下:

const fs = require("fs"); const { webcrack } = require("webcrack"); async function main() { const input = fs.readFileSync("bundle.js", "utf8"); const result = await webcrack(input); fs.mkdirSync("./cracked", { recursive: true }); fs.writeFileSync("./cracked/bundle.clean.js", result.code); if (result.modules) { result.modules.forEach((m, idx) => { fs.writeFileSync(`./cracked/module-${idx}.js`, m.code); }); } } main();

这段代码的作用是把还原结果拆到独立文件里,方便逐个模块阅读。具体字段名可能会随版本变化,用之前对照一下当前版本文档。我的习惯是先把result完整打印出来看一眼结构,再决定提取哪些字段,而不是假设接口永远不变。

4.2 配合Babel处理“非主流混淆”

很多混淆不是打包器原生的,而是发布环节自己写了一套编码逻辑。比如电商项目里经常见到价格、活动码被塞进一段自定义混淆函数里,函数名叫做d、x或者更隐蔽的符号。

处理思路分四步:

  1. 在还原后的代码里搜索高频异常函数调用,比如d(12, "\\x68\\x65\\x6c\\x6c\\x6f")这样的模式。
  2. 找到解码函数的实现,把运行结果做成键值对表。
  3. 用Babel遍历AST,把所有符合特征的调用替换成表里的明文。
  4. 跑一遍格式化,确认逻辑可读。

替换调用那步的模板大概是:

const t = require("@babel/types"); const decodeTable = new Map(); traverse(ast, { CallExpression(path) { const node = path.node; if ( node.callee.name === "d" && node.arguments.length === 2 && node.arguments[0].type === "NumericLiteral" ) { const id = node.arguments[0].value; if (decodeTable.has(id)) { path.replaceWith(t.stringLiteral(decodeTable.get(id))); } } }, });

写这种脚本最花时间的部分,永远是“识别特征”。只要你能回答三个问题:解码函数的调用形式是什么、参数里哪个是索引、编码表从哪来,剩下的就是机械替换。识别特征有没有捷径?没有。老老实实打开源码,先看解码函数怎么写的,再决定遍历规则。

4.3 批量处理场景

真实项目里,线上产物往往不是一个bundle,而是一个目录下有几十个chunk文件。WebCrack能不能直接扫目录,取决于版本。不管支持与否,写个循环脚本总是最通用的:

const fs = require("fs"); const path = require("path"); const { webcrack } = require("webcrack"); async function processFiles(files) { for (const file of files) { try { const code = fs.readFileSync(file, "utf8"); const result = await webcrack(code); const outFile = path.join("out", path.basename(file)); fs.mkdirSync("out", { recursive: true }); fs.writeFileSync(outFile, result.code); console.log(file, "OK"); } catch (err) { console.error(file, "FAILED", err.message); } } } processFiles(["dist/a.js", "dist/b.js", "dist/c.js"]);

批量处理时要注意一个点:如果这些chunk共享同一个runtime,单独还原每个文件会丢失跨文件依赖关系。更稳妥的做法是先处理承载runtime的主入口文件,再处理异步chunk,最后把模块编号做成一张对照表。还原的目的不是让每个文件变好看,而是让整体依赖关系能重新串起来。

4.4 重建模块依赖地图

WebCrack还原后的代码里,require(1)、require(2)这类调用还在。我把这些调用关系提取出来,就能拼出一张模块依赖地图:

const deps = {}; result.modules.forEach((m, idx) => { const requires = m.code.match(/require\((\d+)\)/g) || []; deps[`module-${idx}`] = requires.map((r) => r.match(/\d+/)[0]); }); console.log(deps);

这张地图在手,分析入口就很方便。先看哪个模块被调用最频繁,哪个模块是入口,哪个模块是纯工具函数,再顺着依赖一路读下去。比追着一行行找n(0)、n(1)要快得多。

5. 还原现场翻车实录:常见报错与排查技巧

5.1 输出目录空空的,问题出在哪

最常遇见的翻车现场,是命令跑完以后输出目录里什么都没有,或者只有一个空壳文件。别急着怀疑工具坏了,先排查以下几种情况。

第一种,输入文件根本不是webpack/parcel产物。可能是普通脚本被压缩过,WebCrack找不到任何模块表特征,自然就输出不了东西。这种情况去检查文件开头有没有IIFE和模块注册表结构。没有的话,这工具对它就不适用。

第二种,文件被其他混淆器二次加工过。有些上线流程会叠加商用混淆插件,把webpack runtime的特征彻底改掉。WebCrack识别不到,输出就会残缺。处理办法是先对整段代码做一次粗格式化,人工判断还能不能看到模块表,再决定要不要硬啃。

第三种,超时或内存不足导致中途退出。工具没跑完,输出目录里自然没有完整结果。解决思路见下一节。

5.2 内存爆掉与超时处理

一个几十MB的bundle,解析成AST会占用大量内存。我遇到过一次线上包接近30MB,跑WebCrack直接进程被系统kill掉。排查时先在命令行里调整Node堆大小:

NODE_OPTIONS=--max-old-space-size=8192 npx webcrack big.js -o out

如果还是不够,就要给文件“减负”。bundle里经常夹着大量base64图片、内嵌字体、超长配置JSON,这些对还原业务逻辑没有直接价值。我常用的办法是先把超长字符串替换成占位符,处理完再换回来:

const fs = require("fs"); const input = fs.readFileSync("big.js", "utf8"); const output = input.replace(/base64,[A-Za-z0-9+/=]{5000,}/g, "base64,__LONG_STRING__"); fs.writeFileSync("tmp.js", output);

减负之后,AST节点数量大幅下降,解析成功率高很多。这个技巧在多次实战里都管用,强烈建议先试一下。

5.3 还原结果仍然不可读怎么办

WebCrack跑完了,输出也不为空,但打开一看还是满屏短变量,读起来依然费劲。这种情况不一定是工具失败,而是混淆器已经把变量名语义彻底抹掉了。WebCrack能恢复的是结构,不是原始命名。

这时候我走三步:

  • 接受编号命名,把分析重心放在模块边界和字符串常量上。
  • 用IDE的重命名功能,把关键模块入口手动命名。比如分析出模块0是登录逻辑,就给它加注释// 登录模块入口,后续顺着读。
  • 优先追踪字符串。报错文案、接口路径、URL这些业务字符串是很难完全混淆的,即使被编码,也大概率在还原后重新出现。沿着这些字符串搜索,很快能定位到具体业务模块。

还有一个常见问题:WebCrack输出的代码有时会带语法错误。多半是目标bundle用了工具内置解析器还不支持的最新语法。解决办法是升级工具版本,或者先对原始代码跑一次AST降级转换,比如用Babel把新语法转成ES5,再丢给WebCrack处理。

5.4 常见问题速查表

现象可能原因处理方式
输出为空不是webpack/parcel产物检查IIFE和模块注册表特征
输出为空内存不足或超时增大Node堆,替换超长字符串
结果还是天书变量名语义已丢失接受编号,重点还原模块边界
字符串未解码自定义编码函数写Babel插件手动解码
还原结果报语法错误解析器版本过旧升级工具或先做语法降级
动态chunk找不到分离包处理导致依赖孤岛先还原runtime入口,再处理chunk
还原后的代码行为异常混淆器可能改变了部分运行时结构不直接上线,先静态审查和单元测试

这七条几乎能覆盖日常80%的翻车原因。剩下20%属于罕见混淆变体,只能靠多写脚本、多试错来啃。

6. WebCrack的适用边界和我的实操心得

6.1 什么场景别用WebCrack

工具再好用,也有不值得硬上的场景。

如果你手上还有Sourcemap,优先用Sourcemap。WebCrack再强,也做不到Sourcemap那种逐行还原的精度。如果你只是想美化一下代码,用js-beautify就够了,不需要引入一个AST级解析工具。如果目标代码已经被完全eval包裹,或者经过商业级重型混淆,WebCrack的识别率会大幅下降。这种场景就当没有万能钥匙,老老实实做动态分析或者人工啃。

另外,如果你要分析的是自己写的源码,直接看源码就好,别绕一圈。WebCrack生产在自己项目丢失map文件后的抢救流程里,价值最大。

6.2 我的几条实操心得

第一,先花五分钟判断bundle属于哪类构建器。webpack、parcel、webpacker的产物特征各有差异,选错工具等于白等。只要能看到模块注册表,基本就能判断个八九不离十。

第二,大文件先减负。base64、字体、超长日志字符串,先替换成占位符,还原完成再恢复。很多“工具没反应”的问题,其实是内存先炸了。

第三,字符串是最大的突破口。无论混淆多狠,业务里一定有一部分字符串需要出现在线上环境中,比如API地址、报错提示、页面文案。抓住这些字符串,就是抓住了地图坐标。

第四,工具版本会迭代。旧版解析不了新语法,新版有时候也会改命令行参数。升级前先备份当前可用的版本,别让一次升级把分析链路弄断。

第五,给还原结果写目录注释。你这次分析完了,下次可能又要看同一个bundle。没有注释,下次还是要重新定位入口。

第六,Sourcemap一定要保存到正式发布之外的地方,比如内部制品库。不是所有时候都能依赖反混淆工具。提前留好后路,比事后在混淆地狱里挣扎省太多时间。

6.3 合法与合规红线

WebCrack本身是纯分析工具,但使用它分析别人的代码,必须确保目标代码属于你,或者你拿到了明确书面授权。我自己做安全审计时,会把授权范围、分析目的、以及不利用还原结果绕过对方保护措施的承诺,写到项目说明里。这不是套话,是保护自己的基本动作。

无论是调试历史项目还是做研究,都不要把这类工具用到别人的生产系统上。没有例外。

最后多唠叨一句:我第一次成功把一团上万字符的bundle还原成能读的模块时,确实有种“源码重生”的爽感。但后来用得越多,越明白这类工具的价值不是炫技,而是帮你在没有地图的情况下重新找到出口。它不完美,变量名永远回不到从前,可它能把分析门槛从“看天书”降到“读散文”。真遇到搞不定的混淆,多盯一下AST、多写几个Babel插件,比到处找万能脚本更靠谱。留好Sourcemap,永远是上策。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进