☰
wxappUnpacker小程序反编译:wxapkg解包与AST拆包排错
2026/10/27 21:01:01 网站建设 项目流程

简介:wxappUnpacker 是一套面向微信小程序开发者与安全研究人员的反编译工具包,用于将小程序包还原为可读的 WXML、WXSS、JavaScript 与 JSON 代码,并支持主包与分包处理,便于分析业务逻辑、优化加载性能及开展安全审计。压缩包共 780 个文件,约 1.97MB,以 js、json、md、ts、map 为主,包含工具源码、依赖说明、类型声明与调试映射,另有 license、cmd/ps1 脚本及配置文件,目录结构较完整,便于在本地搭建解析环境并按模块查阅。已有 269 人学习,说明其在逆向分析与小程序机制研究场景中有一定参考价值。读者可据此了解反编译流程与关键文件构成,对照主包、分包结构梳理页面配置、样式与交互实现,为代码复用、问题排查和漏洞检测提供线索。使用时需注意仅限合法合规场景,遵守微信开发者协议与相关授权要求。

1. 第一次运行 wxappUnpacker 处理小程序反编译时,我盯着控制台那行Wrong Header!看了半小时

包是我从自己的测试机里导出来的,路径没写错,Node 也没崩,依赖都在。问题出在包体本身:首字节不是0xBE,说明它在我动手之前就已经被处理过一遍。这件事之后我养成一个习惯:拿到任何一个 wxapkg,先花两分钟看头部和索引,再决定要不要上 wxappUnpacker。

小程序反编译这件事,工具只占三成,剩下七成在“你能不能判断这次还原算成功”。这篇按我实际的操作顺序走:先讲容器里到底装了什么,再走一遍从小程序包到可读工程的完整链路,然后把我踩过的五类失败现象逐条对上原因和解法,最后说还原完之后怎么读代码才有价值。新手可以照着命令跑通,熟手可以看我在参数和校验点上的取舍。

2. 容器与管线:wxappUnpacker 处理的是哪几层文件

2.1 一个 wxapkg 里装的不是压缩包,是索引表加数据区

很多人第一次解包会下意识去找解压密码,其实 wxapkg 根本没有压缩这一层。它是一个自定义二进制容器,结构大致是:一字节起始标记0xBE,接着三个四字节的长度字段,一字节分隔标记0xED,然后是文件数量,跟着一张索引表,每条记录包含“文件名长度 + 文件名 + 数据偏移 + 数据长度”,索引表之后才是所有文件内容首尾相接的数据区。所有多字节整数都是大端序,这点在你自己写脚本读的时候特别容易翻车,按小端解析出来会得到一堆荒唐的数字。

位置字段长度说明
0起始标记1 字节固定0xBE,被改过基本就是加密或二次封装
1保留字段4 字节大端整数,不同构建版本取值不一,不用深究
5索引区长度4 字节索引表总字节数
9数据区长度4 字节所有文件内容加起来的总长
13分隔标记1 字节固定0xED
14文件数量4 字节索引条目数
18 起索引条目变长文件名长度 / 文件名 / 偏移 / 长度

真正的坑在数据区。解出来的东西离“源代码工程”差得很远:所有页面的 JS 逻辑被define('pages/xxx/index.js', function(...){...})这样的包裹串成一整个app-service.js,WXML 被压成一行,标签属性引号被吞掉,{{}}里的表达式被做过转义;WXSS 的@import被内联展开,rpx换算规则散落在各处。所以严格说这不是“反编译”,是“结构还原”。我在评估一个包的时候,从来不用“能不能还原出源码”当标准,而是看“能不能还原到可读、可搜索、可定位逻辑”的程度。

还有一个常被忽略的点:包里不只有代码。图片、字体、音频这些二进制资源是原样存着的,索引表里能拿到准确的偏移和长度,所以资源还原的准确率接近 100%,出问题的一定是代码那几层。把这条记住,后面排查的时候能省一半时间——资源对得上、代码对不上,问题一定出在解析器,不在容器。

2.2 四个脚本各管一层,串起来才是一次完整还原

wxappUnpacker 不是一个单体程序,是入口加几个分工明确的脚本。入口wuWxapkg.js只干容器层的事:校验头部、读索引、按偏移把每个文件切出来写盘,然后根据文件类型决定要不要继续往下交给别的脚本。真正的还原逻辑分散在wuJs.js(拆app-service.js)、wuWxml.js(还原 WXML)、wuWxss.js(还原样式)、wuConfig.js(处理app-config.json)里。

wuJs.js的做法值得单独说。它用esprima把整个app-service.js解析成 AST,然后在语法树上遍历,找形如define(模块名, 函数体)的调用节点,把模块名当路径、把函数体重新生成代码写出去。这个思路的成立前提是构建产物还保留着可识别的define包裹。一旦构建链路换成别的打包器、或者基础库版本变了把模块表换了形式,AST 里就找不到这些节点,结果就是只得到一个几十万行的巨型 JS——这不是工具坏了,是它的识别假设失效了。

wuWxml.js更像一套规则集而不是编译器。它做的是逆向压缩动作:把被吞掉的属性引号补回来、把自闭合标签补全、把被转义的表达式还原成{{}}、把被压成一行的结构按缩进展开。所以它对“标准写法”效果很好,碰上自定义组件、新语法、或者被内联成字符串模板的片段,还原质量会断崖式下降。wuWxss.js的逻辑类似,主要处理@import展开后的还原和rpx相关的换算。

理解这条管线之后,很多报错的性质就清楚了:容器层报错(头部、长度、索引)是硬错误,必须解决;解析层报错(AST 遍历失败、正则匹配不上)是软错误,经常是“部分还原”,还能继续用。我一般会先把容器层跑通拿到全部原始文件,再决定要不要花时间调解析层——因为光靠原始文件里的字符串搜索,很多时候已经够定位问题了。

2.3 三个开关决定你要不要返工

入口脚本的参数不多,但每一个都直接影响后面要不要重跑。-o表示只解容器、不做代码还原,跑完得到的是原始文件树;这个模式在我只想确认包结构和资源清单的时候特别省时间,几十秒出结果,不会卡在 AST 解析上。-d用于处理子包。-s=<主包目录>指定主包解出来的路径,子包里的公共资源引用要靠它来定位。

# 老版本解析依赖在新 Node 上容易抛错,建议固定到 12/14 nvm use 14 npm install # esprima / escodegen / js-beautify / css 等解析依赖 # 只解容器,不还原代码:先看目录结构和资源清单,快 node wuWxapkg.js -o ./pkg/__APP__.wxapkg # 完整还原,产物输出到与包同名的目录 node wuWxapkg.js ./pkg/__APP__.wxapkg # 子包必须带 -s,且主包要先跑完,顺序反了会找不到公共路径 node wuWxapkg.js -s=./__APP__ ./pkg/sub1.wxapkg

-o这个开关我建议所有人都先用一遍。原因很实际:完整还原在大包上可能跑几分钟,如果包体本身有问题,你会在几分钟后拿到一个半成品,然后再回头怀疑是解析器的问题。先用-o确认容器层干净,把变量一个个排除掉,比一次性跑完再猜要快得多。这个习惯类似调试时先打日志再下断点,看着笨,实际最省时间。

3. 复现链路:从小程序包到可读工程的四步实操

3.1 先定位包体、校验头部,别急着上工具

包体的位置和命名是有规律的。主包通常叫__APP__.wxapkg,子包按分包名命名,同一目录下平铺。我一般先把整个目录拷出来,按“主包 + 子包”分组,然后对每个文件跑一遍头部检查。这一步的意义不在于“检查能不能用”,而在于“提前知道哪些包需要特殊处理”,避免跑到一半才发现某个子包是加密的。

import struct, sys, os def peek(path): with open(path, "rb") as f: buf = f.read() # 首字节不是 0xBE,说明包体被处理过(加密或二次封装),先别往下走 if not buf or buf[0] != 0xBE: print(f"{os.path.basename(path)}: 首字节异常,跳过") return info1, idx_len, body_len = struct.unpack(">III", buf[1:13]) # 全程大端序 if buf[13] != 0xED: print(f"{os.path.basename(path)}: 分隔标记异常,索引区可能被替换") return count = struct.unpack(">I", buf[14:18])[0] print(f"{os.path.basename(path)} 文件数={count} 索引={idx_len} 数据={body_len}") p = 18 for _ in range(min(count, 10)): # 只打印前 10 条,够看结构了 n = struct.unpack(">I", buf[p:p+4])[0]; p += 4 name = buf[p:p+n].decode("utf-8", "replace"); p += n off, size = struct.unpack(">II", buf[p:p+8]); p += 8 print(f" {name} off={off} size={size}") if __name__ == "__main__": for f in sys.argv[1:]: peek(f)

这段脚本的关键在三个地方:>III里的>是大端序,写成<会解析出完全对不上的数字;偏移off是相对于数据区起点的,不是相对文件头,实际读取时要加上数据区起始位置;文件名用utf-8加replace容错解码,因为个别包里的路径是转义过的。跑完这个,你对包里有多少文件、有没有异常条目、资源大概占多少,心里就有数了。

3.2 主包先跑、子包后跑,顺序错了后面全白干

分包机制是这套流程里最容易出错的一环。主包里有公共组件、公共样式、公共工具函数,子包在引用这些资源时用的是相对主包根的路径。wuWxapkg.js在处理子包时,需要靠-s参数知道主包解到哪儿了,才能把这些引用解析成真实文件。如果顺序反了,或者忘了带-s,你会得到一堆“找不到模块”的报错,然后误以为是解析器不支持。

我的操作顺序固定成三步:先跑主包并确认产物目录结构正确;再逐个跑子包,每个都带上-s=<主包产物目录>;最后把子包的产物合并回主包目录,冲突的文件按“主包优先、子包覆盖非公共路径”处理。第三步不是工具做的,是手工或者脚本做的,因为合并策略取决于你要干什么——如果只是想搜索关键字,合不合并无所谓;如果要还原完整工程结构,就必须合。

还有一个细节:子包之间可能互相引用。这种情况下合并顺序会影响结果,我一般先按app.json里的分包声明顺序建一个依赖图,把叶子包先合,公共包最后合。听着麻烦,但比事后对着几十个“模块未定义”试错要快。

3.3 五个检查点判断这次还原算不算成功

跑完不知道成不成,是最浪费时间的状态。我给自己定了五个检查点,从粗到细依次过,任何一个不通过就停下来先解决它,不带病往下走。

检查点怎么查不通过说明什么
目录结构产物里是否有pages/、components/等目录,且与配置文件里的路径一一对应容器层解析有问题,索引表读错了
JS 拆分app-service.js是否被拆成多个文件,文件名是否就是模块路径define包裹没被识别,AST 遍历失效
WXML 结构标签是否成对闭合,{{}}是否成对,属性引号是否补全还原规则没有覆盖到这批写法
WXSS 引用@import是否已经展开成真实样式内容样式层还原被跳过或顺序错乱
资源完整性抽几张图片对比字节数与原包索引记录的长度数据区偏移计算有偏差,属于严重问题

第五个检查点最容易被跳过,但它最能说明问题。资源是原样存储的,索引表里有准确长度,如果产物里图片字节数和索引记录对不上,那一定是“偏移计算”或者“数据区起点”错了,这时候去调 JS 解析器纯属浪费时间。反过来,如果资源全对但代码不对,问题就精确定位在解析层,可以直接去看wuJs.js的遍历逻辑有没有匹配上。

4. 排查清单:五类常见故障的现象、原因和解法

4.1 首字节不是0xBE,入口直接报Wrong Header!

现象:脚本一启动就退出,提示头部校验失败,日志里连文件数量都没打印出来。原因:包体在你拿到之前已经被处理过了,常见于从某些环境导出的包,头部被替换或整体做了加密,wuWxapkg.js的第一道校验就过不去。解决:先用 3.1 的脚本确认首字节到底是什么,如果是固定偏移的替换,可以定位到原始标记的位置再看后面结构是否完整;如果整个数据区都被加密,那容器层工具无能为力,得先解决解密再谈还原。我一般的判断是:头部异常但数据区长度字段合理,说明只是头被改;连长度字段都是乱的,说明整包被重写过。

4.2 只得到一个几十万行的app-service.js,没有拆成单文件

现象:容器层跑通了,文件都出来了,但 JS 目录里只有一个巨型文件,其余页面文件一个没有。原因:wuJs.js靠识别define(模块名, 函数体)这种调用形式来拆分模块,构建产物一旦换成别的打包器,或者模块表改成了数组映射,AST 里就找不到这个节点。解决:先在这个巨型文件里搜define(和webpackJsonp,看它用的是哪种模块形式;如果是define,检查是不是被包在了一层eval或者字符串里,这种要先剥一层;如果是别家的模块表,就自己写个脚本按模块表里的名称数组和函数数组配对还原,思路一样,只是入口节点不同。

4.3 WXML 还原出来一堆残缺表达式,{{}}对不上

现象:结构大致在,但属性值变成半截,表达式里的变量名被截断,控制台还刷了一堆正则匹配失败的警告。原因:这批模板用了自定义组件、新语法或者被压缩成了字符串拼接形式,wuWxml.js的规则集覆盖不到。解决:先别急着改规则,降低期望——大多数排查场景里,能在这堆文本里做关键字搜索就够了。如果确实需要结构,就针对具体文件手工还原几个关键模板,别想着一次性全救回来。我吃过这个亏,花了两天调正则,最后发现需要的三个关键字段在原始文本里直接搜就有。

4.4 子包报“找不到模块”,路径解析成一堆../

现象:子包单独跑能出文件,但引用主包资源的语句全变成了失效路径,报错集中在公共组件和工具函数上。原因:没带-s参数,或者-s指向的目录不是主包产物根目录,或者跑的顺序反了——子包先跑,主包目录还不存在。解决:严格按“主包先、子包后、每个子包都带-s=<主包产物目录>”的顺序重跑一遍;如果还不行,检查-s的值是不是写成了相对路径且当前工作目录不对,改成绝对路径最稳。这个错误我犯过不止一次,后来干脆在脚本里写死绝对路径。

4.5 Node 版本太新,装依赖时就崩

现象:npm install报错,或者跑起来抛SyntaxError: Unexpected token,堆栈指向解析库内部。原因:这类工具大多写于 Node 10 到 12 的年代,依赖的解析库版本很老,在新版本 Node 上语法或 API 对不上。解决:用版本管理器切到 12 或 14,重新装依赖再跑。别试图升级依赖版本来“修好”它,解析库的主版本一升,esprima的 AST 节点结构就变了,wuJs.js里的遍历逻辑会全部失效,那才是真正的麻烦。我的做法是给这个工具单独建一个环境目录,锁死版本,不跟其他项目混用。

5. 读懂还原产物:把文件树变成可评估的攻击面

5.1 先扫接口和密钥,再谈逻辑

还原出来的文件树有几百上千个文件,从头读是不可能的。我的第一遍永远是关键字扫,不看逻辑只看“面”。这一步的目标不是找漏洞,是画出一张“这个包跟外界有多少条通路”的地图。

搜索关键词想定位的东西后续动作
request、http、api网络请求封装与接口前缀汇总出接口清单,看有没有内网地址、测试环境地址
token、sign、encrypt鉴权与签名算法判断签名是在客户端算还是服务端算
key、secret、appid硬编码凭据单独列出来,评估是否可被直接复用
upload、download文件流转路径看有没有未校验的存储路径
eval、Function(动态执行点这类点在客户端脚本里通常是风险放大器

扫完你会得到两样东西:一张接口表,和一份“客户端自己算出来的安全参数”列表。后者是重点。签名算法放在客户端,意味着算法本身对攻击者完全公开,安全性只能靠密钥,而密钥通常也在这个包里。这不是说一定有问题,而是说这条链路的安全假设必须由服务端的二次校验来兜底,还原产物恰好是验证“有没有兜底”的最直接材料。

5.2 客户端校验只是体验层,重点看服务端有没有兜底

还原之后最容易产生的错觉是“这里能改,所以有漏洞”。实际上客户端的所有判断都可以被绕过,这句话在业内是常识,但真正做评估的时候还是容易上头。我的做法是:把所有客户端校验点列出来,然后针对每一个,去接口层找对应的服务端校验证据。找不到证据的,才写成风险项。

典型的几类客户端校验:金额在提交前算好并做格式化,服务端如果直接采信这个值就是问题;权限判断靠本地缓存里的角色字段,服务端如果不重新查就是问题;次数限制在本地做计数,服务端如果不记账就是问题。这三类在还原产物里都能找到痕迹——格式化函数、角色字段的读取点、计数变量的自增位置。找到痕迹之后,判断依据不是代码写得好不好看,而是“这个值最终有没有被送到服务端并被服务端复核”,这需要结合接口表一起看。

我在写这类评估记录时有个固定格式:现象(哪个文件的哪一行做了什么)、影响路径(这个值怎么流到接口)、判定依据(服务端有没有对应校验,有就写“已兜底”,没有就写“待确认”)。这个格式看起来啰嗦,但它能避免把“看起来不安全”写成“确认存在风险”,后者是要负责的。

5.3 把一次性动作变成可重复流程

单次还原的价值有限,真正省时间的是把它变成流程。我现在的做法是按日期建目录,每次拿到新包就跑一遍固定脚本,产物按版本归档,然后用目录对比看两个版本之间改了哪些文件。这个动作在跟踪同一套业务的不同版本时特别有效——改动点往往就是新功能或者刚修过的地方,重点看那几个文件,比通读整棵树高效一个量级。

#!/usr/bin/env bash set -e STAMP=$(date +%Y%m%d) OUT="./dump/$STAMP" mkdir -p "$OUT" # 主包先解,产物目录作为后续子包的 -s 基准 node wuWxapkg.js -o "$OUT/__APP__" ./pkg/__APP__.wxapkg >/dev/null node wuWxapkg.js "$OUT/__APP__" ./pkg/__APP__.wxapkg # 子包按声明顺序逐个处理,-s 必须是绝对路径,避免工作目录漂移 for sub in ./pkg/sub*.wxapkg; do node wuWxapkg.js -s="$(realpath "$OUT/__APP__")" "$sub" || echo "skip: $sub" done # 只保留业务代码,二进制资源另存,避免归档体积膨胀 find "$OUT" -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.ttf' \) -delete echo "done -> $OUT"

脚本里|| echo "skip"这一句是刻意的:子包处理失败很常见,但我更希望整批跑完再统一看哪些失败,而不是第一个失败就中断。归档时删掉二进制资源是为了控制体积,文本产物才是后续对比和搜索的对象。这套流程跑顺之后,一个中等规模的包从拿到到可搜索,大概十分钟以内。

6. 一个具体技巧:用目录对比锁定改动,比通读整棵树高效得多

如果只让我留一个技巧,就是这个:用diff -rq对比两个版本的还原产物,只看差异文件列表。

# 只列差异文件名,不打印内容,几千个文件也能一眼看完 diff -rq ./dump/20240110/__APP__ ./dump/20240118/__APP__ | grep -v '^Only in'

这个命令在跟踪同一套业务的迭代时几乎是作弊。业务方每次发版,改动的文件通常只有几十个,剩下几百个是原封不动的。你把差异列表拉出来,按目录聚类,一眼就能看出这次动的是哪个模块——如果集中在一个业务目录,那就是功能迭代;如果散落在网络层和工具目录,那就值得多看一眼,因为这类改动往往涉及鉴权、加密或者请求封装的调整,是整个包里最需要复核的部分。

我最深的一次教训跟这个技巧正好相反。早期我做评估,拿到还原产物就一头扎进app-service.js,几百个模块挨个读,读了整整两天,写出来的记录全是“某某函数实现了某某功能”,一句有用的判断都没有。后来带我的前辈问了一句“你知道这次发版改了什么吗”,我答不上来。他让我先做版本对比,结果改动只集中在三个文件,其中一个是签名函数——把一段本地拼接改成了走服务端下发。这一处改动直接推翻了前面两天的很多结论。

从那以后我每次做这类分析,都强制走一遍“先对比、再定点、最后才通读”的顺序,不跳步。前面那些准备工作——头部校验、-o先跑一遍、五个检查点验收——看着繁琐,本质上都是同一个道理:先把变量控制住,再谈结论。

这份 wxappUnpacker 的整理版我放在资源里了,入口脚本、依赖清单,还有我这些年攒下来的报错对照备注都在里面,几个典型失败的现场处理方式也一并写上去了,拿去按第 3 章的顺序跑一遍就能出结果。希望帮到你。

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

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

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

立即咨询