简介:这份小程序反编译工具包围绕 wxappUnpacker 展开,面向需要研究微信小程序内部实现、进行代码分析或安全审计的开发者与安全研究人员,重点解决主包与分包二进制文件难以阅读的问题,可将 WXML、WXSS、JS 与 JSON 等核心文件还原为便于查看和编辑的源码形式。压缩包共 780 个文件,约 1.97MB,以 374 个 js 文件为主体,辅以 83 个 json 配置、71 个 md 说明、70 个 ts 源码及 61 个 map 映射文件,并包含许可协议、命令行脚本与批处理文件,便于了解工具结构、依赖关系及运行方式。已有 270 人学习下载,适合希望搭建分析环境、理解反编译解析流程和分包处理思路的读者参考。其价值在于提供较完整的工具代码与配套说明,可用于梳理界面标记、样式、业务逻辑和配置信息的对应关系,辅助排查问题、性能优化、代码复用与安全检测;使用时需注意遵守平台协议与合法合规要求。
1. 拿到 .wxapkg 打不开:wxappUnpacker 能还原到什么程度
前段时间同事把一个.wxapkg文件发给我——前同事留下的项目,仓库被清空了,线上还在运行,本地只剩一个从测试机导出的包。把后缀改成.zip再解压、拖进编辑器、用十六进制查看器打开,看到的全是乱码。这种情况基本只有一条路:用 wxappUnpacker 把小程序反编译回工程目录。它是一套 Node 脚本,负责的事情并不复杂:按 wxapkg 的索引表把文件逐条切出来,再把编译过的 wxml、wxss 和app-service.js反解析成能读的文本。适合三类人:接手遗留项目的、要做兼容性比对的、想搞清楚某个第三方组件真实行为的。前提是你对这个包有处置权限——自己开发的、公司交接的、明确授权的,不要越界。
2. 拆包前先看壳:14 字节头部与两段索引表
很多人拿到脚本后就直接执行node wuWxapkg.js,报一行Error: 非法的文件就懵了。其实 wxapkg 的结构简单到可以手写解析器,花十分钟看懂这层壳,后面遇到的九成报错都能自己定位。这一章先不碰反编译逻辑,只做一件事:把一个二进制包拆成“文件清单 + 内容块”,并且确认这个包是完整的、没有加密的、没有被截断的。
2.1 头部字段与条目表的字节布局
wxapkg 是一个很朴素的归档格式:固定头部、索引表、数据区,三段首尾相接。头部只有 14 个字节,字段如下。
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | firstMark | 固定 0xBE,第一个魔数 |
| 1 | 4 | info1 | 保留字段,通常是 0 |
| 5 | 4 | indexLen | 索引区长度 |
| 9 | 4 | bodyLen | 数据区长度 |
| 13 | 1 | lastMark | 固定 0xED,第二个魔数 |
| 14 | 4 | fileCount | 文件条目数量 |
两个魔数把头部夹在中间,工具启动后的第一件事就是读这两个字节,只要对不上就直接退出——所以“解包失败”很多时候根本不是反编译的问题,而是文件本身不对。紧接着 14 字节之后是索引表:先 4 字节的文件数,然后每条记录是[4字节文件名长度][文件名 UTF-8][4字节数据偏移][4字节数据长度]。注意这个偏移是相对文件开头的绝对值,不是相对索引区的,所以解析的时候不需要做任何累加修正,直接buf.slice(offset, offset + size)就能切出内容。
一个可以用来体检的恒等式:14 + indexLen + bodyLen应该接近文件总长度,误差通常只有几个字节的填充。这个式子是我判断“包是不是拷全了”的第一道关,比任何报错信息都直观。
2.2 先跑一段体检脚本,别急着解包
下面这段不写任何文件,只读头部、走一遍索引表,把文件数和前若干条目打出来。放在解包之前跑,能省掉大量来回试错。
// peek.js —— 只读文件头和索引表,不落盘,用来判断包是否完整 const fs = require('fs'); const buf = fs.readFileSync(process.argv[2]); const firstMark = buf.readUInt8(0); // 应为 0xBE const info1 = buf.readUInt32BE(1); // 保留位,一般 0 const indexLen = buf.readUInt32BE(5); // 索引区长度 const bodyLen = buf.readUInt32BE(9); // 数据区长度 const lastMark = buf.readUInt8(13); // 应为 0xED const fileCount = buf.readUInt32BE(14); // 条目数 console.log('magic:', firstMark.toString(16), lastMark.toString(16)); console.log('indexLen:', indexLen, 'bodyLen:', bodyLen, 'fileCount:', fileCount); console.log('理论总长:', 14 + indexLen + bodyLen, '实际:', buf.length); // 走一遍条目表,只打印前 20 条,顺便验证偏移是否越界 let pos = 18; for (let i = 0; i < fileCount && i < 20; i++) { const nameLen = buf.readUInt32BE(pos); pos += 4; const name = buf.toString('utf8', pos, pos + nameLen); pos += nameLen; const offset = buf.readUInt32BE(pos); pos += 4; const size = buf.readUInt32BE(pos); pos += 4; const ok = offset + size <= buf.length ? 'ok' : '越界'; console.log(i, name, offset, size, ok); }参数只有一个:process.argv[2]是包路径,用node peek.js ./__APP__.wxapkg跑。脚本本身没有任何副作用,可以放心对着可疑文件反复跑。
输出里要盯的是三处。第一处是 magic,如果不是be和ed,说明拿错文件或者包被二次封装了。第二处是fileCount,正常小程序主包是几百到几千,如果打印出上百万这种数量级,基本是头部错位,后面必然越界。第三处是理论总长与实际长度,差几百字节以上就要怀疑文件被截断——从设备里往外拷的时候中断很常见,尤其包体超过十几兆。
2.3 主包、分包与目录树拼装
一个完整小程序通常不止一个 wxapkg。主包里是app.json、app.js、app.wxss和主包页面;分包是独立文件,里面有各自的子目录页面。每个包都有自己完整的头部和索引表,条目里的路径也是相对各自包根的,所以解出来是几棵互相独立的树,需要合并成一棵。
| 包类型 | 命名特征 | 典型内容 | 处理方式 |
|---|---|---|---|
| 主包 | __APP__.wxapkg或纯数字名 | app.json、app.js、主包页面、组件 | 先解,输出到根目录 |
| 分包 | __sub__<名称>.wxapkg | 分包页面、分包独有组件 | 后解,覆盖式合并到同一目录 |
| 运行时骨架 | 同上包内 | app-service.js、app-config.json、page-frame.html | 不合并,单独留作拆分源 |
合并用循环就够了,不需要写额外逻辑,因为分包落盘的路径本身就带子目录前缀:
# 主包先解,落到 out/ 根目录 node wuWxapkg.js -o ./out ./__APP__.wxapkg # 分包逐个解到同一个 out/,路径自带前缀,直接覆盖合并 for f in ./pkg/__sub__*.wxapkg; do echo "==> $f" node wuWxapkg.js -o ./out "$f" done-o是输出根目录,不传的话默认是包文件名去掉后缀,多包场景下会生成一堆平行目录,改名很麻烦,所以我一般显式指定。循环里不要再加别的开关,保证主包分包的解析参数完全一致,否则同一批.json的缩进和编码会有细微差别,后面做 diff 会很难受。
合并完,目录里应该能看到app.json、app.js、app.wxss、pages/、components/、utils/,外加三个体积明显偏大的文件:app-service.js(所有页面的 JS 打成一包)、app-config.json(所有页面配置打成一包)、page-frame.html(WXML 的运行时容器)。这三个文件是后面几章的主角——真正麻烦的反编译工作,全在它们身上。
3. 跑通 wxappUnpacker:Node 版本、命令链与四类产物
3.1 Node 版本选 12,别硬上新版
这套脚本的核心逻辑写于 Node 8/10 时代,依赖里esprima是很老的版本,源码里还有new Buffer(size)这种写法——它在 Node 10 之后被标记废弃,Node 16 之后彻底移除。在 Node 18/20 上跑,典型症状是启动即抛TypeError: Buffer.alloc is not a function,或者new Buffer is not a constructor,也可能解析到一半莫名其妙卡住。
# 用 nvm 隔离一个旧环境,别动系统默认的 Node nvm install 12 nvm use 12 node -v # 确认输出 v12.xWindows 上对应的是 nvm-windows,或者干脆单独装一个 12.x 的安装包,跑完再切回默认版本。不要图省事直接在 Node 20 上改源码:grep -rn "new Buffer(" .全量替换成Buffer.alloc(或Buffer.from(确实能过第一关,但esprima那关还是过不去,老 parser 不认可选链?.、空值合并??、类字段这些 ES2020 之后的语法,一碰就抛Unexpected token。所以切版本是成本最低的方案。
3.2 一条命令的执行链:从读索引到写文件
环境就绪后,跑起来只有一行。但要知道它在干什么,出问题才知道往哪看。
cd wxappUnpacker npm install # 装 esprima / escodegen / js-beautify 等解析依赖 node wuWxapkg.js -o ./out ./__APP__.wxapkg # 包路径必须放最后内部顺序大致分四步。第一步,读头部和索引表,把fileList建起来,这一步不产生任何输出。第二步,按扩展名分发:.wxml交给wuWxml,.wxss交给wuWxss,.js交给wuJs,.json交给wuConfig,其余类型(图片、字体、wasm)原样 dump。第三步,遇到app-service.js和app-config.json这两个聚合文件时特殊处理,先存一份原始副本,再按内部的define结构拆成独立文件。第四步,按索引表里的路径逐级mkdir再writeFile。
调试时最有用的一条经验:日志如果卡在Unpacking pages/xxx/xxx.js那一行不动,CPU 却拉满,问题一定出在wuJs这一支上。这时按 Ctrl+C,把wuWxapkg.js里分发到wuJs的那几行临时注释掉,先拿到 wxml、wxss、json 三样,JS 单独处理。分批拿产物看着不优雅,但比卡在一个文件上耗半天强得多。
3.3 产物逐层核对:哪些能用,哪些只能读
解完之后别急着往开发者工具里拖,先对着下面这张表过一遍,心里有个预期。
| 产物 | 来源 | 还原程度 | 能不能直接用 |
|---|---|---|---|
pages/*/*.wxml | 编译后的 wxml | 标签和指令基本还原,复杂表达式可能残缺 | 小修可用 |
pages/*/*.wxss | 编译后的 wxss | rpx 已反算,存在 ±0.5 的舍入误差 | 基本可用 |
pages/*/*.js | 从 app-service.js 拆出 | 结构完整,变量名和注释全丢 | 只能读 |
pages/*/*.json | 从 app-config.json 拆出 | 完整 | 可直接用 |
app.json | 从 app-config.json 拆出 | 完整,但 subPackages 路径需核对 | 可用 |
project.config.json | 包里没有 | —— | 必须自己补 |
重点说 JS 这一行。拆出来的是压缩过的代码经过js-beautify排版后的结果,define("pages/index/index.js", function(e,t,i){...})里那三个短参数原本是require、module、exports,工具会按参数位置尝试改名回来。大部分情况是对的,但遇到箭头函数、解构参数、默认参数,位置推断就会失准——所以改逻辑之前,一定要对着页面的实际行为验一遍,别信变量名。
3.4 app-service.js 的 define 拆分:JS 只有一半能看
编译期的做法是把所有页面的 JS 塞进一个app-service.js,每个模块用define("模块路径", function(require, module, exports, ...){...})包一层。拆分靠的是匹配define(加上括号配对,切出模块体再写回对应路径。这个办法对绝大多数模块有效,验证一下就知道漏没漏:
# 拆分前有多少个 define,拆分后有多少个 js 文件 grep -c 'define("' app-service.js find ./out -name '*.js' | wc -l两个数字应该接近,差值主要是app.js这类入口文件。如果差得很多,说明有模块的define第一个参数不是字符串字面量——拼出来的路径、变量、或者被压缩器改写过的形式,正则都切不出来,这部分会原样残留在app-service.js里,得手工捞。
还有一个绕不过去的现实:如果项目里用了打包器,第三方库会被打成__webpack_require__那一套运行时,反编译出来是几百行状态机,能读不能改。真正值得花时间的是自己业务代码那几段,找到define("pages/xxx/xxx.js"开头的那一块,前后对照着看,逻辑基本能还原到八成。
4. WXML 与 WXSS 还原:750 基准换算和组件路径修正
4.1 编译后的 WXML 丢了什么、能捡回什么
小程序打包时会先把 wxml 编译成一份带运行时调用的中间表示:wx:if变成条件表达式,{{a + b}}变成表达式树,<block>直接展开,wx:for的 item/index 被重命名成极短的名字。wuWxml走的是反解析路线——把这份中间表示重新 parse 成 AST,再按模板语法生成文本。
能捡回来的:标签层级、大部分wx:if / wx:elif / wx:else、wx:for、bind和catch事件名、{{}}里的简单表达式、静态的class和 `
本文还有配套的精品资源,点击获取