首页 / 资讯中心 / 文章详情

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

WebCrack实战:sourcemap缺失时如何还原webpack混淆代码 ★ FEATURED ARTICLE
前阵子接手一个历史遗留项目的线上包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的初始值来自一个对象字面量那个对象字面量就是模块表。正常流程下来大致分七步解析JS代码生成AST。定位打包器runtime的入口通常是IIFE。识别模块表容器和每个模块函数的边界。剥离runtime包装把每个模块函数独立提取。对局部变量、函数名做规范化重命名。解码可识别的字符串数组和编码函数。格式化并输出把模块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不要直接拿生产大文件试很容易因为体积太大、混淆花样太多而失败。标准流程应该是小步快跑先确认是不是webpack/parcel产物。搜索__webpack_require__是最快的特征检查但压缩后可能只剩短变量名。更可靠的是看开头是不是IIFE里面有没有一个模块注册表结构。备份原始bundle。反混淆输出可能不完整原文件是唯一的底牌。跑一次WebCrack生成还原结果。用编辑器打开结果先别管细节看整体模块边界清不清楚。用prettier或eslint二次格式化把长表达式展开提升阅读体验。全局搜索业务关键词比如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函数就是runtimet是模块缓存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或者更隐蔽的符号。处理思路分四步在还原后的代码里搜索高频异常函数调用比如d(12, \\x68\\x65\\x6c\\x6c\\x6f)这样的模式。找到解码函数的实现把运行结果做成键值对表。用Babel遍历AST把所有符合特征的调用替换成表里的明文。跑一遍格式化确认逻辑可读。替换调用那步的模板大概是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-size8192 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永远是上策。
阅读完成 · 觉得有帮助?
咨询建站