☰
小程序反编译实战:wxapkg拆包、还原度与分包合并
2026/10/31 7:55:08 网站建设 项目流程

简介:面向开发者与安全研究人员的微信小程序逆向分析工具包,围绕 wxappUnpacker 展开,可将小程序二进制包还原为可读的 WXML、WXSS、JS 与 JSON 源码,并支持主包与分包分别处理,便于梳理页面结构、组件用法、事件处理与网络请求逻辑,也适用于代码优化与安全审计场景。压缩包共 780 个文件、约 1.97MB,其中 374 个 js 脚本与 70 个 ts 源码构成反编译核心逻辑,另有 83 个 json 配置、61 个 map 映射文件、71 个 md 说明文档,以及 ps1、cmd、bat 等一键执行脚本和若干授权文件,目录完整、依赖齐全,解压后即可按模块定位入口。目前已有 270 人学习下载。读者可由此获得从取包、还原到分析排错的完整工具链与实现思路,理解模板、样式与脚本资源在各阶段如何解析,并借助说明文档快速上手调试。

1. 一个 1.9MB 的包和一份消失的源码:小程序反编译到底能做到什么

去年接手一个外包交接的小程序,对方只留下一个 1.9MB 的 wxapkg 文件,源码仓库权限跟着人一起没了。我先用 wxappUnpacker 跑了一遍,半小时就把目录结构、页面、组件全吐了出来,当时还挺得意。结果真正翻车在后面:还原出来的 wxml 少了两个自定义组件的插槽属性,js 全是require("xxx")的数字模块引用,产物能看、能读懂大概逻辑,但你不能直接把它当源码接着开发。

这件事的关键判断是:小程序反编译文件这件事,工具只负责「拆壳」这一半,另一半在拆完之后的结构判断、还原度评估和交叉比对。这篇就按我实操的顺序讲清三件事:wxapkg 这个容器怎么组织、wxappUnpacker 到底能还原到哪一层、以及哪些坑我踩过不止一次。刚接触这块的人可以照着流程跑通一遍,已经拆过几个包的人,能看到我在哪儿做取舍、依据是什么。

2. 拆开 wxapkg 这个黑匣子:三段结构与 wxappUnpacker 的边界

很多人拿到包第一反应是双击、改名、找解压软件,全试一遍之后才发现它既不是 zip 也不是 tar。wxapkg 是自定义的容器格式,结构简单到有点朴素:一个固定长度的头部、一张索引表、一段直接拼接的文件数据。理解这三段,后面所有报错基本都能自己定位。

2.1 一个 wxapkg 里到底装了什么

头部占 18 个字节,字段顺序是固定的:第 0 字节是魔数 0xBE,紧接着三个 4 字节的无符号整数(保留位、索引段长度、数据段长度),第 13 字节是一个分隔字节 0xED,第 14 到 17 字节是文件条目总数。索引表从第 18 字节开始,每个条目是「名字长度 → 名字 → 文件大小 → 数据段内偏移」,变长结构所以只能顺序读。索引表完了才是真正的数据段,所有文件内容按索引里记录的偏移首尾相接堆在一起,没有对齐、没有分隔符。

往里看,你通常会看到这几类东西:app-service.js(所有页面和组件的 js 合并后的产物)、app-config.json(全局配置)、若干pages/xxx/xxx.wxml之类的路径其实是不存在的——真实情况是 wxml 被模板编译器编成了 js 里的字符串块,wxss 被样式编译器编成了注册样式的 js,json 是被原样塞进去的。所以「包里没有 wxml 文件」不是你找错地方了,是它本来就以编译产物的形态存在。

2.2 wxappUnpacker 的能力边界:能还原到哪一层

这一步我要说清楚,避免预期错位。wxappUnpacker 干的是三件事:拆容器、按索引还原文件树、调用配套的模板和样式编译器把 js 里的编译产物逆回 wxml/wxss。它可以被理解成「解包器 + 几段反编译脚本」的组合,目录里按文件类型拆成了几个入口,主脚本负责容器和整体调度,剩下几个分别处理 js 模块、wxml、wxss 和配置。

不同类型的还原度差异很大,我给个自己实测出来的经验排序:

产物类型还原度主要损失
json 配置接近无损极少数构建期注入的字段
wxml 模板高部分组件插槽、自定义指令、事件绑定的别名
wxss 样式中等被合并的选择器顺序、被压缩掉的多余声明
js 逻辑模块级变量名混淆不可逆、原始文件划分不可知、注释全丢

表格里最刺眼的是 js 那一行。还原出来的是模块粒度的拆解,你能看到一个个模块和它们之间的依赖关系,但函数名是a、b、c,文件名是数字编号,原始项目里「这个工具函数放在 utils 下」的信息彻底没了。所以如果目标是「读懂业务逻辑」,够用;如果目标是「接着维护」,它还差一次人工重构。

提示:只在你自己拥有、或者拿到明确授权的包上做这件事。这一点我从来不含糊,因为它决定了后面所有分析是不是站得住脚。

2.3 环境准备与第一次跑通

工具是 Node 写的,对运行时版本比较挑。我一般不会一上来就装最新的 LTS,而是在 12~16 这一段较老的稳定版本里试,原因是它内部依赖的解析器对太新的语法支持不一定跟得上,版本差了会直接在解析 js 的时候抛异常。目录里除了主脚本,还有几个配套的编译器二进制文件,这几个文件必须和主脚本放在同一层,缺了它们照样能拆出文件树,但 wxml 和 wxss 就会停留在编译产物状态。

跑之前我习惯先用三十行脚本自己拆一遍,拿到索引表的真实内容,这样工具报错的时候我知道是包的问题还是工具的问题:

import struct, os def parse(path): buf = open(path, 'rb').read() # 魔数校验:不是 0xBE 说明包被改过或加了自定义头,先别急着上工具 assert buf[0] == 0xBE, f'首字节 {buf[0]:#04x}' info1, index_len, body_len = struct.unpack('>III', buf[1:13]) assert buf[13] == 0xED, '分隔字节应为 0xED' count = struct.unpack('>I', buf[14:18])[0] pos, entries = 18, [] for _ in range(count): name_len = struct.unpack('>I', buf[pos:pos + 4])[0]; pos += 4 name = buf[pos:pos + name_len].decode('utf-8', 'replace'); pos += name_len size, offset = struct.unpack('>II', buf[pos:pos + 8]); pos += 8 entries.append((name, size, offset)) # 数据段起点:多数实现是 18 + index_len,少数包用绝对偏移,探测一下 base = 18 + index_len name0, size0, off0 = entries[0] probe = buf[base + off0: base + off0 + min(size0, 64)] if sum(1 for b in probe if b < 9 or 13 < b < 32) > len(probe) // 2: base = 0 return buf, entries, base buf, entries, base = parse('target.wxapkg') os.makedirs('raw', exist_ok=True) for name, size, off in entries: path = os.path.join('raw', name) os.makedirs(os.path.dirname(path) or 'raw', exist_ok=True) with open(path, 'wb') as f: f.write(buf[base + off: base + off + size]) print(len(entries), sum(e[1] for e in entries), 'bytes')

几个参数值得说明:index_len和body_len加起来应当约等于文件总长减去 18,如果差得离谱,基本可以判定包被截断或者加了额外的尾部数据。base那一段探测是血泪经验,我用过的包里绝大多数走18 + index_len,但确实遇到过偏移直接写成绝对值的情况,表现为拆出来的 wxml 全是不可打印字符——如果你也遇到这个现象,把base改成 0 再跑一次,五秒钟就能验证。

最后那行打印的两个数字要记住:条目数和总字节数。后面无论用工具还是自己拆,这两个数就是你的校验基准,少一个文件、少几百字节,都能立刻发现。

3. 从包到可读目录:wxappUnpacker 的完整拆包流程

工具跑通不难,难的是跑完之后目录里一堆东西你不知道哪个是入口、哪个是垃圾。这一章按我实际的顺序走:先定位包,再定参数,最后按产物类型分别处理。

3.1 先找到包:缓存目录的三种常见落点

包不是一个,是一堆。宿主应用在小程序运行或预下载时会把包落到自己的私有目录里,不同宿主、不同端,路径命名差异很大,但规律有三条。第一条,安卓侧基本都在宿主包名的私有目录下,往下找一层叫pkg、mini、subpackage之类的目录,里面散着一批.wxapkg文件,文件名通常是一串数字或哈希。第二条,桌面端喜欢放在「缓存」类的目录下,按时间戳分文件夹,最新的一次运行在最上面。第三条,如果小程序有分包,主包和每个子包都是独立的.wxapkg文件,体积差异明显,最大的那个一般是主包。

我自己的习惯是先按体积排序看一眼:ls -lhS *.wxapkg | head。主包通常几百 KB 到 2MB,子包几十 KB 到几百 KB,个别做了资源分离的包会有特别小的、只有几 KB 的文件,那种往往是只有配置没有逻辑的空壳,单独拆意义不大。

3.2 命令行参数:-o / -s / -d 各自管什么

工具的命令行形式大致是「主脚本 + 若干开关 + 目标包路径」。常用开关就三个,含义我用踩坑后的理解说明白,具体以-h输出为准,不同分支版本会有细微差别:

-o是覆盖已有输出目录。默认情况下工具发现同名目录已存在会停下来问你,脚本化调用时这一步会卡死,所以第二次跑同一个包必须带上它。我吃过一次亏:在自动化循环里跑了二十个包,全部卡在交互确认上,日志里静悄悄,半小时后才发现一个文件都没产出。

-d是保留中间产物。反编译过程中会先落地一批编译期的临时 js,再转成 wxml/wxss,正常情况下中间文件会被清掉。排查还原失败时这个开关非常关键——中间文件在,说明容器拆解没问题,卡在反编译那一步;中间文件都没有,说明连索引表都没读对。

-s是给分包指路的。子包单独拆的时候,如果任它自由发挥,会生成一个以子包名为根的新目录,和主包目录并列,合并起来非常难受。把-s指向主包已经拆出来的目录,子包的内容就会落到主包对应的路径下,最后得到一棵完整的树。

# 主包:覆盖输出 + 保留中间产物,便于第一次排查 node wuWxapkg.js -o -d ./pkg/main.wxapkg # 子包:合并进主包目录,而不是另起一个根 node wuWxapkg.js -o -s=./main ./pkg/sub1.wxapkg

顺序上一定要主包先、子包后。反过来的话,-s指向的目录还不存在,工具会自己建一个空目录然后把你子包的内容塞进去,看起来成功了,实际上主包的页面和子包的页面在两个平行世界里。

3.3 四类产物的还原度差异:wxml、wxss、js、json

拆完之后目录里大致分四类,处理方式完全不同。

json 最省事,原样落地,打开就是格式化好的配置,app-config.json里的页面列表是你后面所有工作的地图——它告诉你这个包到底有几个页面、路由怎么走、哪些是 tabBar 页。我一般第一件事就是把它拉出来数页面数,和索引表里的条目数对一下,心里有个底。

wxml 是重点。还原出来的模板在结构上是可信的,标签层级、class 名、数据绑定表达式基本都在,但有几类东西会失真:自定义组件的插槽内容有时候会被拍平、wx:for上的wx:key偶尔会丢、事件名可能从原来的语义化命名变成了编译后的短名。判断失真的方法很土但有效:找一个结构最复杂的页面,用还原出来的 wxml 和实际运行时的界面元素对照着看,对不上三处以上,就别指望它和原始代码逐字一致了。

wxss 的还原度中等。编译期会做合并、去重、压缩,还原回来的是「一份等效的样式表」而不是「原来那几份文件」。表现是选择器还在、属性值还对,但顺序和来源文件已经乱了。如果你只是想知道某个元素为什么长这样,够用;如果想把样式按组件拆回去,得靠人肉判断。

js 最麻烦。所有页面逻辑被合并进一个app-service.js,工具会按模块表把它拆成一堆数字命名的文件,模块之间的require关系是保留的,但文件边界和业务边界已经不是一回事了。我一般的做法是:先看入口模块,顺着require往下画两层,找出真正承载业务的那个模块,然后只读它。全量读完是不可能的,一个中等规模的小程序拆出来几千个模块,人眼扛不住。

注意:还原出的 js 里数字模块名是稳定的,同一个包拆两次编号一致,所以你可以放心地把它当作索引来记笔记,不用担心下次对不上。

4. 加密头、分包和多包合并:三种非常规情况的处理

标准包拆起来十分钟就能结束,真正耗时间的是这三种情况:包被改过头、分包没拆全、多包需要合并。这一章按从易到难排。

4.1 识别被改过头的包:hexdump 三个特征

不是所有.wxapkg都是标准 wxapkg。我第一次遇到时工具直接抛了一个语焉不详的错误,输出目录是空的,后来才发现包的首字节根本不是 0xBE。

判断方法就是看前 32 个字节,xxd -l 32 target.wxapkg。三种特征对应三种处理方向:

第一种,首字节是别的值,但第 13 字节和第 0 字节之间存在固定的异或关系。这种情况最常见,属于「每个字节统一异或了一个常量」的轻度改装。识别办法非常干脆:同时检查两个位置,首字节异或后等于 0xBE、第 13 字节异或后等于 0xED,两个位置同时命中,key 基本不会错。

def guess_xor_key(buf): for k in range(256): # 两个位置同时命中才算数,单看首字节容易误判 if buf[0] ^ k == 0xBE and buf[13] ^ k == 0xED: return k return None key = guess_xor_key(buf) if key: buf = bytes(b ^ key for b in buf)

这段的可靠性来自第二个判断条件。只匹配首字节会有大量误报(256 个 key 里有 1 个必然命中),加上 0xED 这个位置之后,误报率几乎降到零。解完之后不要急着跑工具,先拿上一章的解析函数过一遍,确认index_len + body_len + 18和实际文件长度对得上,对不上就说明这只是个巧合命中。

第二种,首字节是 0x50 0x4B 或者 0x1F 0x8B。这是被整包再压缩了一层,先用常规解压工具处理,得到的结果再走一遍格式校验。

第三种,包前面多了一段自定义头,特征是 0xBE 出现在偏移 4 到 64 之间的某个位置。这种要按偏移切片,切掉前面的自定义头之后再做解析,同时注意索引表里的偏移量是相对于原来那段头之后的位置还是绝对位置——这就是上一章base探测要解决的问题。

4.2 分包与独立分包的处理顺序

分包这件事,工具能做的是把子包内容落到正确路径,做不了的是「告诉你这个包属于谁」。所以顺序必须是:先拆主包,看app-config.json里的分包配置,确认有几个子包、各自的 root 是什么,再去拆对应的文件。

独立分包是个例外。它的特点是运行时不依赖主包,配置上和普通分包分开列出,拆的时候如果按普通分包塞进主包目录,路径是对的,但你会发现在主包里根本找不到指向它的引用——因为它本来就不从主包引。这种包我一般单独放一个目录,标注清楚,避免后面看代码时误以为它是死代码。

还有一种情况是「预下载分包」。这类包在配置里会有额外的标记,体积往往不大,内容是几个高频页面的提前下放。拆出来之后你会发现这些页面的代码主包里也有一份,这不是工具出错,是构建期就复制了两份,别浪费时间去找「哪份才是真的」。

4.3 合并多个包成一份可读目录

合并本质上是路径对齐问题。我用过最稳的做法不是靠-s,而是手动把子包拆到一个临时目录,然后按路径合并:

node wuWxapkg.js -o ./tmp/sub1 ./pkg/sub1.wxapkg # 只补主包里不存在的文件,已存在的保留主包版本,避免覆盖出问题 cp -rn ./tmp/sub1/. ./main/

cp -rn的n是关键,不覆盖已存在文件。这样即使子包里有一份和主包同名的app-config.json(有些构建会这么做),主包的版本不会被冲掉。合并完之后再做一次校验:find ./main -type f | wc -l,和所有包索引条目数之和比一下,差几个是正常的(重复文件被-n跳过),差几十个说明有包没拆全。

提示:合并顺序建议主包、普通分包、独立分包,独立分包放最后,出问题时容易回滚。

5. 五个反复翻车的坑:现象、原因和兜底做法

这一章是我这些年记录下来的踩坑清单,按出现频率排序。每一条都按「现象 → 原因 → 解决」写,遇到对应现象可以直接照做。

5.1 工具跑完没有任何报错,输出目录却是空的

现象:命令执行完毕,退出码 0,日志里只有几行看不出内容的输出,ls一看目录是空的或者只有一个空文件夹。

原因:九成是包不是标准 wxapkg,工具在解析头部时走了一个不抛异常的失败分支。少数情况是包本身是个空壳(只有配置没有内容),体积通常在几 KB 以内。

解决:先xxd -l 32看首字节,不是 0xBE 就走上一章的异或或切片流程;是 0xBE 就用第 2 章那段脚本自己拆一遍,看条目数是不是 0。条目数为 0 说明这个包确实没有内容,别再折腾工具了。

5.2 wxml 和 wxss 全是编译产物,只有一堆 js 落了下来

现象:文件树出来了,页面目录里只有 js 和 json,没有 wxml/wxss。

原因:配套的模板编译器和样式编译器缺失,或者版本和包不匹配。工具在找不到它们的时候通常不会中断整个流程,而是静默跳过反编译步骤,所以表现就是「拆成功了但只有一半」。

解决:先确认那几个编译器文件和主脚本在同一层目录;再确认运行时版本。版本不匹配的表现是抛一个语法解析类异常,这时候换一个较老的稳定版运行时重试,比死磕当前版本效率高得多。加上-d保留中间产物,能看到中间文件就说明卡在反编译这一步,中间文件都没有说明是容器层的问题。

5.3 拆出来的文件名是乱码,或者一堆问号

现象:目录里出现形如????.js的文件,或者中文路径全变成替换字符。

原因:索引表里的文件名是 UTF-8 变长字节,如果包是在非 UTF-8 环境下构建的(少见但存在),或者你在读的时候用了错误的长度计算,就会错位。还有一种情况是单个文件名的长度字段被改写,导致后续所有条目跟着错位。

解决:解析时用decode('utf-8', 'replace')而不是直接 decode,先把整张索引表读出来看名字是否合理。如果从某一条开始全部乱掉,说明前一条的名字长度算错了,往上一条一条回溯。判断依据很简单:正常包的文件名都是pages/、components/这类有语义的路径,出现连续乱码就是错位。

5.4 分包拆了,但主包里找不到对它的引用

现象:子包内容已经合进主包目录,页面也在,但全局配置的页面列表里没有这些页面。

原因:这是独立分包,运行时不从主包加载,配置里单独列出。也可能你拆的是预下载分包,本来就有一份副本在主包里。

解决:先去全局配置里搜子包的 root 名,能搜到就是普通分包或独立分包,搜不到就是预下载。两种情况都不影响阅读代码,只是别把它当死代码删掉。

5.5 第二次跑同一个包,输出目录被清空了

现象:第一次拆得好好的,改了个参数重跑,结果目录里只剩一部分文件。

原因:工具默认会清理输出目录再写入,如果你的-s指向了主包目录,子包那一轮会把主包目录当成自己的输出目录清一遍。

解决:永远给子包指定一个临时输出目录,用cp -rn手动合并;或者严格按「主包-o、子包-s指主包且不加-o」的组合来。我现在固定用一个 shell 脚本串起来,参数写死,就是为了不再犯这个错。

6. 验证还原是否完整:一份可复用的比对方法

拆包这件事,跑通只是及格线,能不能证明「我拆全了」才是分水岭。我现在的固定动作是三步比对,十秒钟能做完,但救过我很多次。

第一步比数量。索引表条目数、落盘文件数、全局配置里的页面数,这三个数要能对上。页面数一般小于等于文件数,因为一个页面至少对应 wxml、js、json 三份;如果落盘文件数比索引条目数少,说明有文件写入失败,通常是路径里有非法字符或者大小写冲突。

第二步比体积。索引表里所有文件的 size 之和,和落盘文件的总字节数应当完全相等。差值是正数说明有文件没写完,负数说明有重复写入。这个校验非常灵敏,一字节的偏差都能抓到:

# 索引表总字节 vs 实际落盘总字节,两个数必须相等 python3 -c "import os;print(sum(os.path.getsize(os.path.join(r,f)) for r,_,fs in os.walk('main') for f in fs))"

第三步比结构。挑三个页面,看它们的 wxml 有没有class属性、js 有没有Page(或Component(的注册调用、json 有没有usingComponents。这三样齐全,说明这个页面的还原是完整的;少了任何一样,就回到第 5 章对应条目去查。我用这个方法在一个 200 多页的包上定位出 7 个还原失败的页面,全部集中在使用了自定义编译插件的模块上,属于工具的能力边界,不是操作问题。

顺带说一句自保。拆过别人这么多包之后,我自己写小程序时会强制做三件事:核心业务逻辑一律放服务端,包里只留展示和交互;构建期做标识符混淆和字符串加密,让还原出来的 js 至少读起来费劲;配置类的敏感信息绝不进包,不管是密钥还是内部接口地址。这三条不是什么高深方案,但它们把「拆开就能用」变成「拆开只能看个大概」,性价比最高。

从那以后我每次拿到一个包,第一件事不是直接跑工具,而是先xxd看前 32 字节、再用三十行脚本把索引表读出来数一遍条目和总字节——这两个数字后来成了我判断「拆没拆全」的唯一基准。希望帮到你。

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

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

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

立即咨询