简介:wxappUnpacker 是一套面向微信小程序开发者与安全研究人员的反编译工具源码包,重点解决小程序主包与分包二进制内容难以阅读分析的问题,可将包内结构还原为接近源码的形式,便于理解业务逻辑、排查问题并开展合规的安全审计。压缩包共 780 个文件,约 1.97MB,以 js 脚本、json 配置、md/markdown 说明及 ts、map 文件为主,辅以 license、cmd、ps1 等运行与许可相关文件,目录覆盖工具核心逻辑、依赖模块和文档说明。已有 269 人学习,说明其在逆向分析与小程序结构研究场景中具备一定参考价值。读者可借此了解 WXML、WXSS、JS 与 JSON 等关键文件的还原思路,观察主包与分包的处理方式,并参考现有脚本组织与依赖配置搭建分析环境。适合具备一定前端与 Node.js 基础、希望深入小程序架构与安全检测的读者,使用时需遵守开发者协议与相关法律法规。
1. 拿到一个只有几十 KB 的 .wxapkg,改后缀是没用的
手上拿到一个.wxapkg,双击打不开,把后缀改成.zip解压出来一堆乱码——这是大多数人第一次接触小程序反编译文件时的真实场景。wxappUnpacker 解决的就是这件事:一套运行在 Node.js 上的拆包脚本,输入一个.wxapkg,输出一棵能够阅读的目录树,包含app.json、页面级 json、wxml、wxss,以及从app-service.js里按模块切出来的独立.js文件。它适合三类读者:接手遗留项目、需要看清页面结构和数据流的人;做合规检查、想知道包里到底打包了什么的人;以及研究小程序编译产物形态的人。前提只有一条——包必须是你名下的,或者你拿到了书面授权,这一点后面还会重复提到。
2. 拆包前先读懂 wxapkg:14 字节头、索引表和偏移量
2.1 三段式结构,决定了为什么改后缀没有用
.wxapkg不是压缩包,它没有压缩也没有加密(部分加固方案例外),本质上是一种自定义的容器格式。整个文件从头到尾分三段:14 字节的定长头、变长的索引区、变长的数据区。头部结构用大端序排列,字段依次是——
| 偏移 | 长度 | 字段 | 含义 |
|---|---|---|---|
| 0 | 1 | firstMark | 固定0xBE,用来快速识别文件类型 |
| 1 | 4 | info1 | 版本/标志位,多数包为 0 |
| 5 | 4 | indexInfoLength | 索引区字节数 |
| 9 | 4 | bodyInfoLength | 数据区字节数 |
| 13 | 1 | lastMark | 固定0xED,尾部校验 |
索引区里是一条条紧凑排列的记录,没有文件数量字段,只能一直读到索引区结束。每条记录是:4 字节文件名长度 + 文件名(UTF-8,含/分隔的路径)+ 4 字节数据偏移 + 4 字节数据长度。数据区就是所有文件的原始字节顺次拼接。
搞清这一点之后就能理解两件事。第一,改后缀没有用不是错觉,因为没有 zlib/gzip 的魔数可解。第二,文件名是明文,所以grep -a "pages/"就能大致看出这个包有哪些页面——这一步在动手反编译之前做一次,能省掉很多无用功。
提示:文件名的编码绝大多数是 UTF-8,但遇到过用 GBK 存的包,
decode失败时先换编码试,不要急着判定包损坏。
2.2 用 thirty 行 Python 把包按索引表摊平
在跑 wxappUnpacker 之前,我习惯先用一段最小脚本把包原样摊平。它的意义不在于替代工具,而在于:一旦后面的反编译环节出问题,你能立刻判断是「包没解开」还是「解开了但还原失败」。
# unpack_wxapkg.py —— 只做一件事:按索引表把 .wxapkg 摊平成目录 import struct, os, sys HEAD = struct.Struct('>BIIIB') # 首标记1 + info1(4) + 索引长度4 + 数据长度4 + 尾标记1 = 14 BASE = 0 # 数据偏移基准。若解出的文件开头是乱码,改成 14 + idx_len 再试 def unpack(path, outdir): raw = open(path, 'rb').read() first, info1, idx_len, body_len, last = HEAD.unpack_from(raw, 0) if first != 0xBE or last != 0xED: raise ValueError('不是 wxapkg,或头部被改过') pos, end, count = 14, 14 + idx_len, 0 while pos < end: name_len = struct.unpack_from('>I', raw, pos)[0]; pos += 4 name = raw[pos:pos + name_len].decode('utf-8', 'replace'); pos += name_len off, size = struct.unpack_from('>II', raw, pos); pos += 8 dst = os.path.join(outdir, name.replace('/', os.sep)) os.makedirs(os.path.dirname(dst) or outdir, exist_ok=True) with open(dst, 'wb') as f: f.write(raw[BASE + off: BASE + off + size]) count += 1 print('文件数=%d 索引区=%d 数据区=%d' % (count, idx_len, body_len)) if __name__ == '__main__': unpack(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else 'raw')几个参数值得说明。HEAD用>BIIIB(大端),如果写成小端,first会是0xBE000000这类明显异常的值,一眼能看出问题。BASE是最容易出错的地方:多数包里索引里的偏移是相对整个文件起点算的,所以默认 0;但确实存在相对数据区起算的变体,判断方法是解出来的第一个文件(通常是app-config.json或app-service.js)开头如果是乱码或长度完全对不上,就把BASE改成14 + idx_len重跑。decode('utf-8', 'replace')不带 replace 的话,一个坏字节会让整个循环中断,而后面几百个文件根本还没写出来。
2.3 主包、分包、插件的命名规律
从测试机上导出的包通常不是一个目录,而是一组:
wxapkg/ ├── __APP__.wxapkg # 主包,app.json / app-service.js 在这里 ├── __FULL__.wxapkg # 整包(部分版本才有,等于主包+全部分包) ├── packageA.wxapkg # 分包,名字跟 app.json 里 subPackages 的 root 对应 ├── packageB.wxapkg └── plugin.wxapkg # 插件包,根目录产物是 plugin.json比如__APP__.wxapkg用 2.2 的脚本跑一遍,看文件数判断是不是完整包;再跑一遍packageA.wxapkg,看它的文件名前缀是不是集中在packageA/下。前缀对不上说明这个包被改过名,或者是别的小程序包混进来了。
主包和分包解出来时会撞文件名:两个包里都有app-config.json,直接解到同一目录会互相覆盖。常见做法是先解主包到dist/,再把每个分包解到dist/(注意这个顺序不能反,因为分包里的页面配置要合并进主包的app.json),覆盖前先备份主包的app-config.json,最后手工把分包的页面路径并进主包配置。如果懒得合并,分包单独解到dist/packageA/也能读,只是页面之间的跳转路径要自己脑补。
3. wxappUnpacker 跑通:Node 版本、参数与四类产物的归属
3.1 环境与依赖,为什么它对 Node 版本敏感
wxappUnpacker 是纯 Node 脚本,不涉及原生模块编译,理论上npm install完就能跑。但它内部依赖一批 AST 和格式化相关的库——esprima负责把 JS 解析成语法树、escodegen把树再生成回来、js-beautify做美化、css-tree解析样式——这些库的高版本在 Node 18 之后陆续换过 API 签名,表现就是脚本跑到一半抛xxx is not a function。
血泪经验是:把 Node 切到 14 或 16 的 LTS 再装依赖,能省掉 80% 的玄学报错。项目管理器用 nvm 之类随便一个都行:
# 拉下源码目录后,先确认 node 版本 node -v # 建议 14.x 或 16.x nvm use 16 # 在含 package.json 的那一层装依赖 npm install --production=false # 不带任何参数跑一次,看它自己吐出来的用法说明 node wuWxapkg.js最后一条命令很关键。不同分支的开关字母不一样,-o、-d、-s的含义在 fork 之间有出入,与其背文档不如让它自己告诉你。我一般只记两条:包路径永远放最后;不带开关是「解包 + 还原」的完整流程。
3.2 命令形态与输出目录的约定
参数化之后大致是这三种用法:
| 用法 | 命令形态 | 产物 |
|---|---|---|
| 完整还原 | node wuWxapkg.js ../pkg/__APP__.wxapkg | 解包目录 + 还原后的 js/wxml/wxss |
| 只解包 | node wuWxapkg.js -o ../pkg/__APP__.wxapkg | 原始文件树,不做任何反编译 |
| 带调试 | node wuWxapkg.js -d ../pkg/__APP__.wxapkg | 保留中间文件,便于定位失败点 |
输出目录默认是包文件同名(去掉扩展名)。这个约定有个副作用:同一个包反复跑,第二次会在已有目录上叠加,上一轮的残留文件会干扰判断。我一般每次跑之前先rm -rf掉输出目录,或者在命令后面显式指定一个带时间戳的目录。
-o这一步千万不要跳过。第一次接触一个陌生包,先只解包,用ls -R看一遍文件清单,确认app-service.js、app-wxss.js、app-config.json这三个文件都在,再去跑完整流程。少了app-wxss.js说明这个包的样式被内联进了 js,wuWxss.js那一步天然不会产出东西,这不是故障。
3.3 四类产物分别由哪个脚本负责
仓库根目录那堆wu*.js各管一摊,弄清楚分工,报错时才知道该看哪里:
| 脚本 | 输入 | 输出 |
|---|---|---|
wuConfig.js | app-config.json | app.json与各页面级.json |
wuJs.js | app-service.js | 各页面/组件的独立.js |
wuWxml.js | app-service.js里的模板函数 | 各页面的.wxml |
wuWxss.js | app-wxss.js里的setCssToHead | 各页面的.wxss |
wuWxs.js | app-service.js里的 wxs 模块 | .wxs文件 |
wuLib.js | — | 公共读写/解码函数,被上面几个调用 |
注意wuWxml.js和wuJs.js吃的是同一个输入文件。这意味着如果app-service.js本身没解出来,js 和 wxml 会一起失败,报错信息却可能只提其中一个。排查顺序永远是:先确认app-service.js存在且非空,再看它内部的模块定义形式。
3.4 怎么判断拆出来的东西「能不能读」
三分钟自检,比通读几千行代码有效:
# 1. 页面配置是否完整 python -c "import json;print(len(json.load(open('dist/app.json'))['pages']))" # 2. 切出来的 js 文件数量,够不够覆盖页面数 find dist -name "*.js" | wc -l # 3. 随便挑一个页面,看有没有 Page({...}) 或 Component({...}) grep -n "Page({" dist/pages/index/index.js | head第 3 条最关键。如果打开页面 js 看到的是一整段压缩过的、没有任何Page(字样的东西,说明模块切分失败了,这时候不要继续往下调样式,先回头解决切分问题。判断依据很简单:只要压缩器没有把Page这个全局调用重命名(一般不会),它一定在文件里。
4. 分包、插件与 app-service.js:把一个大文件拆成目录树
4.1 app-service.js 是还原工作的主战场
跑完完整流程之后,最容易出问题的产物就是app-service.js。它是一个把所有页面逻辑打在一起的巨型文件,内部结构大致是这样:
define("pages/index/index.js", function(require, module, exports, window, document, ...){ var app = getApp(); Page({ data: {}, onLoad: function(){ ... } }); }); define("utils/request.js", function(require, module, exports, ...){ ... }); require("pages/index/index.js");还原脚本做的事就是按define("模块名", function(...){ ... })这个模式把函数体切出来,按模块名落成文件,再把require的相对路径修好。所以还原失败的绝大多数原因,不是脚本写错了,而是实际包里的定义形式跟正则对不上:有的包被二次打包成 webpack 的__webpack_require__形式;有的包把模块名换成了纯数字 id;还有的包在define外面套了一层eval。
先看清楚再动手,比盲目换工具强:
# 看模块定义的实际形态(取前 5 条) grep -o 'define("[^"]*"' dist/app-service.js | head -5 # 统计模块数量,跟 app.json 里的页面数 + 组件数对一下 grep -c 'define(' dist/app-service.js # 如果上面两条都没输出,看看是不是被 eval 包了一层 head -c 200 dist/app-service.js4.2 自己写一个按花括号配平的切分脚本
正则直接匹配到}是不可靠的——函数体里字符串、模板串、注释都可能包含花括号。稳妥做法是走一遍字符扫描,做括号配平。下面这段跑在 Node 里,不依赖第三方库:
// split-service.js —— 把 app-service.js 里的 define(...) 按模块名写回目录 const fs = require('fs'); const path = require('path'); const src = fs.readFileSync(process.argv[2], 'utf8'); const outDir = process.argv[3] || 'service-out'; // 从 start(指向 '{')开始,返回与之配对的 '}' 的下标 function matchBrace(s, start) { let depth = 0, i = start, quote = null; for (; i < s.length; i++) { const c = s[i], prev = s[i - 1]; if (quote) { // 字符串内部,只找结束引号 if (c === quote && prev !== '\\') quote = null; continue; } if (c === '"' || c === "'" || c === '`') { quote = c; continue; } if (c === '/' && s[i + 1] === '/') { i = s.indexOf('\n', i); continue; } // 行注释 if (c === '{') depth++; else if (c === '}' && --depth === 0) return i; } return -1; } const re = /define\(\s*["']([^"']+)["']\s*,\s*function\s*\([^)]*\)\s*\{/g; let m, n = 0; while ((m = re.exec(src)) !== null) { const name = m[1]; const bodyStart = re.lastIndex - 1; // 指向 '{' const bodyEnd = matchBrace(src, bodyStart); // 指向 '}' if (bodyEnd < 0) break; const code = '// ' + name + '\n' + src.slice(bodyStart + 1, bodyEnd); const dst = path.join(outDir, name); fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.writeFileSync(dst, code, 'utf8'); n++; re.lastIndex = bodyEnd; // 跳过去,避免嵌套 define 重复匹配 } console.log('切出模块数:', n);几个点值得说。matchBrace里re.lastIndex = bodyEnd这句不能省,否则函数体内部如果还有define(嵌套模块很常见),会被重复切一遍,产出重复文件。prev !== '\\'处理的是转义引号,虽然简化了(没处理\\"这种双反斜杠场景),但实际包里的概率极低。模板串`按单字符处理,不解析${}里的花括号——这是已知局限,遇到含${}且里面有对象的模板串可能配平失败,表现是切出来的文件被截断,用node --check一跑就能发现。
模块名里的/直接当路径分隔符用了,这在 Linux/macOS 上没问题,Windows 上path.join会自动处理。切完之后补一步require路径检查:
# 找出所有相对路径 require,确认目标文件真的存在 grep -rho 'require("[^"]*")' service-out | sort -u | head -204.3 分包与插件的目录还原顺序
分包的处理不是把包解开就完事。subPackages里配置的root决定页面路径前缀,而分包包内部的文件名可能是不带前缀的。合并时的顺序我固定为三步:
- 主包完整跑一遍,产物落在
dist/; cp dist/app.json dist/app.json.bak,备份主包配置;- 每个分包单独跑,产物也落
dist/,跑完手动把dist/app.json.bak还原,再把分包里的页面路径按root前缀补进主包pages或subPackages。
插件包的路径完全不同,根目录是plugin.json而不是app.json,wuConfig.js对它基本无效,能拿到的就是拆开的 js 和 wxml。插件包里的页面通常在plugin/pages/或__plugin__/前缀下,app-service.js的模块名也会带这个前缀,切分脚本不用改。
4.4 wxss 里的 setCssToHead 与 rpx 占位符
样式还原失败的表现特别有辨识度:打开.wxss看到一堆[object Object]、NaN或者直接空文件。原因是编译后的样式被存成数组,rpx 单位被替换成了三元组:
setCssToHead([ ".banner{", ["height", 320, "rpx"], ";background:#fff}", ["width", 750, "rpx"], ])(...);还原逻辑就是把数组拼回字符串:字符串原样输出,数组按[属性名, 数值, 单位]拼成属性名:数值单位。这里有个必须注意的点——rpx 不要换算成 px。很多教程会让你按 750 设计稿除以 2,改完看着像能跑,实际上所有断点和媒体查询都错位了,而且原始数值信息也丢了。保留 rpx,让它在开发者工具里自己按设备宽度算。
手写这段转换时,判断条件要宽松一点:
const css = arr.map(item => { if (typeof item === 'string') return item; if (Array.isArray(item) && item.length >= 3) return `${item[0]}:${item[1]}${item[2]}`; return ''; // 其他形态(作用域标记、条件包裹)先跳过 }).join('');最后那个return ''是我踩过坑之后改的。早先版本直接String(item),结果数组里混进来的作用域控制对象被序列化成[object Object],整段样式全废,而且报错信息里根本看不出是哪一条。
5. 避坑与排查:五类翻车现场的现象、原因和解决
5.1 环境与依赖类故障
现象一:npm install跑完没报错,但执行node wuWxapkg.js xxx.wxapkg立刻抛TypeError: xxx is not a function,堆栈指向escodegen或js-beautify内部。
原因很明确:Node 版本太高,依赖树里装的还是老版本库,其内部调用的 API 在新 Node 上签名变了。解决方式是把版本切到 14/16,然后rm -rf node_modules package-lock.json重装一遍。注意必须删 lock 文件,只删node_modules会因为 lock 里锁死的解析结果装回同一套。
现象二:Error: Cannot find module 'css-tree',但node_modules目录明明存在。
原因通常是执行目录不对——在子目录里跑了脚本,Node 从当前目录往上找node_modules,找不到就报这个。解决方式是cd到含package.json的那一层再执行,或者用node /绝对路径/wuWxapkg.js配合NODE_PATH环境变量指向依赖目录。后者我一般不用,容易在下一次换机器时忘掉。
5.2 产物缺失类故障
现象三:命令跑完输出了「解包完成」,但输出目录里只有app-config.json和几个空目录,app-service.js是 0 字节。
原因是索引表里的偏移基准判断错了,也就是 2.2 节里那个BASE。脚本按BASE=0去切,切出来的内容对不上,后续步骤读到空文件就静默跳过了——这类失败最讨厌的地方在于它不抛异常。解决方式:用 2.2 的 Python 脚本带BASE=0跑一遍看首个文件开头,如果是乱码就换成14 + idx_len;也可以在 wxappUnpacker 的输出目录里找app-service.js,用xxd | head看前 16 字节,正常应该能看到define或var之类的 JS 关键字。
现象四:js 切出来了,但wxml目录是空的,或者每个文件里只有<view></view>这种空骨架。
原因是被拆的包用了较新的编译产物结构,模板函数被塞进了$gwx的一个巨型闭包里,而wuWxml.js匹配的还是老形态的字符串拼接。这种情况没有一劳永逸的解法,务实的三条路:一是看看有没有更新更活跃的分支;二是在app-service.js里grep -n "gwx"找到模板段,手工按$gwx('./pages/index/index.wxml')的调用点配合字符串常量拼一部分出来;三是接受现实,只靠app.json的页面结构和 js 里的data字段反推页面结构,工作量大但不会卡死。
5.3 内容错乱类故障
现象五:样式文件还原出来了,但尺寸全错,页面上所有元素挤成一团;或者中文文案变成测试这样的乱码。
尺寸错乱九成是 4.4 节里那个 rpx 三元组没拼对,检查输出里有没有残留的[object Object]或NaN就能定位。中文乱码则是编码问题:文件内容的解码用了latin1,或者文件名里带了%E4%B8%AD这种 URL 编码没做decodeURIComponent。修法是在读取和写文件两头各加一层decodeURIComponent,并把fs.readFileSync(p, 'utf8')统一成显式编码,不要依赖默认值。
还有一种更隐蔽的:文件名正常、内容正常,但页面之间跳转全 404。这是主包和分包解到同一目录后同名文件互相覆盖导致的,app.json里的pages还在,实际文件却被分包的版本顶掉了。解决方式就是 4.3 节说的分离目录,宁可多一步手工合并。
6. 还原质量的三条自检和一个 AST 去壳技巧
拆完之后怎么判断这份产物值不值得继续投入时间?我固定跑三条自检,缺一条都不往下走。
第一条是数量对齐:用 2.2 的脚本拿到索引表条目数,跟输出目录里的文件数比。差几个可以接受(脚本会跳过一些元信息文件),差几十个说明中途大面积失败。第二条是模块对齐:grep -c 'define('的结果要大于等于app.json里pages数组长度加组件数,小于就说明有模块没被切出来。第三条是路径对齐:把app.json的pages数组导出来,逐条拼成dist/{page}.js判断文件是否存在,缺的页面单独列出来。
python - <<'PY' import json, os cfg = json.load(open('dist/app.json', encoding='utf-8')) pages = cfg.get('pages', []) + [p for s in cfg.get('subPackages', []) for p in [s['root'] + '/' + x for x in s.get('pages', [])]] miss = [p for p in pages if not os.path.exists('dist/%s.js' % p)] print('页面总数=%d 缺失=%d' % (len(pages), len(miss))) print('\n'.join(miss[:10])) PY三条都过了,再谈去壳。前面提过,有些包的app-service.js外面套了一层eval(function(...){...}),正则切分会把整个 eval 当成一个模块。比正则稳的做法是走 AST:用esprima解析(它对这种老式语法兼容最好),遍历所有CallExpression,找callee.name === 'define',直接取第二个参数的range从原文切片。这样做的好处是完全不受字符串里的花括号干扰,缺点是遇到不合法语法会整体解析失败,所以用之前先node --check验一遍。
const esprima = require('esprima'); const fs = require('fs'); const src = fs.readFileSync('dist/app-service.js', 'utf8'); const ast = esprima.parseScript(src, { range: true, tolerant: true }); (function walk(node) { if (!node || typeof node.type !== 'string') return; if (node.type === 'CallExpression' && node.callee && node.callee.name === 'define' && node.arguments.length >= 2 && node.arguments[0].type === 'Literal') { const name = node.arguments[0].value; // 模块名 const [s, e] = node.arguments[1].range; // 函数体原文区间 console.log(name, e - s); // 这里换成写文件逻辑 } for (const k in node) { if (k === 'range' || k === 'loc') continue; const v = node[k]; Array.isArray(v) ? v.forEach(walk) : (v && typeof v === 'object' && walk(v)); } })(ast);tolerant: true是关键参数,它让解析器在遇到小瑕疵时继续往下走而不是整体抛错;代价是产出的树可能不完整,所以数量上要跟grep -c 'define('的结果交叉验证一遍,两个数字差得多就说明这份文件里有解析器吃不下的语法。这个技巧我后来也用在了组件Component({...})的属性提取上——data里的默认值、properties的类型声明,从 AST 里取比用眼睛翻快一个数量级。
从那以后,我每次拆完包都强制先跑那三条自检,哪怕只是看一眼页面结构。因为翻车最惨的一次不是拆不开,而是拆开了、看着有文件、以为齐了,写到一半才发现少了三个页面,前面两小时的分析全部作废。希望帮到你。
本文还有配套的精品资源,点击获取