目录


1. 背景

微信小程序自 2017 年上线以来,已成长为日活超过 9 亿的轻量级应用生态。它以"无需安装、触手可及"的形态渗透到电商、金融、政务、出行等几乎所有垂直领域。与原生 App 不同,小程序的代码包以 .wxapkg 形式下发到客户端本地,这为安全研究人员提供了一条相对可达的逆向路径。

小程序逆向在以下几个场景中具有现实意义:

  • 安全研究:挖掘越权、信息泄露、支付逻辑等业务漏洞;
  • 知识产权保护:评估自有小程序代码的防逆向强度,验证加固效果;
  • 协议分析:还原私有 API 签名算法,用于自动化测试或合规审计;
  • 恶意样本分析:识别伪装成小程序的钓鱼、欺诈应用。

本文记录一次完整的小程序逆向过程,涵盖从 wxapkg 获取、解包、代码恢复、关键点提取到工具链选型的全链路,并在末尾给出对应的防御建议。所有操作均在授权环境下进行。


2. 微信小程序架构概述

2.1 双线程模型

微信小程序采用双线程架构,逻辑层与视图层物理隔离,通过原生层(Native)的消息通道进行通信。这是它与传统 Web 页面最本质的区别。

┌─────────────────────────── 微信客户端 (Native) ───────────────────────────┐
│  ┌──────────────────────┐         ┌──────────────────────┐               │
│  │  逻辑层 (Service)     │         │   视图层 (View)       │               │
│  │  JSCore / V8         │  JSBridge │  WebView (渲染)      │               │
│  │  - App()/Page()      │◄─────────►│  - WXML → DOM       │               │
│  │  - 业务逻辑 / wx.* API │  通信通道  │  - WXSS → 样式      │               │
│  └──────────┬───────────┘         └──────────┬───────────┘               │
│             └──────────────┬─────────────────┘                           │
│                    ┌───────▼────────┐  消息序列化 / 域名白名单 / 接口签名  │
│                    │ Native 中转层   │  完整性校验 / 沙箱隔离               │
│                    └────────────────┘                                       │
└──────────────────────────────────────────────────────────────────────────┘

双线程带来的安全特性:逻辑层运行在 JSCore 中,没有 DOM/BOM 对象,无法直接操作页面;视图层运行在 WebView 中,无法直接调用 wx.* 接口。两者通过 setData / 事件机制交互,这种隔离天然增加了 Hook 与注入的成本。

2.2 .wxapkg 打包格式

小程序代码最终编译打包为 .wxapkg 文件。一个完整小程序通常包含主包 __APP__.wxapkg(含 app.js/app.json/app.wxss 及公共页面)、按需加载的独立分包(如 _-_-subPkg.wxapkg),以及 Worker 线程代码包。

2.3 运行环境

层级 iOS Android Windows
逻辑层 JavaScriptCore V8 / XWeb V8
视图层 WKWebView X5 / 系统 WebView Chromium WebView
原生桥 ObjC 消息分发 Java/JNI 桥 C++ 桥

2.4 微信安全机制

微信在小程序生命周期中部署了多层防护:

  1. 代码混淆:发布时对 JS 进行变量名混淆、字符串数组化、控制流平坦化;
  2. 接口签名:部分 wx.* 接口(如支付、登录态)附带服务端校验签名,防止伪造;
  3. 域名白名单:request/socket/uploadFile 的目标域名必须在管理后台配置,且强制 HTTPS;
  4. 包完整性校验:客户端加载 wxapkg 前会校验包的完整性,防止本地篡改后被加载;
  5. 沙箱隔离:小程序运行在独立沙箱中,文件系统、存储相互隔离。

理解这些机制是逆向的前提——它们决定了"能拿到什么"和"拿不到什么"。


3. wxapkg 文件格式解析

3.1 文件结构

wxapkg 是一种自定义二进制打包格式,整体由 Header、Index Area、Data Area 三段构成,每个 File Entry 记录 nameLen + name + offset + size:

偏移   字段              长度      说明
0x00   Magic             4 字节    "V1MM"(明文主包) / "wxsg"(加密包)
0x04   Version           4 字节    通常为 0
0x08   Index Length      4 字节    索引区长度
0x0C   Data Length       4 字节    数据区长度          ← Header 共 16 字节
──────────────────────────────────────────────────────
0x10   File Count        4 字节    文件条目数量
0x14   File Entry[0..n]  变长      每条: nameLen(4) + name + offset(4) + size(4)   ← Index Area
──────────────────────────────────────────────────────
       File Data[0..n]   size      实际文件内容(可能 zlib 压缩)                    ← Data Area

3.2 解包脚本实现

下面是一个可用的 wxapkg 解包器,覆盖 Header 解析、索引区遍历、数据区提取与 zlib 解压:

import struct
import os
import zlib

class WxapkgHeader:
    """wxapkg 文件头解析"""
    def __init__(self, magic, version, index_len, data_len):
        self.magic = magic            # 4字节魔数
        self.version = version        # 版本号
        self.index_len = index_len    # 索引区长度
        self.data_len = data_len      # 数据区长度

    @classmethod
    def from_file(cls, fp):
        fp.seek(0)
        data = fp.read(16)
        if len(data) < 16:
            raise ValueError("Invalid file: too short for header")
        magic, version, index_len, data_len = struct.unpack("<4sIII", data)
        if magic not in [b"V1MM", b"wxsg"]:
            raise ValueError(f"不是有效的 wxapkg 文件,魔数:{magic.hex()}")
        return cls(magic, version, index_len, data_len)

class WxapkgFileEntry:
    """wxapkg 文件条目"""
    def __init__(self, name, offset, size):
        self.name = name
        self.offset = offset
        self.size = size

def unpack_wxapkg(filepath, output_dir):
    """解包 wxapkg 文件"""
    with open(filepath, 'rb') as fp:
        header = WxapkgHeader.from_file(fp)
        print(f"Magic: {header.magic}")
        print(f"Version: {header.version}")
        print(f"Index length: {header.index_len}")
        print(f"Data length: {header.data_len}")

        # 读取索引区
        file_count = struct.unpack('>I', fp.read(4))[0]
        print(f"File count: {file_count}")

        entries = []
        for _ in range(file_count):
            name_len = struct.unpack('>I', fp.read(4))[0]
            name = fp.read(name_len).decode('utf-8')
            offset = struct.unpack('>I', fp.read(4))[0]
            size = struct.unpack('>I', fp.read(4))[0]
            entries.append(WxapkgFileEntry(name, offset, size))

        # 读取数据区
        for entry in entries:
            fp.seek(entry.offset)
            data = fp.read(entry.size)

            # 尝试解压
            try:
                data = zlib.decompress(data)
            except:
                pass  # 非压缩数据

            output_path = os.path.join(output_dir, entry.name.lstrip('/'))
            os.makedirs(os.path.dirname(output_path), exist_ok=True)
            with open(output_path, 'wb') as f:
                f.write(data)
            print(f"Extracted: {entry.name} ({entry.size} bytes)")

# 使用示例
# unpack_wxapkg('__APP__.wxapkg', './output/')

需要注意的是,较新版本微信对主包做了整体加密(wxsg 魔数即加密包标志),此时直接 zlib 解压会失败。对于加密包,需要先用微信内置密钥进行解密(通常涉及对文件头 1024 字节的 AES/异或处理)后再走上述流程。社区工具 unveilr 已内置该解密逻辑。

3.3 获取 wxapkg 的途径

在 Android 设备上,小程序包缓存路径通常为:

/data/data/com.tencent.mm/MicroMsg/{用户哈希}/appbrand/pkg/

由于该目录需要 root 权限,实践中常用以下替代方案:

  • 已 root 设备:直接 adb pull 拉取;
  • 模拟器:使用自带 root 的 Android 模拟器,路径一致;
  • PC 微信:Windows 版微信缓存路径 %APPDATA%\Tencent\WeChat\WeChat Files\Applet\,部分版本可直接复制。

4. 解包后的代码恢复

解包后的目录结构通常如下:

output/
├── app.js / app.json / app.wxss   # 入口逻辑(已混淆) / 全局配置 / 全局样式
├── pages/index/{index.js,.wxml,.wxss,.json}   # 各页面四件套
├── components/                    # 自定义组件
└── miniprogram_npm/               # 构建后的 npm 依赖

4.1 JS 代码反混淆

小程序发布时会经过微信混淆器处理,主要手段包括变量名替换(getUserInfo → _0xab12)、字符串数组化(字符串集中存入数组按索引引用)、控制流平坦化(顺序代码改写为 switch-case 状态机)、死代码注入(插入永不执行的分支干扰分析)。下面是一个基于 AST 的反混淆思路,使用 @babel/parser + @babel/traverse 还原字符串数组引用:

// deobfuscate.js —— 字符串数组还原 (典型模式: var _0x3a1f=['login',...]; function _0x4b2c(i){return _0x3a1f[i];})
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
const fs = require('fs');

const ast = parser.parse(fs.readFileSync('app.js', 'utf-8'));
const stringArrayMap = {};
let arrayName = null, accessorName = null;

// 1. 定位字符串数组及其访问函数
traverse(ast, {
  VariableDeclarator(path) {
    if (t.isArrayExpression(path.node.init) && path.node.id.name.startsWith('_0x')) {
      arrayName = path.node.id.name;
      path.node.init.elements.forEach((el, i) => {
        if (t.isStringLiteral(el)) stringArrayMap[i] = el.value;
      });
    }
  },
  FunctionDeclaration(path) {
    if (generate(path.node).code.includes(arrayName)) accessorName = path.node.id.name;
  }
});

// 2. 将 _0x4b2c(0x1) 形式的调用替换回原始字符串
traverse(ast, {
  CallExpression(path) {
    if (path.get('callee').isIdentifier({ name: accessorName })) {
      const arg = path.node.arguments[0];
      if (t.isNumericLiteral(arg) && stringArrayMap[arg.value] !== undefined) {
        path.replaceWith(t.stringLiteral(stringArrayMap[arg.value]));
      }
    }
  }
});

fs.writeFileSync('app.deob.js', generate(ast, { comments: true }).code);
console.log('反混淆完成 -> app.deob.js');

控制流平坦化的还原则更复杂,通常需要先识别调度变量(dispatcher),再按状态顺序重建基本块。对于深度混淆的代码,结合 webcrack、deobfuscator 等自动化工具可显著降低人工成本。

4.2 WXML/WXSS 还原

编译后的 WXML 会被转换成 JS 函数($gwx 生成器),形如 { "node": { "tag": "view", "children": [...] } } 的虚拟节点树。通过 unveilr 或 wxappUnpacker 可将其还原为原始 WXML:

<!-- 还原后的 index.wxml -->
<view class="container"><text>{{message}}</text></view>

WXSS 编译后会被转成 JS 注入的 CSS,还原后得到标准样式表。

4.3 app.json 配置分析

app.json 是小程序的全局配置,逆向时重点关注:

{
  "pages": ["pages/index/index", "pages/login/login"],
  "subPackages": [
    { "root": "pkgA", "pages": ["pages/detail/detail"] }
  ],
  "networkTimeout": { "request": 10000 },
  "permission": {
    "scope.userLocation": { "desc": "用于配送定位" }
  },
  "requiredBackgroundModes": ["audio"],
  "__usePrivacyCheck__": true
}

从中可提取:页面路由(确定攻击面)、分包结构、申请的权限范围、后台能力。这些信息为后续的关键点提取提供导航。

4.4 依赖包分析

若小程序使用了 npm 包(开启了"构建 npm"),miniprogram_npm/ 目录下会包含打包后的依赖。通过对比 package.json 与发布版本号,可识别是否存在已知漏洞依赖(如旧版 lodash 原型污染、axios SSRF 等),这是供应链审计的入口。


5. 关键点提取技术

逆向的最终目标通常不是阅读全部代码,而是定位少数关键点:API 接口、签名算法、加密密钥、核心业务逻辑。

5.1 API 接口提取

通过正则批量提取 wx.request 调用及其 URL:

import re
from pathlib import Path

def extract_api_calls(js_dir):
    pattern = re.compile(
        r'wx\.request\s*\(\s*\{[^}]*?url\s*:\s*[\'"]([^\'"]+)[\'"]',
        re.DOTALL
    )
    apis = set()
    for f in Path(js_dir).rglob('*.js'):
        code = f.read_text(encoding='utf-8', errors='ignore')
        for m in pattern.finditer(code):
            apis.add(m.group(1))
    return sorted(apis)
# 输出示例: ['https://api.target.com/v1/login', '.../v1/order/create', 'https://pay.target.com/v1/sign']

更准确的做法是用 AST 遍历 CallExpression,提取 wx.request 的配置对象字面量,可同时拿到 method、header、data 字段,构造出完整的请求模板。

5.2 签名算法定位

小程序常用的签名模式是在请求头或参数中附加 sign 字段,常见算法为 MD5(参数 + 时间戳 + 密钥)。定位思路:

  1. 关键词检索:搜索 sign、signature、md5、hmac、crypto;
  2. Hook 加密函数:用 Frida Hook CryptoJS.MD5、JSCore 原生加密接口;
  3. 回溯调用链:从 wx.request 拦截器向上回溯,找到 header.sign 的赋值点。

下面是一个定位签名生成函数的 Frida 脚本片段:

// frida -U -n WeChat -l hook_sign.js
Java.perform(function () {
  var CryptoJS = Java.use('com.tencent.mm.plugin.appbrand.jsapi.CryptoJS');
  CryptoJS.MD5.implementation = function (input) {        // Hook CryptoJS.MD5(若走 JS 层)
    console.log('[MD5] in=' + input + ' out=' + this.MD5(input));
    return this.MD5(input);
  };
});
// 签名在 JS 层时, 亦可 Hook JSCore 的 evaluateJavascript 拦截 sign 生成入参
Interceptor.attach(Module.findExportByName(null, 'JSObjectMakeFunction'), {
  onEnter: function (args) { /* 记录函数创建 */ }
});

定位到签名函数后,提取其算法伪代码:

// 还原后的签名逻辑: 字典序拼接 + 时间戳 + 盐 → MD5
function genSign(params, timestamp, salt) {
  var str = Object.keys(params).sort()
    .map(k => k + '=' + params[k]).join('&')        // 1. 按 key 字典序拼接
    + '&timestamp=' + timestamp + '&salt=' + salt;   // 2. 追加时间戳与盐
  return md5(str).toUpperCase();                      // 3. MD5 摘要
}

5.3 加密密钥提取

密钥常硬编码在 JS 中或通过 wx.getStorage 从本地读取。提取方法:

# 密钥特征正则
patterns = [
    r'(?:aes|aeskey|secret|appkey|app_secret)\s*[:=]\s*[\'"]([A-Za-z0-9+/=]{16,64})[\'"]',
    r'[\'"]([0-9a-fA-F]{32})[\'"]',  # 32位hex (MD5/AES-128)
    r'[\'"]([A-Za-z0-9+/]{44}=)[\'"]',  # Base64 (AES-256)
]

若密钥来自服务端动态下发,则需结合抓包与 Frida 追踪运行时内存。

5.4 业务逻辑分析

重点关注高风险逻辑:支付流程(订单创建、金额校验、回调验签)、权限控制(wx.login → code2session → 自建 token 鉴权链)、优惠券/积分(服务端是否校验业务约束)。典型漏洞模式:客户端自行计算订单金额并提交,服务端未做二次校验,导致支付金额篡改。

5.5 小程序云函数分析

使用云开发的小程序,部分逻辑运行在云函数(Node.js)中,客户端无法直接获取其源码,但可间接分析:搜索 wx.cloud.callFunction({ name: 'xxx' }) 列出所有云函数名;Hook callFunction 抓取入参/出参;结合业务场景与返回数据结构推断内部逻辑;云函数常因未做调用方身份校验而存在未授权访问或水平越权。

客户端 ──callFunction({name:'pay'})──> 云函数 ──校验?(常缺失)──> 云数据库
       <──{code:0, data:{...}}──────── │       <──订单数据────── │

6. 逆向工具链

6.1 工具链全景

阶段      工具
获取      root 设备 / 模拟器 / PC 微信缓存
解包      unveilr (推荐) / wxappUnpacker / 自研脚本
反混淆    webcrack / deobfuscator / Babel AST 自研
调试      微信开发者工具 / Chrome DevTools 调试 WebView
流量      Charles / Fiddler / mitmproxy (HTTPS 抓包)
动态      Frida (Hook JSCore/Native) / Xposed
辅助      VS Code + ESLint / Sourcemap 还原 / 正则工具

6.2 各工具定位

工具 类型 用途 备注
unveilr 解包 解密 + 解包 + 还原 WXML/WXSS/JS Node 实现,支持加密包,社区活跃
wxappUnpacker 解包 经典解包工具 部分加密包已不适用
微信开发者工具 调试 加载还原后代码、断点调试 需绕过 AppID 校验
Charles / Fiddler 抓包 HTTPS 中间人,分析请求 需信任根证书
Frida 动态 Hook 运行时拦截加密/签名函数 跨平台,Android/iOS 通用
webcrack 反混淆 自动化还原混淆 JS 基于 Babel AST
mitmproxy 抓包 脚本化流量分析 适合自动化测试

6.3 调试与抓包配置

将还原后的代码导入微信开发者工具时,AppID 不匹配会阻断加载。绕过方式:新建空白项目并覆盖还原代码、开启"不校验合法域名"(详情 → 本地设置)、注释掉 app.json 中云开发配置,即可在本地断点调试逻辑层,实时观察 wx.request 的参数构造与签名生成。流量抓包方面,Android 7+ 默认不信任用户证书,可用低版本设备/模拟器、将证书安装到系统目录(需 root)或用 Frida 绕过 SSL Pinning。mitmproxy 启动代理:mitmweb --listen-port 8888,设备代理指向主机 IP:8888 后访问 mitm.it 安装证书。


7. 防御方案

逆向分析与防御是硬币的两面。从开发者视角,应从以下几个维度构建纵深防御。

7.1 代码加固

手段 实现方式 效果
商业混淆服务 接入腾讯代码保护/第三方加固 变量名、控制流深度混淆
JS 加密 关键函数运行时解密执行 静态分析失效
WASM 化 核心算法用 C/Rust 编译为 WASM 反编译成本大幅提升
代码分包加密 子包单独加密,按需解密 减少一次性暴露面

将核心签名算法迁移到 WASM 模块是当前性价比较高的方案,静态反汇编 WASM 的难度远高于 JS 反混淆。

7.2 接口签名与防重放

服务端必须独立校验签名,不信任客户端任何业务字段:

// 服务端校验示例 (Node.js): HMAC-SHA256 + 时间窗口 + 常量时间比较
const crypto = require('crypto');
function verifySign(params, sign, secret) {
  const { sign: _, ...rest } = params;                          // 1. 剔除 sign 字段
  const str = Object.keys(rest).sort()
    .map(k => `${k}=${rest[k]}`).join('&') + `&key=${secret}`;  // 2. 字典序拼接
  const expected = crypto.createHmac('sha256', secret).update(str).digest('hex'); // 3. HMAC
  if (Math.abs(Date.now() - Number(params.timestamp)) > 5 * 60 * 1000) return false; // 4. 5分钟窗口防重放
  return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expected)); // 5. 防时序攻击
}

关键点:密钥永不下发客户端、使用 HMAC 而非裸 MD5、加入 nonce 防重放、常量时间比较防时序攻击。

7.3 敏感逻辑服务端化

客户端只负责"采集"和"展示",所有"判断"和"计算"放到服务端:金额、数量、权限等级由服务端从数据库读取,不接受客户端传入;优惠券可用性、活动条件由服务端实时计算;支付回调以支付平台服务端通知为准,不信任客户端的"支付成功"状态。

7.4 小程序安全检测接入

发布前接入自动化安全检测平台(腾讯、阿里等厂商均提供 SaaS,可作为发布流水线一环),覆盖代码混淆强度评估、硬编码密钥/Token 扫描、域名白名单合规检查、越权与信息泄露漏洞扫描、依赖组件已知漏洞清单。

7.5 运行时防护

包括关键路径上的 wxapkg 完整性校验(发现篡改即熔断)、开发者工具/调试器附加检测(触发风控降级)、以及对异常批量 API 调用、高频签名请求的限流与告警。


8. 总结

本文完整记录了一次微信小程序逆向的全过程,核心脉络如下:

获取 wxapkg → 格式解析解包 → JS 反混淆/WXML 还原 → 配置与依赖分析
                                                        │
                                                        ▼
                                              关键点提取 [API | 签名 | 密钥 | 业务逻辑]
                                                        │
        ┌───────────────────────────────────────────────┤
        ▼                                               ▼
   工具链协同 (unveilr/Frida/Charles/AST)         漏洞验证 (授权环境)

几点实践体会:解包已基本工程化,unveilr 等工具能覆盖绝大多数场景,加密包也已有成熟解密方案;反混淆是主要人力成本,深度混淆下纯静态分析几乎不可行,必须结合动态 Hook 与 AST 工具;签名算法是逆向的"终点",拿到签名即可构造合法请求,后续漏洞挖掘转为传统 Web 安全范畴;云函数是新的盲区,客户端可见的只有入口名,逻辑推断依赖抓包与行为分析;防御的核心是服务端校验,客户端加固只是提高门槛,真正的安全边界必须在服务端建立。

小程序的安全本质仍是"客户端不可信"原则的延伸。任何放在客户端的密钥、算法、判断逻辑,都应被视为可被还原的公开信息。将信任锚点收敛到服务端,才是抵御逆向的根本之道。


免责声明

本文所述技术内容仅用于安全研究、授权渗透测试与学术交流目的。读者应在获得目标系统所有者明确书面授权的前提下进行相关操作,不得用于任何非法用途。未经授权对他人小程序进行逆向、破解、数据爬取或接口调用,可能违反《中华人民共和国网络安全法》《中华人民共和国著作权法》《计算机软件保护条例》等法律法规,以及微信小程序平台服务协议,由此产生的一切法律责任由行为人自行承担。本文作者不对读者基于本文内容所实施的任何行为及其后果承担任何责任。请务必遵守所在国家/地区的法律法规,尊重软件开发者的知识产权与平台规则。