☰
PC微信小程序wxapkg解密实战:从V1MMWX文件头到AES-CBC还原
2026/10/31 10:13:03 网站建设 项目流程

简介:面向需要分析PC端微信小程序包的研究者与开发者,这份5KB的轻量工具包提供了一套基于Python的wxapkg解密脚本,解决PC微信2.9.5.41版本下小程序加密包无法直接读取的问题。压缩包内共6个文件,核心为main.py脚本,并配有README.md使用说明、LICENSE授权文件、.gitignore配置及两个txt资源标签/内容清单,便于快速上手与合规使用。目前已有172人学习下载,适合有一定Python基础、希望逆向分析小程序前端逻辑或做安全测试的读者。通过该工具,可完成从定位Applet目录、获取微信APPID到执行解密并输出可解析wxapkg文件的全流程,脚本参数覆盖wxid、iv、salt等关键项,且描述中给出了版本失效时自行替换参数的自救思路,实用性较强。

1. 拿到 PC 微信小程序 wxapkg 加密包:先搞清楚你要解的是什么

从 PC 版微信的缓存目录里翻出几个 .wxapkg 文件,用十六进制编辑器一看,文件头不是预期的wxapkg而是V1MMWX——这就是 PC 端加密存放的小程序包。标题里那种「PC微信小程序wxapkg解密.zip」,装的基本就是解密脚本、密钥表和配套的解包工具。这类包能解决什么问题:把微信从服务器拉下来并加密落盘的 wxapkg 还原成可读的编译产物,用于小程序竞品分析、安全审计、或者参考别人页面的实现思路。适合小程序开发进阶者、逆向分析入门者和做自动化抓取页面结构的人。需要先说清楚边界:解密还原出来的是编译压缩后的 JS 与模板,不是开发者手里的原始源码,后面所有分析都基于编译产物展开。

2. wxapkg 加密结构拆解:V1MMWX 文件头、salt 区与密钥的三个来源

2.1 先认识 wxapkg 的原始形态与落盘后的加密形态

微信开发者工具在构建小程序时,会把 wxml、wxss、js、json 统一编译打包成一个后缀为 .wxapkg 的二进制文件。明文 wxapkg 的内部结构由三部分组成:6 字节文件头标识、文件索引表和按索引偏移排列的文件数据块。文件索引表里每一项记录文件名长度、文件名、数据偏移量和数据长度,所以解包时只要按表切分就能还原出一个个独立文件。

但 PC 微信从小程序服务器拉取包之后,并不会把明文直接写进磁盘,而是先做一次 AES 加密再落盘。加密后的文件头被替换成 6 字节的V1MMWX,紧接着是 1024 字节的 salt 区,其中前 4 字节以小端序记录了原始明文包的总长度,后面 1020 字节是参与密钥派生的随机盐;再往后才是真正的 AES 密文主体。理解这个结构对解密的帮助在于:你不需要猜密钥派生流程,只需要从固定偏移取出 salt、从第 6~10 字节取出真实长度,然后用候选密钥去做 CBC 解密,拿「解密结果是否仍是合法 wxapkg 结构」来校验密钥对不对。

拿到一个待解文件时,第一步永远是看前 6 个字节。如果开头是V1MMWX,那就走 PC 端解密流程;如果直接能看到wxapkg或者一长串可打印字符,说明文件本身没加密,直接进解包环节;如果前 6 字节是别的什么!&&&之类的杂散内容,那它多半是图片或音视频缓存,不是小程序包。用 xxd 或者 Python 读文件头,几十秒就能完成初判。

# 查看前 32 字节,判断文件类型 xxd -l 32 path/to/xxx.wxapkg

注意输出里第一行开头是否为5631 4d4d 5758,这对应 ASCII 字符V1MMWX。如果是,继续往下读密文段;如果不是,先别浪费时间去跑解密脚本。这个命令的价值在于把「看着像 wxapkg」和「真是 wxapkg」区分开,避免后续所有步骤建立在错误前提上。

2.2 密钥从哪来:固定密钥、内存提取与版本密钥表

解密绕不开密钥。社区里常见的做法分三条路线,适用场景完全不同,踩坑也大多发生在选错路线上。

路线一是固定密钥。2018 到 2019 年前后的 PC 微信版本里,小程序包的加密密钥是客户端写死的一组 32 字节数据,网上流传的早期解密脚本里基本都内置了这串 hex。老版本包的解密就这么简单:取 salt,定 IV,一把钥匙开到底。但微信后续版本逐步收紧了这种方式,固定密钥的适用版本范围有限,拿老 key 去解新包必然乱码。

路线二是内存提取。较新版本的 PC 微信在运行时,密钥存在于 WeChatAppEx.exe 进程堆里。常见做法是先用任务管理器 dump 指定进程的内存镜像,然后在镜像里搜索符合密钥长度特征的候选字节序列,逐个拿去解目标 wxapkg。这个方案的优点是不依赖版本号,缺点是需要一点内存分析经验,而且每次微信更新后进程结构可能调整,提取位置要重新找。

路线三是查社区维护的版本密钥表。有些技术仓库会按 PC 微信版本号整理对应的密钥和加密参数变更记录,使用前先确认自己电脑上的微信版本,再按版本号去表里取密钥。这种做法最省事,但依赖社区更新速度,新版本刚发布时往往查不到。

密钥来源适用场景操作成本稳定性
固定密钥2019 年前后的老包低稳定,但只覆盖老版本
内存提取较新版本、无现成密钥表中随版本更新需重新提取
版本密钥表社区已收录的版本低依赖维护速度

三者的共同点是算法都是 AES-CBC,差异主要在密钥长度和 IV 的取法上。不同脚本里 IV 有取 salt 前 16 字节的,有取 salt 后 16 字节的,我看到更多实现是取 salt 末尾 16 字节。如果手头脚本解出来乱码,先别怀疑算法,把 IV 取法换一下再试,这个细节能救回不少包。

3. 用 Python 解密 wxapkg 并解包:最小命令、参数说明与产物对照

3.1 定位 PC 微信的小程序缓存目录

PC 微信的小程序缓存固定在用户文档目录下,路径通常是C:\Users\<用户名>\Documents\WeChat Files\Applet\。这个目录下是按 appid 编码出来的哈希文件夹,每个文件夹里再按版本号分子目录,子目录里放着一个或多个 .wxapkg 文件。主包和分包共用一个 appid 目录,但分包有时会单独放在以__开头的子目录里。

实际操作里,我不会靠肉眼翻目录,直接用命令行按修改时间排序找最新文件。微信只在打开小程序时拉取或更新包,所以最近修改时间最接近当下时刻的 wxapkg,就是当前正在用的版本。

# 找到最近修改的 5 个 wxapkg 文件 find "/c/Users/$USER/Documents/WeChat Files/Applet" \ -name "*.wxapkg" -printf "%T@ %p\n" \ | sort -rn | head -5 | cut -d' ' -f2-

这个命令的逻辑是先输出「修改时间戳 + 文件路径」,按时间戳倒序取前 5 条,再裁掉时间戳只留路径。注意如果你把微信文件迁移到了其他盘,把WeChat Files的实际位置替换进去即可。拿到路径列表后,建议顺手用ls -lh看一下每个文件大小,主包通常在几百 KB 到 2MB 之间,分包更小,几十到几百 KB。

3.2 解密脚本:AES-CBC 参数、IV 取法与长度校验

拿到加密 wxapkg 后,用下面这段 Python 脚本解密。脚本做了三件事:校验V1MMWX文件头、从 salt 区提取真实长度和 IV、用候选密钥依次尝试三种常见密钥长度解密。

import struct, sys from Crypto.Cipher import AES def decrypt_wxapkg(enc_path, key_hex, out_path): enc = open(enc_path, 'rb').read() # 1. 文件头校验,不是 V1MMWX 直接终止 if enc[:6] != b'V1MMWX': raise SystemExit(f'{enc_path} 不是 PC 端加密 wxapkg,请先确认文件类型') # 2. salt 区前 4 字节是小端序的真实明文长度 real_len = struct.unpack('<I', enc[6:10])[0] salt = enc[6:1030] # 3. 常见实现取 salt 末尾 16 字节作为 CBC 的 IV iv = salt[-16:] body = enc[1030:] key = bytes.fromhex(key_hex) # 4. 依次尝试 AES-128 / AES-192 / AES-256 for key_len in (16, 24, 32): try: cipher = AES.new(key[:key_len], AES.MODE_CBC, iv) plain = cipher.decrypt(body) # 5. 长度对得上,且末尾 padding 合法,判定为解密成功 if plain[-1] <= 16 and len(plain) >= real_len: open(out_path, 'wb').write(plain[:real_len]) print(f'解密成功,使用 AES-{key_len * 8},输出 {out_path}') return except ValueError: continue raise SystemExit('解密失败:密钥长度或 IV 取法不匹配,请核对版本密钥')

参数说明:key_hex是从密钥表或内存提取工具拿到的十六进制字符串,传参时不需要带0x前缀;脚本会自动截取前 16 / 24 / 32 字节去匹配不同 AES 变体。real_len的存在是为了裁掉 PKCS7 padding 的补位字节,否则解密结果尾部会多出一段\x0e之类的填充。运行方式如下:

python decrypt_wxapkg.py path/to/encrypted.wxapkg 0123456789abcdef... -o plain.wxapkg

一个实用技巧:把plain[:real_len]再写一份到 .bin 文件里,然后在十六进制编辑器里看开头 6 字节。如果看到的是wxapkg或00 00 00 00这类正常包结构,说明密钥选对了;如果全是一眼假的乱码,直接切到另一套密钥重跑,不要浪费时间分析。

3.3 解包:把明文 wxapkg 拆回页面、逻辑与配置

解密成功的明文 wxapkg 仍然是一整个二进制包,需要再走一步解包才能得到可读文件。这一步有两个常用工具:老牌的 wxappUnpacker 和较新的 unveilr。两者的原理一致——解析文件索引表,按偏移切出每个文件并写盘。wxappUnpacker 是 Node 脚本,命令格式很直观:

# 安装依赖并执行解包 npm install -g wxappUnpacker node /path/to/wxappUnpacker/wuWxapkg.js plain.wxapkg

解包完成后,会在 wxapkg 同目录生成一个与包同名的文件夹,里面是还原出的目录结构。正常的主包至少包含以下三类产物:appservice.js是逻辑层全部 JS 的合并压缩产物,page-frame.html是视图层渲染脚本和模板配置,app.json是全局路由与窗口配置。pages 目录下是按页面拆分的 js、wxml、wxss 和 json。下面这张表对照了产物和它们对应的分析价值。

产物文件内容说明分析价值
appservice.js逻辑层全部页面与公共代码合并压缩最高,业务逻辑、接口调用都在这
page-frame.htmlWXML 编译函数、事件绑定映射页面结构与事件绑定关系
app.json页面路由、tabBar、窗口外观配置快速了解小程序功能模块划分
pages/xxx.js按页面拆分的逻辑代码单页逻辑,方便定向阅读

注意一个常见认知偏差:解包不等于还原源码。产物里的 JS 是 ES6 转 ES5 加压缩混淆后的结果,函数名、变量名全部被改写,wxml 也被编译成 render 函数。它能让你看懂逻辑框架和数据流,但别指望看到开发者写的原始代码。

4. 解密结果怎么读:从 appservice.js 里反向定位业务代码

4.1 先认三个产物:appservice.js、page-frame.html 与 app.json

拿到解包目录后,我习惯按 app.json → appservice.js → page-frame.html 的顺序展开分析。app.json 的 pages 字段列出所有页面路径,这个列表就是小程序的地图,能直接告诉你它有哪些功能模块。带着模块列表再去看 appservice.js,才不会在动辄上百万字符的压缩代码里迷路。

appservice.js 是所有页面逻辑被压缩进一个文件的产物,实际结构大多长这样:顶部是模块注册表,之后是__webpack_require__风格的模块加载函数,再往后按模块 ID 排列的匿名函数。每个匿名函数体对应一个业务模块,函数内Page({...})或App({...})的调用位置就是页面逻辑入口。page-frame.html 和逻辑层的关系是:它只描述视图怎么渲染、事件怎么绑定,不含请求逻辑,所以业务分析的主战场仍然在 appservice.js。

定位页面入口的常见做法是搜索Page(关键字,然后在附近找data:、onLoad:、onShow:这些生命周期方法。每找到一处,结合 app.json 的 pages 列表对应到具体的页面文件。这一步做完,你基本能画出这个小程序的页面—逻辑映射关系。

4.2 把整包 JS 拆小:按模块边界切分与字符串搜索

直接读几兆的 appservice.js 非常痛苦,不可持续。我一般先把包按模块边界切成上百个小文件,再逐个阅读。模块边界可以从数字:function(module,exports,__webpack_require__)这种代码模式去切,不同压缩器产出的边界写法略有差异,但万变不离其宗——每个模块都是 key-value 形式注册的。

import re, os, sys code = open(sys.argv[1], encoding='utf-8').read() os.makedirs('split', exist_ok=True) # 匹配模块注册边界,捕获模块 ID pattern = re.compile( r'^\s*(\d+):function\((module|e|t),\s*(exports|e|t|n),\s*__webpack_require__\)', re.M ) matches = list(pattern.finditer(code)) if len(matches) < 2: raise SystemExit('未识别到模块边界,请检查压缩器产物特征') for i, m in enumerate(matches): end = matches[i + 1].start() if i + 1 < len(matches) else len(code) # 每个模块独立存成一个文件 with open(f'split/module_{m.group(1)}.js', 'w', encoding='utf-8') as f: f.write(code[m.start():end]) print(f'切分完成,共 {len(matches)} 个模块,输出到 ./split/')

这段脚本的匹配正则有两个需要现场调整的地方:一是function关键字后面参数列表的写法可能不同,压缩器可能把module简写成e或t;二是模块 ID 不一定是纯数字,个别产物使用字符串 ID。脚本会把无法识别边界的文件原样忽略,不会破坏原包。跑完以后去 split 目录里按文件大小排序,最大的那几个往往就是核心业务模块,直接打开看。

拆完之后配合字符串搜索效率更高。先列出所有外联地址、再按域名反向找请求构造代码:

# 提取所有 http(s) 链接并去重 grep -oE 'https?://[^" ]+' split/*.js | sort -u # 在拆出的模块里搜特定业务关键词 grep -rn "userInfo\|orderList\|getBalance" split/ | head -20

字符串搜索的意义在于绕过混淆。变量名可以改,但接口路径、业务关键词、上报字段这些字符串常量改不了,它们是压缩产物里的锚点。比如你想搞明白某个列表加载更多的分页逻辑,直接搜pageSize、hasMore、pageNum这类参数名,命中后顺着调用链往回查,比从入口逐行捋代码快一个数量级。

5. wxapkg 解密高频翻车现场:文件头误判、乱码与分包丢失的排查

5.1 缓存目录里一堆 .dat,把图片缓存当成了小程序包

现象:从缓存目录搜*.wxapkg搜出来一批文件,丢进解密脚本却提示文件头不是V1MMWX,或者解出来的东西完全不可读。

原因:PC 微信在 Applet 目录旁边混存了大量 .dat 结尾的资源文件,这些其实是聊天图片、头像、表情包的加密缓存,只是碰巧后缀或命名规律看着像小程序包。更迷惑的是有些 .dat 文件同样以非文本二进制开头,很容易被脚本误收。

解决:按文件头过滤,而不是按后缀过滤。在查找命令里加一步判断——凡是前 6 字节不是V1MMWX的文件一律跳过。用 Python 写个批量过滤脚本,遍历目录读每个文件前 6 字节再决定是否进入解密流程,几十行就够,能过滤掉九成误报。

5.2 密钥版本不对,解出来全是乱码

现象:解密脚本跑完没有报错,但输出的 .wxapkg 文件开头是一串乱码,或 real_len 与文件实际长度差出十万八千里。

原因:这基本是密钥用错,没有第二种常见解释。PC 微信更新版本后密钥会轮换,部分版本连 IV 取法都会变。网上流传的固定密钥只是某个历史版本的产物,拿 2019 年的 key 去解 2023 年的包,AES 解出来自然全是噪声。

解决:先把微信版本号确认清楚,再找对应版本的密钥表。如果密钥表里没有这个版本,转去走内存提取路线。有一个省钱技巧:解密脚本里把plain[-1] <= 16这个 padding 校验打开,如果实跑时大量包在这个条件附近波动,说明密钥基本对了,只是 IV 取法不同,调一下 salt 偏移就好。

5.3 主包解了,分包却一片空白

现象:解包输出目录里只有主包的 appservice.js 和首页文件,app.json 里明明列了十几个页面,pages 目录下却找不到对应文件。

原因:小程序的 pages 可以拆成主包和分包两套 wxapkg 存储。主包只放公共代码和 tabBar 页面,分包页面单独打包成独立的 wxapkg 文件,放在 appid 目录的子文件夹里。只解主包自然什么都缺。

解决:把 appid 目录下所有 .wxapkg 全部解出来,再按工具要求做目录合并。wxappUnpacker 对分包的处理是生成独立的输出目录,手动把分包目录并进主包目录即可。注意分包密钥和主包是同一套,不需要重新从内存里提取。

5.4 格式化后仍然读不懂压缩产物

现象:用 prettier 或 js-beautify 格式化 appservice.js 之后,代码还是布满_0x3f2e、__webpack_require__(123)之类的符号,阅读进展缓慢。

原因:格式化只能整理缩进和换行,改变不了编译器的产物性质。小程序的 JS 经历了 ES6 转 ES5、变量名混淆、模块加载器包装三层处理,格式化后的可读性提升有限。

解决:先做模块切分再做字符串锚定,不要试图通读。用前面给过的拆分脚本把包拆成小文件,然后针对单个模块做局部反混淆。变量重命名脚本可以针对_0x开头的标识符做批量替换,但替换前先确认它是否被多个模块共享引用,否则改了名字容易引入新错误。这个方向如果深入进去,用 Babel 做 AST 级别的还原是更稳的路,后面单独展开。

6. 把解密结果用起来:diff 对比、逻辑注入与变量名批量还原

解密只是开始,真正有价值的是拿解密结果做对比和验证。我实际用得最多的场景是版本对比:小程序发新版后,PC 微信会拉取新包,把新旧两份解密结果做 diff,改动点立刻暴露出来。接口地址变了、参数多了字段、渲染逻辑调整,全部在 diff 输出里一目了然,比人工读代码找变更高效得多。

# 对比两个版本的 appservice.js,-w 忽略行尾差异 diff -w old/appservice.js new/appservice.js | head -100

另一种验证方式是逻辑注入。在拆出的页面 JS 里找到data:定义或onLoad开头,插入一行console.log打印关键变量,再把改过的文件重新打包回 wxapkg 替换缓存,重启微信后控制台能看到输出。老版本微信对本地包校验较松,这条路能走通;新版本校验严格,替换后微信可能直接报错,这时改用本地 mock 服务更稳妥,不碰原始包,只劫持输出。

变量名批量还原适合处理单模块代码。对读不明白的压缩变量,用 Babel 做 AST 遍历,只重命名作用域内的单字母标识符,并保留下一个可读前缀加数字序号:

const fs = require('fs'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const code = fs.readFileSync('module_12.js', 'utf-8'); const ast = parser.parse(code, { sourceType: 'script' }); let seq = 0; const renamed = new Set(); traverse(ast, { Identifier(path) { // 只处理单字母变量,并且是函数内部声明,不碰属性名 if (/^[a-z]$/.test(path.node.name) && path.scope.hasBinding(path.node.name) && !path.parentPath.isMemberExpression({ property: path.node })) { const name = `v_${seq++}`; path.scope.rename(path.node.name, name); } } }); fs.writeFileSync('module_12_readable.js', generate(ast).code);

这段脚本通过path.scope.rename自动同步所有引用位置,不会出现改了一处漏了另一处的问题。它只处理单字母作用域变量,不动全局对象,所以相对安全。

最后说个我自己的教训:早年拿到一个加密包,没看版本就顺手用网上流传的固定密钥跑,解出来全是乱码,还以为是脚本有问题,折腾半天才发现是 key 太旧。后来养成的习惯是先记下微信版本号、再看文件头、最后才选密钥,三步做完基本不白费时间。做 wxapkg 解密这件事,最大的坑往往不在技术上,而在跳过前置确认直接开跑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询