微信小程序分析

Published: 2023年06月04日

In Reverse.

小程序架构

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

img

小程序的JS代码运行在逻辑层,这是一个纯JS环境,没有DOM/BOM对象,除了JS本身内置对象外只存在微信逻辑层注入的接口可用。小程序的页面视图会在渲染层里,这是一个完整的浏览器环境(WebView),然而这里面的JS是小程序开发者无法控制的,甚至可以说里面的DOM都不是开发者完全掌控的,开发者使用WXMLWXSS制作的页面会有开发者工具转换为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直接调用它的invokepublish接口即可,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,下面分别说明

在微信小程序中,wxmlwxssjs 文件分别对应于网页开发中的 HTMLCSSJavaScript。下面我将详细说明这三类文件的作用及其与 HTMLCSSJavaScript 的类比。

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或重签名微信来实现

抓包

微信小程序能发出四种包:httpwebsockettcpudp,对于没有开启微信网关的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级别。

环境真实性

  1. hook检测,检测关键函数是否被hook
  2. 版本与平台检测,不同版本和平台对API的支持情况不同,可通过canIUse等检测,基础库版本可直接对比JS

网络流量

就是JA3,Akamai等协议上的指纹特征咯~

群控方案

群控小程序,需要关注:多开、抓包、自动化、指纹等,WX的端有很多,所以有丰富的选择:

1.PC端微信运行小程序,特别是Mac上的HOOK起来特别方便(ObjC),可直接单微信多开小程序,而且退一步直接多开微信也很简单

2.安卓上,由于前台只能运行一个应用,需要安卓群控(如魔云腾),可对WX魔改直接起小程序,省去了登录的麻烦

3.基于微信开发者工具:好处当然是完全js实现,修改方便易于定制化,但缺点是特征太多需要对抗,而且它本身只能运行有源码的程序,这里也得打补丁

4.基于wmpf开发:wmpf本身目的就是为了让小程序运行时脱离微信,以便拓展其使用范围,但当前其官方只放开了Android版的实现,我们需要对其进行修改(不必对抗微信的风控了),以便实现多开与绕过权限限制,在多开时可考虑windows的安卓子系统

image-20230923165623316

可学习wechat-web-devtools-linux

其它

打开小程序的指定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

[2] 企业微信超大型工程-跨全平台UI框架最佳实践

[3] 微信小程序技术原理分析

social