小程序架构
它是一个多线程模型,小程序的核心功能由两个线程承载,即渲染层(View Thread)和逻辑层(App Service Thread):

小程序的JS代码运行在逻辑层,这是一个纯JS环境,没有DOM/BOM对象,除了JS本身内置对象外只存在微信逻辑层注入的接口可用。小程序的页面视图会在渲染层里,这是一个完整的浏览器环境(WebView),然而这里面的JS是小程序开发者无法控制的,甚至可以说里面的DOM都不是开发者完全掌控的,开发者使用WXML和WXSS制作的页面会有开发者工具转换为JS再有渲染层的运行时渲染为DOM节点(类似VDOM),渲染层和逻辑层可通过Native通信,如逻辑层可通过数据形式有限的修改渲染层节点,渲染层也可以给逻辑层发送很多事件。渲染层相对好调试,但在分析小程序时逻辑层才是核心,下面列下各种环境的底层:
| 平台 | 渲染层 | 逻辑层 | 注 |
|---|---|---|---|
| iOS | WKWebView | JavaScriptCore | - |
| Android/wmfs | xwalk(Chromium) | V8 | - |
| Windows | CEF | V8 | - |
| macOS | WKWebView | JavaScriptCore | - |
| 开发者工具 | NW.js | NW.js (V8) | 两个层其实都是webview,其实都是很完整的运行环境,但官方为了保持与真机一致用JS限制了逻辑层的能力(可越狱,无意义),逻辑层和渲染层通过WS通信 |
在通信时有两种情况:
1.Native->JS:Native向逻辑层上下文执行JS评估(V8为evaluateJavascript,JSC为evaluateJavaScript:completionHandler:),这里通常是给JS回调的结果
2.JS->Native:Native向JS注入全局对象(WeixinJSBridge),JS直接调用它的invoke、publish接口即可,wx.xxx是对它的封装
源代码结构
典型源码布局如下:
my-mini-program/
├── app.js // 小程序逻辑入口文件,用于初始化全局配置
├── app.json // 项目的全局配置文件,如页面路径、窗口表现等
├── app.wxss // 全局样式表,影响所有页面的样式
├── project.config.json // 项目配置文件,包含开发者工具相关设置
├── pages/ // 存放各个页面的目录
│ ├── index/ // 首页
│ │ ├── index.wxml // 页面结构文件
│ │ ├── index.wxss // 页面样式表
│ │ ├── index.js // 页面逻辑文件
│ │ └── index.json // 页面配置文件(可选)
│ ├── logs/ // 日志页面
│ │ ├── logs.wxml
│ │ ├── logs.wxss
│ │ ├── logs.js
│ │ └── logs.json // (可选)
│ └── ... // 更多页面
├── utils/ // 工具类库或公共函数
│ └── util.js // 提供一些常用的工具方法
├── images/ // 图片资源
│ └── logo.png // 示例图片
└── components/ // 自定义组件
└── my-component/ // 示例自定义组件
├── my-component.wxml
├── my-component.wxss
├── my-component.js
└── my-component.json
这里面关键的是三类文件:WXML、WXSS、JS,下面分别说明
在微信小程序中,wxml、wxss 和 js 文件分别对应于网页开发中的 HTML、CSS 和 JavaScript。下面我将详细说明这三类文件的作用及其与 HTML、CSS 和 JavaScript 的类比。
WXML (WeiXin Markup Language)
WXML 是微信小程序的页面结构描述语言,类似于 HTML。它定义了页面的布局和结构,包括各种标签、属性以及数据绑定等。如:
<view class="container">
<text>{{message}}</text>
<button bindtap="handleTap">点击我</button>
</view>
WXSS (WeiXin Style Sheets)
WXSS 是微信小程序的样式表语言,类似于 CSS。它用于定义页面的样式,包括颜色、字体、布局等。如:
.container {
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx;
}
button {
background-color: #1aad19;
color: white;
border: none;
padding: 10rpx 20rpx;
border-radius: 5rpx;
}
JS (JavaScript)
JS文件是微信小程序的逻辑处理文件,类似于网页开发中的JavaScript`。它负责处理页面的数据逻辑、事件处理、网络请求等。如:
Page({
data: {
message: 'Hello, World!'
},
onLoad: function() {
// 页面加载时执行的初始化操作
console.log('Page loaded');
},
handleTap: function() {
// 处理按钮点击事件
wx.showToast({
title: '你点击了按钮',
icon: 'none'
});
}
});
要学习,可以去官网申请账号,但更简单的就是直接去用测试号,只需登录微信,之后到首页就是小程序发布流程一把梭
包结构
不同版本
其实没啥特别的,但一些解包工具做了很精细化的处理,例如分了小程序和小游戏、插件、运行公共库、子包等,这主要根据是否含有某文件区分,可忽略~
加密结构
在windows和mac上它们是加密存放的,且加密方式存在差异,如下
Windows
它的前六字节是V1MMWX,接下来1024字节由AES加密,剩下是异或编码,且加密的salt和iv是固定值,key由wxid决定,具体如下:
const wxid = process.argv[2];
const iv = 'the iv: 16 bytes';
const salt = 'saltiest';
const wxapkgPath = '__APP__.wxapkg';
const decWxapkgPath = 'dec.wxapkg';
const data = fs.readFileSync(wxapkgPath);
// 生成密钥
const dk = crypto.pbkdf2Sync(Buffer.from(wxid), Buffer.from(salt), 1000, 32, 'sha1');
// 创建AES解密器
const decipher = crypto.createDecipheriv('aes-256-cbc', dk, Buffer.from(iv, 'utf8'));
// 解密前1024字节
let originData = decipher.update(data.slice(6, 1024 + 6));
// 处理剩余部分
const xorKey = wxid.length >= 2 ? wxid.charCodeAt(wxid.length - 2) : 0x66;
const afData = data.slice(1024 + 6).map(b => b ^ xorKey);
// 合并解密数据
originData = Buffer.concat([originData, Buffer.from(afData)]);
// 写入解密后的文件
fs.writeFileSync(decWxapkgPath, originData);
Mac
只有老版本有加密,新版本没了。老版本加密方式是AES-ECB加密前1024字节,并且尾部会有WAPkgEncryptedTagForMac标记,可参考mac_wxapkg_decrypt获取密钥,它是账户级的:
// 拦截 AccountService 的 GetEncryptKey 方法
function interceptAccountService(accountService: NativePointer) {
Interceptor.attach(accountService.implementation, {
onLeave: (ret: NativePointer) => {
const keyObject = new ObjC.Object(ret);
const key = keyObject.bytes().readByteArray(keyObject.length());
console.log(hexdump(key, { offset: 0, length: 16, header: true, ansi: false }));
}
});
}
const accountService = ObjC.classes.AccountService['- GetEncryptKey'];
interceptAccountService(accountService);
分析
解包
先获取小程序的代码,分为手机端和PC端:
# 安卓 需root
/data/data/com.tencent.mm/MicroMsg/{用户ID}/appbrand/pkg/ # 旧版
/data/data/com.tencent.mm/MicroMsg/appbrand/pkg/general # 新版
# IOS 需越狱
/var/mobile/Containers/Data/Application/{微信App的UUID}/Library/WechatPrivate/{用户ID}/WeApp/LocalCache/release/{小程序AppID}/
# windows
C:\Users\{UserName}\Documents\WeChat Files\Applet\{小程序AppID}\
# mac
/Users/{UserName}/Library/Group Containers/xxxx.com.tencent.xinWeChat/Library/Caches/xinWeChat/{用户ID}/WeApp/LocalCache/release/# 旧版
/Users/{UserName}/Library/Containers/com.tencent.xinWeChat/Data/.wxapplet/packages/ # 新版
也可以通过抓包获取...
有了文件后,存在大量工具可以使用,它们分为正则表达式提取和AST分析的,推荐后者,下面是工具列表:
解密后用这里的工具就可以解包了~
调试
1.使用开发者工具抓包(直接在Console->Networks查看或者用专业工具抓),记得要勾选不校验证书,还有个wx.setEnableDebug没分析过~
2.windows版可使用WeChatOpenDevTools-Python强制开启调试,这个工具也可以hook macOS版的,其实macOS上的老版本直接开WebKit的调试即可~
3.安卓可用http://debugxweb.qq.com/?inspector=true去开启渲染层的调试,逻辑层得另寻它法~
要开启macOS的微信小程序调试,hook的点是一样的(
enable vconsole/Devtools/app->web),但是mac需要特权,可以通过关闭SIP或重签名微信来实现
抓包
微信小程序能发出四种包:http、websocket、tcp和udp,对于没有开启微信网关的http流量可以直接通过设置系统代理实现,websocket尽管也是基于http但和剩下三者一样,需要强制转发流量,详见抓包相关文章。
JS bridge定位
wx.xxx是关键的函数,通常需要hook观察调用情况,之前的版本是Java实现的,它使用J2V8 (它改名为mmj2v8/mmv8),故通过registerJavaMethod/com.tencent.mm.appbrand.commonjni.AppBrandJsBridgeBinding即可找到所有的js bridge注册,后来估计是安全/性能考虑,移走了,现在关键点在如下so文件:
libappbrandcommon.so
libcronet.119.0.6045.214.so
libilink_protobuf.so
libmmj2v8.so
libMMProtocalJni.so
libmmv8.so
libwxa-runtime-binding.so
libmmwcwss.so
风控
小程序运行在一个极端受限的环境里,视图层和逻辑层是完全分开的,JS只能在受限的环境中执行,该环境没有DOM/BOM等,因此大多数浏览器相关的指纹在纯小程序环境里是拿不到的(当然可以通过关键页,如验证码页跳Webview去获取),这里就列下小程序本身提供的可供用作风控指纹的API:
系统信息
wx.getSystemInfo // brand(品牌) model(型号) pixelRatio(像素比) 屏幕和窗口信息 language(微信设置的语言) version(微信版本) system(操作系统及版本) platform(客户端平台 如ios) fontSizeSetting(用户字体大小) SDKVersion(基础库版本) benchmarkLevel(性能等级) safeArea(安全区域) locationReducedAccuracy(模糊定位) theme(暗黑主题?) host(宿主环境) enableDebug(开启调试) deviceOrientation(设备方向) xxAuthorized(授权状态) xxEnabled(系统开关)...
wx.getDeviceInfo // platform(客户端平台) abi(微信APP二进制接口类型) deviceAbi(设备二进制接口类型) brand(设备品牌) model(设备型号) cpuType(设备 CPU 型号) system(操作系统及版本) memorySize(设备内存大小) benchmarkLevel(设备性能等级)
wx.getDeviceBenchmarkInfo // benchmarkLevel modelLevel(设备档位,和BenchmarkLevel有映射关系)
wx.getAccountInfoSync // 当前小程序的appid 版本 版本类型(release) 插件名称和版本
wx.getPerformance // 获取性能数据
wx.getWindowInfo // 窗口/屏幕信息
wx.getSkylineInfo // skyline 支持度/版本
wx.getRendererUserAgent // webview ua
wx.env // USER_DATA_PATH(用户目录路径 (本地路径))
__wxConfig // 里面有很多信息,例如envVersion==develop
wx.getLaunchOptionsSync // scene(场景值) path(启动路径) apiCategory query(启动参数)
wx.getAppBaseInfo // 基础库/微信版本 是否开启调试 语言 字体 ...
wx.getAvailableAudioSources // 支持的音频输入源,如buildInMic手机麦克风 headsetMic耳机麦克风
用户活动信息
包括传感器,是否在录屏,是否在截屏,用户位置,网络信息,连接的Wi-Fi信息,生命周期监控
wx.getAppAuthorizeSetting // 授权信息 album(相册) bluetooth camera location locationReduce microphone notificationAlert notification notificationBadge notificationSound phoneCalender
wx.getScreenBrightness // 屏幕亮度
wx.getSystemSetting // 手机的设置 开没开相关功能 bluetooth location wifi deviceOrientation(设备方向)
wx.getLocation // 经纬度 速度 高度 这些信息的精度
wx.checkIsOpenAccessibility // 是否开启了能
wx.getBeacons // 周边的蓝牙信息
wx.getNetworkType // 网络类型 信号强度 是否有系统代理 是否是弱网环境
wx.onNetworkStatusChange
wx.getConnectedWifi // 连接的Wi-Fi的信息
wx.getHCEState // 是否支持HCE
wx.getBatteryInfo // 获取电量/充电状态
App()、Page() // 路由/生命周期等,这里有很多
wx.getClipboardData //剪切板
wx.startAccelerometer // 加速度计
wx.startCompass // 罗盘
wx.startDeviceMotionListening // 设备方向
wx.startGyroscope // 陀螺仪
wx.onUserCaptureScreen // 截屏
wx.getScreenRecordingState // 录屏
// 还有就是渲染层绑定的各种输入事件,如触控 输入等
用户信息
小程序本身的信息,账户信息
wx.getUserInfo // 用户的各种信息 如昵称 头像 性别 语言等 含签名
wx.getUserProfile // 同上
存储
// storage 存储 存储信息
wx.xxStorage[XX]
wx.getStorageInfo // 所有存的key,存储空间大小
// file 可存放cookie等信息
wx.getFileSystemManager
// 预取
wx.getBackgroundFetchToken
Canvas
// 常量定义
const CANVAS_ID = "fingerprintCanvas";
/**
* 绘制Canvas并生成MD5指纹
* @param {Object} canvasParams - 画布参数对象
* @param {Function} successCallback - 成功回调函数
* @param {Function} failCallback - 失败回调函数
* @param {Function} completeCallback - 完成回调函数
*/
function generateCanvasFingerprint(canvasParams, successCallback, failCallback, completeCallback) {
const performanceMetrics = { startTime: new Date().getTime() };
const canvasContext = wx.createCanvasContext(CANVAS_ID, this);
if (!canvasContext) {
if (failCallback && typeof failCallback === 'function') {
failCallback({
errMsg: "Canvas creation failed, Please check if canvasId is consistent"
});
}
return;
}
// 设置画布属性
const platform = canvasParams.pl || "";
canvasContext.globalCompositeOperation = platform === "android" ? "xor" : "multiply";
// 绘制基本图形
drawBasicShapes(canvasContext);
performanceMetrics.basicShapesTime = new Date().getTime();
// 绘制文本
drawTextElements(canvasContext);
performanceMetrics.textDrawingTime = new Date().getTime();
// 绘制圆形
drawCircles(canvasContext);
performanceMetrics.circlesTime = new Date().getTime();
// 绘制参数信息
drawParameterInfo(canvasContext, canvasParams);
performanceMetrics.parameterDrawingTime = new Date().getTime();
// 生成图像并计算MD5
let fingerprintHash = "";
canvasContext.draw(false, () => {
wx.canvasToTempFilePath({
canvasId: CANVAS_ID,
x: 0,
y: 0,
width: 100,
height: 100,
destWidth: 100,
destHeight: 100,
fileType: "png",
success: (result) => {
try {
performanceMetrics.drawTime = new Date().getTime();
const fileData = wx.getFileSystemManager()
.readFileSync(result.tempFilePath, "base64");
fingerprintHash = md5.hexMD5(fileData);
performanceMetrics.md5Time = new Date().getTime();
canvasParams.canvasPerformance = performanceMetrics;
if (successCallback) {
successCallback(fingerprintHash);
}
} catch (error) {
if (successCallback) {
successCallback("");
}
}
},
fail: () => {
if (successCallback) {
successCallback("");
}
},
complete: (result) => {
if (completeCallback) {
completeCallback(result);
}
}
});
});
}
// 辅助函数:绘制基本图形
function drawBasicShapes(context) {
context.rect(0, 0, 10, 10);
context.rect(2, 2, 6, 6);
context.textBaseline = "alphabetic";
context.fillStyle = "#f60";
context.fillRect(32, 1, 62, 20);
}
// 辅助函数:绘制文本元素
function drawTextElements(context) {
context.fillStyle = "#069";
context.fillText("Cwwm aa fjorddbank glbyphs veext qtuiz, 😃", 2, 15);
context.fillStyle = "rgba(102, 204, 0, 0.2)";
context.font = "18pt Arial";
context.fillText("Cwwm aa fjorddbank glbyphs veext qtuiz, 😃", 4, 45);
context.fillStyle = "rgb(255,0,255)";
}
// 辅助函数:绘制圆形
function drawCircles(context) {
let x = 16, y = 16;
context.beginPath();
context.arc(x, y, 16, 0, 2 * Math.PI, true);
context.closePath();
context.fill();
x = 32, y = 16;
context.fillStyle = "rgb(0,255,255)";
context.beginPath();
context.arc(x, y, 16, 0, 2 * Math.PI, true);
context.closePath();
context.fill();
x = 24, y = 32;
context.fillStyle = "rgb(255,255,0)";
context.beginPath();
context.arc(x, y, 16, 0, 2 * Math.PI, true);
context.closePath();
context.fill();
x = 24, y = 24;
context.fillStyle = "rgb(255,0,255)";
context.arc(x, y, 24, 0, 2 * Math.PI, true);
context.arc(x, y, 8, 0, 2 * Math.PI, true);
context.fill();
context.beginPath();
context.textBaseline = "top";
context.font = "14px Arial";
context.fillStyle = "#000";
context.closePath();
}
// 辅助函数:绘制参数信息
function drawParameterInfo(context, params) {
const parameterKeys = ["cpi", "br", "mo", "pr", "pl", "sh", "sw", "hh"];
parameterKeys.forEach((key, index) => {
const value = params[key] || "";
context.fillText(value, 0, 15 * (index + 1));
});
}
Webgl
function getWebGLRendererInfo(infoKey) {
try {
// 创建离屏WebGL上下文
const offscreenCanvas = wx.createOffscreenCanvas({
type: "webgl",
width: 0,
height: 0
});
const glContext = offscreenCanvas.getContext("webgl");
if (!glContext) {
console.error("Failed to get WebGL context");
return;
}
// 获取调试扩展
const debugExtension = glContext.getExtension("WEBGL_debug_renderer_info");
if (!debugExtension) {
console.error("WEBGL_debug_renderer_info extension not supported");
return;
}
// 获取渲染器信息 如GPU类型
const rendererInfo = glContext.getParameter(
debugExtension.UNMASKED_RENDERER_WEBGL
);
// 存储信息
storeDeviceInfo({
value: rendererInfo,
key: infoKey
});
} catch (error) {
console.error("Error while getting WebGL renderer info:", error);
}
}
安全网关
小程序可一键配置微信网关,配置后原始wx.request等的请求将不会再直接到目标服务器,而是通过专有协议到网关服务器*wxgateway*,再由网关添加一些设备信息、用户信息、风控信息与私有数据后再转交给目标服务器,这会导致抓包困难,对抗也会升级到微信APP级别。
环境真实性
- hook检测,检测关键函数是否被hook
- 版本与平台检测,不同版本和平台对API的支持情况不同,可通过
canIUse等检测,基础库版本可直接对比JS
网络流量
就是JA3,Akamai等协议上的指纹特征咯~
群控方案
群控小程序,需要关注:多开、抓包、自动化、指纹等,WX的端有很多,所以有丰富的选择:
1.PC端微信运行小程序,特别是Mac上的HOOK起来特别方便(ObjC),可直接单微信多开小程序,而且退一步直接多开微信也很简单
2.安卓上,由于前台只能运行一个应用,需要安卓群控(如魔云腾),可对WX魔改直接起小程序,省去了登录的麻烦
3.基于微信开发者工具:好处当然是完全js实现,修改方便易于定制化,但缺点是特征太多需要对抗,而且它本身只能运行有源码的程序,这里也得打补丁
4.基于wmpf开发:wmpf本身目的就是为了让小程序运行时脱离微信,以便拓展其使用范围,但当前其官方只放开了Android版的实现,我们需要对其进行修改(不必对抗微信的风控了),以便实现多开与绕过权限限制,在多开时可考虑windows的安卓子系统

其它
打开小程序的指定url
1.利用小程序跳小程序,跳转完到指定小程序可生成短链接
2.利用微信浏览器(H5)跳小程序,利用wx-open-launch-weapp跳转
3.利用app直接生成相关卡片(需要APP实现了该功能)
4.直接在小程序里打开指定域名(需要指定域名可控,不现实)
5.利用微信直接生成卡片(需要分析微信:HOOK/改数据库)
6.利用小程序本身调用服务端的生成短链接能力(需要分析它怎么调用的,已知服务端利用getUnlimitedQRCode接口实现的)
参考
[0] A Measurement Study of Wechat Mini-Apps
[1] 微信小程序源码阅读笔记1
[3] 微信小程序技术原理分析