简介wxappUnpacker 是一款面向微信小程序开发者与安全研究人员的反编译工具可将小程序主包与分包中的 WXML、WXSS、JavaScript 及 JSON 配置转换为可读源码便于理解页面结构、业务逻辑与数据组织方式也适用于代码优化、问题排查和安全审计等场景。压缩包共 780 个文件约 1.97MB以 JavaScript 源码、JSON 配置、Markdown 说明、TypeScript 与 source map 为主并包含许可证、命令行脚本及少量批处理文件覆盖工具运行、依赖说明和示例配置等用途。已有 270 人学习说明其在逆向分析入门与实务参考中有一定关注度。借助完整目录与配套脚本读者可对照反编译产物梳理小程序架构、组件用法、事件处理和网络请求实现并积累主分包解析与安全检测的排查思路。1. 拿到一个 .wxapkg 之后先搞清楚它是什么再谈反编译源码丢了、线上还在跑这种情况比想象中常见某公司交接时只留下一个发布包和几张截图需求方还要在原有逻辑上加功能。这时手里的 .wxapkg 不是黑匣子它就是一个没写目录的归档文件小程序反编译要做的就是把索引表和正文拆开。wxappUnpacker 属于这类工具里用法最直接的一个给它一个包它吐出 pages 目录、app.json、app-service.js 和 app.wxss能把「当时怎么写的」还原到七八成。它适合三类人接手遗留项目要读懂逻辑的、做安全审计要确认包里有没有多余权限声明的、以及自己早年发过包现在找不回源码的。边界也得先说清——它还原的是构建产物不是原始工程混淆过的 JS 拿回来只有可读性没有原名样式和模板还需要手工修指望一键出完整工程会失望。2. 拆开 .wxapkg头部结构、加密头与四个脚本的分工在命令行里敲 node 之前值得花十分钟把包的字节结构看一遍。原因很实在wxappUnpacker 的报错大多很含糊不是合法的wxapkg文件这一句背后可能是头部标记不对、可能是加密没解、也可能是索引表被截断分不清就只能反复换包试。看懂结构之后报错基本能自己定位到具体哪一段。2.1 从字节流看三段结构定长头、索引表、数据段一个 wxapkg 从头到尾只有三段18 字节的定长头、变长的文件索引表、以及所有文件内容拼起来的数据段。头里的关键字段是索引段长度和数据段长度索引表则是一条条「文件名 偏移 长度」的记录按文件个数重复。文件名是 UTF-8 编码的相对路径形如pages/index/index.js偏移一般是相对整个文件起点的绝对位置直接切片就能拿到内容。偏移长度字段说明01首字节标记明文包固定为 0xBE14保留位通常为 0读的时候跳过54索引段长度大端 32 位无符号整数94数据段长度大端 32 位无符号整数131分隔标记固定为 0xED144文件个数决定索引表循环次数用 Python 把索引表打出来比对着十六进制编辑器猜要快得多。下面这段脚本不依赖 wxappUnpacker纯粹用来验证包是否完整import struct def dump_index(path): with open(path, rb) as f: data f.read() if data[0] ! 0xBE: raise ValueError(首字节是 0x%02X不是明文包先解密 % data[0]) index_len, body_len struct.unpack(II, data[5:13]) if data[13] ! 0xED: raise ValueError(分隔标记不是 0xED索引偏移读错了) count struct.unpack(I, data[14:18])[0] pos 18 for _ in range(count): name_len struct.unpack(I, data[pos:pos 4])[0] pos 4 name data[pos:pos name_len].decode(utf-8) pos name_len off, size struct.unpack(II, data[pos:pos 8]) pos 8 print(%-48s off%-8d size%d % (name, off, size))参数和逻辑说明II表示大端读两个 32 位整数大小端写反会读出一个天文数字的长度然后直接 MemoryErrorname_len是字节数不是字符数路径里有中文时按字符切会错位循环里pos必须严格按「4 name_len 8」推进少加一次后面全乱。跑完这份清单你应该能看到app.json、app-service.js、app-config.json以及各个页面的四件套。还有一种变体要留意部分包里的 offset 是相对数据段起点的不是绝对位置。判断方法很简单——按绝对偏移切出来的第一个文件开头是乱码就说明要加上数据段起点18 index_len body_len 之前的偏移量再试。这类小差异是很多「解出来全是乱码」问题的根因。2.2 加密头 V1MMWX先解密再交给反编译器PC 端缓存下来的包首字节往往不是 0xBE而是 ASCII 的V1MMWX。这说明整个包被对称加密过直接丢给 wxappUnpacker 会得到「不是合法的 wxapkg 文件」。它的密钥不是随机串而是由小程序 appid 通过 PBKDF2 派生盐和迭代次数固定IV 也是写死的 16 字节字符串。换句话说只要手上有这个包对应的 appid解密过程完全可复现。import hashlib from Crypto.Cipher import AES def decrypt_v1mmwx(src, dst, appid): with open(src, rb) as f: raw f.read() if raw[:6] ! bV1MMWX: raise ValueError(头部不是 V1MMWX可能本身就是明文包) # PBKDF2 派生出 32 字节取前 16 字节作为 AES-128 的 key key hashlib.pbkdf2_hmac(sha1, appid.encode(), bsaltiest, 1000, 32)[:16] iv bthe iv: 16 bytes plain AES.new(key, AES.MODE_CBC, iv).decrypt(raw[6:]) # 去掉 PKCS#7 填充最后一个字节就是填充长度 pad plain[-1] if 0 pad 16: plain plain[:-pad] with open(dst, wb) as f: f.write(plain) decrypt_v1mmwx(__APP__.wxapkg, __APP__.plain.wxapkg, wx占位替换成实际appid)参数说明appid决定了密钥填错不会报错只会解出一堆看起来像随机数的字节所以解完第一时间还是拿 2.1 的脚本验首字节是不是 0xBE。raw[6:]是从第 6 字节开始切前 6 字节是标记不是密文。pad的判断要有边界某些包解密后末尾没有标准填充强行按填充长度裁剪会啃掉正文最后几个字节表现就是最后一个 JS 文件语法错误。依赖是 pycryptodome装不上就换环境别在这上面耗。提示解密这一步只在你对自己有权限处理的包上做——自己项目的包、公司交接过来的包、书面授权的审计对象。别人的线上包解出来看逻辑属于另一码事。2.3 四个脚本各管一段别指望一个命令干完所有事wxappUnpacker 不是一个单体程序而是一组分层的脚本。wuWxapkg.js是入口负责读包、拆文件、按扩展名把活儿分派出去wuJs.js处理app-service.js和页面 JSwuWxml.js处理 WXMLwuWxss.js处理 WXSSwuConfig.js负责把 JSON 配置里缺的字段补齐。理解这层分工你才知道报错该看哪个文件。脚本输入输出典型失败表现wuWxapkg.js.wxapkg目录骨架、分包合并报「非法的wxapkg」多为头部或偏移问题wuJs.jsapp-service.js按模块切分的 JS卡死、OOM多为单文件过大wuWxml.js.wxml展开后的模板标签不闭合、wx:for 变量名统一成 e/twuWxss.js.wxss可读样式import 路径断链公共样式丢失wuConfig.jsapp.json 等补全后的配置页面路径与实际目录对不上其中 wuJs.js 有个容易被忽略的设计它会用 vm2 起一个沙箱去执行部分运行时代码把运行时拼接出来的字符串常量还原出来。这也是它对 Node 版本敏感的原因——vm2 在高版本 Node 上有兼容性变化报的错还往往和真正的原因不挨着。所以别急着升级环境反过来降版本更省时间。3. 跑通一次完整反编译环境、命令与分包顺序真正卡住新手的通常不是原理而是环境和顺序。我见过太多次「装完依赖跑第一条命令就崩」最后发现是 Node 版本太新或者把分包先于主包跑了。3.1 把 Node 版本钉住再谈依赖这个工具链的依赖里有 esprima、uglify-es、css-tree 这类库其中一部分已经停止维护。在 Node 18 以上跑常见的是ERR_REQUIRE_ESM、crypto.createCipher is not a function这类和业务毫无关系的报错。稳妥做法是单独装一个 Node 14 的运行时用版本管理器切换不要动系统默认环境。# 安装并用 14.x 一类的旧版本运行时避免依赖的兼容问题 nvm install 14 nvm use 14 node -v # 进入工具目录装依赖npm 源慢的话换成你常用的源 cd wxappUnpacker npm install # 先看一遍参数不同分支的参数拼写有差异 node wuWxapkg.js -h逻辑说明nvm use只影响当前 shell换窗口就失效所以每次开新终端先node -v确认一下这是最省事的一道保险。npm install如果卡在某个包上先看它是不是在编译原生模块多数情况下是网络问题而不是代码问题。-h这一步别省社区里有多个分支版本-s的含义在不同分支里不完全一致先看帮助再抄命令。3.2 主包先解分包后解-s指向前者一个稍微像样的小程序都有分包。主包是__APP__.wxapkg分包是subpackage_xxx.wxapkg之类的名字。顺序反了分包里引用主包的公共模块和app.wxss就找不到反编译出来的页面打开是白屏或者在控制台里报模块未定义。mkdir -p out # 第一步解主包产物落在 out/ node wuWxapkg.js -o ./out ./__APP__.wxapkg # 第二步解分包-s 指向主包产物目录让脚本知道公共模块在哪 node wuWxapkg.js -o ./out -s./out ./subpackage_index.wxapkg # 第三步检查分包页面是否真的合并进来了 find out -name *.wxml | sort | head -30参数说明-o是输出目录建议每次换一个干净的目录别在旧产物上覆盖否则新旧文件混在一起排查问题时分不清哪个是这次解出来的。-s./out里的等号在部分分支可以省略写上更稳。第三步的find是低成本的验收手段如果分包页面一个都没出现说明-s没生效或者分包包名传错了回到上一步重新跑不要继续往下做修补——地基歪了后面全是白工。3.3 输出目录里哪些东西可信哪些只能参考反编译产物不是等价的可信度分层很明显。把这张表记在脑子里能省掉大量「为什么改了没效果」的困惑。产物可信度说明app.json / 页面 json高结构基本完整个别自定义字段被裁掉wxss高样式规则基本原样只有 import 链会断wxml中结构在属性顺序乱循环变量名被统一页面 js中低逻辑可读变量名全丢函数顺序可能变app-service.js低全部逻辑打成一坨需要手工切可信度低的不代表没用。app-service.js虽然是一整块但里面的模块边界是清晰的按define(和require(的调用关系切一刀就能拆回单文件这一步在第 4 章展开。真正没法还原的是原始变量名、注释和 sourcemap这三样在构建时就被丢掉了任何工具都变不回来。4. 还原质量的二次加工WXML、WXSS、JS 三层分别怎么修反编译跑通只是把手里的包变成了「能看的文本」离「能改、能跑」还差一层手工。这一层的投入产出比很高因为问题类型高度集中修几十个文件基本就是重复几类操作。4.1 WXML先格式化再逐个页面核对结构WXML 还原后的典型症状是缩进全乱、自闭合标签写成image src...没有斜杠、wx:for的 item 和 index 被统一成了e和t。前两个是格式问题机器能修第三个是语义问题只能人工确认。# 用 prettier 统一缩进和换行parser 用 htmlwxml 语法基本兼容 npm i -g prettier prettier --write out/**/*.wxml --parser html # 格式化前后留个备份方便 diff 出被误改的地方逻辑说明prettier 用 html 解析器处理 wxml 有两个副作用一是{{ }}里的表达式在某些嵌套场景会被换行打断二是block标签可能被当成未知标签保留原样。所以格式化之后一定要用git diff或者diff -r过一遍重点看表达式有没有被拆行。这一步我一般放在独立分支上做确认没问题再合并。循环变量名统一的问题修法是按页面逐个打开对照。wx:for里如果原代码用的是item反编译后变成e而模板内部又有多层嵌套循环就会出现e被内层覆盖的情况表现是列表渲染出重复数据。判断依据是看wx:for-item属性还在不在在的话直接按属性名改名不在的话看内部引用该变量的字段名反推语义比如e.detailUrl大面积出现那这个e大概率就是item。这类反推没有捷径但一个页面通常十来分钟能理顺。wxs文件也是容易漏的地方。它被内联进 wxml 后会变成一段wxs modulexxx的脚本块逻辑本身完整但模块名可能被改。检查方法是搜module找到所有引用点确认调用处和定义处的名字一致。4.2 WXSS把断掉的 import 链接回去样式层的还原度最高问题几乎只有一个import的相对路径。反编译后公共样式一般落在app.wxss或common/下而页面样式里的import ../../common/base.wxss;可能指向一个不存在的路径微信开发者工具会直接报编译错误。import os, re IMPORT_RE re.compile(rimport\s[\]([^\])[\]\s*;) def fix_imports(root): for dirpath, _, files in os.walk(root): for fn in files: if not fn.endswith(.wxss): continue fp os.path.join(dirpath, fn) with open(fp, encodingutf-8) as f: text f.read() def repl(m): target os.path.normpath(os.path.join(dirpath, m.group(1))) if os.path.exists(target): return m.group(0) # 路径断了就退化成同级查找多数公共样式都在 common 下 guess os.path.join(root, common, os.path.basename(m.group(1))) return import %s; % os.path.relpath(guess, dirpath) if os.path.exists(guess) else m.group(0) new IMPORT_RE.sub(repl, text) if new ! text: with open(fp, w, encodingutf-8) as f: f.write(new)逻辑说明IMPORT_RE只匹配标准的import 路径;写法带url()的变体要另外加规则。os.path.normpath把../折叠掉再判断存在性这一步是关键直接拿原始字符串去exists会因为没规范化而误判。回退策略是去common目录按文件名找这个约定在多数项目里成立找不到就原样保留让人工兜底。跑完之后重新用开发者工具打开编译错误数量应该会明显下降。rpx不需要转换保留原样即可它是运行时单位改成 px 反而会让适配错乱。唯一要注意的是反编译出来的数值可能有精度损失比如13.33333rpx被截成13rpx视觉上一般看不出来但做像素级还原的话要留意。4.3 JS把 app-service.js 按模块边界切开app-service.js是整包逻辑的合集几千行起步全在里面用define(路径, function(require, module, exports){...})的写法注册模块。直接读当然能读但改起来痛苦。切分的原则很简单找define(的起始位置按括号配对找到对应的结束位置取出中间的模块体写到索引表里记录的原始路径去。const fs require(fs); const path require(path); const src fs.readFileSync(out/app-service.js, utf8); const outRoot out; // 匹配 define(模块路径, function(...){ 的开头模块路径作为分组依据 const headRe /define\(\s*[]([^])[]\s*,\s*function\s*\(/g; let m, marks []; while ((m headRe.exec(src)) ! null) { marks.push({ file: m[1], start: m.index, bodyStart: headRe.lastIndex }); } marks.forEach((mark, i) { const end i 1 marks.length ? marks[i 1].start : src.length; const chunk src.slice(mark.bodyStart, end); // 去掉尾部多余的 });只保留函数体 const body chunk.replace(/\}\s*\)\s*;?\s*$/, }); const target path.join(outRoot, mark.file.endsWith(.js) ? mark.file : mark.file .js); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, // 反编译还原变量名已丢失\n body, utf8); });逻辑说明headRe用全局匹配把所有模块的起始位置收集起来每个模块的结束位置就是下一个模块的起始位置最后一个是文件末尾。body的正则用来啃掉尾部闭合的});这一步不能省否则每个文件末尾多一对括号语法检查直接失败。写文件前先mkdirSync建目录因为模块路径是pages/index/index.js这种带层级的。切完之后原来的app-service.js不要删留着对照——有些模块的注册顺序和依赖关系会影响执行出问题时需要回头看原始顺序。至于变量名a、b、c这种没法自动还原。我的习惯是按调用点反推一个函数被this.setData({ list: ... })调用里面的参数大概率就是列表数据手动改成list并在文件头留一行注释说明原名下次再看就省事。这一步纯粹是给未来的自己省时间不急的话可以只改被改动的那几个函数。4.4 补 project.config.json让开发者工具能打开光有 pages 目录微信开发者工具是打不开的它需要一个project.config.json。这个文件反编译不出来得自己写一个关键是appid和miniprogramRoot两个字段。{ miniprogramRoot: ./, projectname: restore-demo, appid: touristappid, setting: { urlCheck: false, es6: false, postcss: false, minified: false }, compileType: miniprogram, libVersion: 2.19.4 }参数说明appid填touristappid用游客模式打开能编译能预览但不能真机调试和上传如果你有对应小程序的开发者权限填真实 appid 更省事。es6设成 false 是因为反编译出来的代码已经是编译后的产物再走一遍转译容易出幺蛾子。urlCheck关掉否则本地调试时所有网络请求都会被拦。libVersion填一个开发者工具支持的版本号不要乱写高版本基础库不匹配会报一堆找不到 API 的错这个字段是最常见的「打开就红一片」的原因。5. 五个让我返工的坑现象、原因与解法这一节全是血泪经验按「现象 → 原因 → 解决」写。每一条都是我实际踩过的遇到同类问题可以直接对号入座。5.1 依赖装完运行报 Cannot find module现象npm install显示成功一跑node wuWxapkg.js就报Cannot find module esprima或者vm2重新装一遍还是一样。原因多数是当前 shell 的 Node 版本和装依赖时用的版本不是同一个nvm切换后node_modules里的原生模块对不上另一种情况是在工具目录外执行了命令Node 从当前目录往上找node_modules自然找不到。解决先node -v确认版本再pwd确认在工具根目录两个都对还报错就删掉node_modules和 lock 文件重装。如果只有个别模块找不到单独npm i 模块名补上比整体重装快。5.2 输出目录建了但里面一个文件都没有现象命令没报错退出码是 0out/目录也建出来了但里面空空如也或者只有 app.json。原因路径里有空格或中文脚本内部拼接路径时被截断或者包本身是加密的读出来的首字节不是 0xBE脚本静默跳过了所有文件。解决把包和输出目录都挪到纯英文、无空格的短路径下再跑比如/tmp/restore/。同时用 2.1 的脚本验一下首字节是V1MMWX就先解密。这一步排查顺序别反先验包再验路径能省一半时间。5.3 分包页面全是空白主包页面正常现象主包页面能打开点进分包页面白屏控制台报某个模块 undefined。原因分包在主包之前解了或者-s参数指错了目录。分包里的页面依赖主包的公共模块反编译时找不到就会漏掉引用。解决清空输出目录严格按主包、分包的顺序重跑-s指向主包的输出目录而不是包文件所在目录。跑完用find out -name *.wxml数一下页面数量和app.json里subpackages声明的页面对一遍数量对上基本就没问题。5.4 反编译大包时卡死或者内存溢出现象进度条卡在某个 JS 文件不动或者直接JavaScript heap out of memory。原因app-service.js体积过大工具内部会把它整个读进内存再跑 AST 解析几 MB 的文件加上 AST 节点默认堆内存不够用。解决先加内存node --max-old-space-size4096 wuWxapkg.js ...这是最直接的一招。还不行就手工把app-service.js用 4.3 的脚本先切碎跳过wuJs.js对它的处理页面 JS 单独跑。牺牲一点自动化换回可执行性。5.5 反编译出的 WXML 在开发者工具里报标签未闭合现象编译报错指向某个 wxml 文件提示标签不匹配肉眼看代码又是闭合的。原因prettier 格式化时把block或者自定义组件标签当成了自闭合标签处理或者把{{ }}里的内容换行拆断导致解析器认为标签结构破坏。解决先git checkout回到格式化前的版本用prettier --check定位具体是哪个文件被改坏然后只对这个文件手工调整缩进。如果项目里大量使用自定义组件直接放弃全量格式化改成按需格式化新改的文件别一次性动全仓。6. 验收与批量处理怎么判断还原结果没有跑偏反编译做完最危险的状态是「看起来能跑但逻辑和线上不一致」。这种偏差不会报错只会在某个边界条件下暴露等用户反馈就晚了。我一般用两轮验收把偏差压下去。第一轮是结构验收机械比对十分钟能做完。打开app.json把pages和subpackages里的路径全部列出来逐个确认对应目录下的四件套js、json、wxml、wxss是否齐全缺哪个补哪个。页面的navigationBarTitleText这类配置是用户直接可见的和线上截图对一遍能顺带发现页面对应关系搞错的问题。第二轮是行为验收按页面走关键路径。验收项做法判定标准页面路由从首页点进每个一级入口路径与 app.json 一致无 404数据渲染对照线上截图看列表、详情字段名对得上无 undefined交互事件逐个点按钮看 bindtap 是否触发控制台无未定义函数报错网络请求看 request 的 url 和参数结构参数名与后端约定一致权限声明检查 app.json 的 permission 字段没有多出来的授权项表格里最后一项是安全审计场景的重点。反编译出来的app.json会保留permission、requiredPrivateInfos这类声明如果你的目的是确认包里有没有申请多余权限就看这一处比读 JS 快得多。批量处理这块我的习惯是写一个总入口脚本把「解密 → 解主包 → 解分包 → 切 JS → 修 import」串起来每个阶段失败就停产物按时间戳分目录留档。这样同一批包换台机器也能一次跑完不用回忆上次敲了什么命令。#!/usr/bin/env bash set -euo pipefail PKG_DIR$1 # 存放 wxapkg 的目录 STAMP$(date %Y%m%d%H%M) OUTrestore_${STAMP} mkdir -p $OUT for f in $PKG_DIR/*.wxapkg; do base$(basename $f .wxapkg) # 主包优先分包带上 -s 指向主包产物 if [ $base __APP__ ]; then node wuWxapkg.js -o $OUT $f else node wuWxapkg.js -o $OUT -s$OUT $f fi echo [ok] $base done # 阶段产物留档出问题能回滚到某一步 tar -czf ${OUT}.tar.gz $OUTset -euo pipefail是这段脚本里最重要的三个词任何一步失败立刻停不会带着半成品往下跑变量未定义直接报错避免空路径把文件写到奇怪的地方。按时间戳分目录是为了留后悔药某一轮改坏了能直接对比上一轮。最后的打包不是必须的但如果要发给同事或者存档比散落的目录好用。最后说个教训。我早期做这类还原习惯一口气解完就开始改代码改到一半发现某个页面根本解错了前面的修补全废。从那以后我每次解完包第一件事是把原始产物单独打一个 tar 包存着所有修改都在副本上做任何一步觉得不对就整目录回滚重来绝不试图在坏地上盖楼。这个习惯救过我很多次也建议你从第一个包开始就这么做。希望帮到你。本文还有配套的精品资源点击获取