正在加载中...

体育场馆预约小程序逆向

背景

学校的体育场馆只能通过小程序预约, 而且很难抢到, 所以想着做个脚本.
首先还是常规的 BurpSuite + Proxifier 抓包, 然而发现数据都经过了加密.
image
所以只好使用 Wedecode 反编译小程序的 wxapkg 包, 逆向分析源码.
小程序的源码大致结构如下:

├─@babel
│   └─...
├─common
│   └─vendor.js
├─components
│   └─...
├─pages
│   └─...
├─service
│   ├─apis.js
│   └─request.js
├─static
│   └─...
├─store
│   └─...
├─rutils
│   ├─constant.js
│   ├─encryption.js
│   └─wx-pay.js
├─app-config.json
├─app.js
├─app.json
└─app.wxss

加密方法分析

在微信开发者工具中导入该项目, 设置一下自己的AppId, 打开调试器, 编译运行, 发现小程序发起了两个相关请求, 查看一下调用栈:
image
从调用栈进入request.js
image
简单来说, 这是ES5下的生成器函数, _r 来自 _yield$t$encryptionData, 而该值实际上为上一个case的返回值, 即t.encryptionData(a), 往上翻, 查看t的定义:
image
因此进入encryption.js, 找到加解密函数:

var e = require("../common/vendor.js"),
  t = e.CryptoJS.enc.Utf8.parse("ed79fe80db6ac20357e5f39fb42bda73c270d1258d9b81385ae0eb12fe4c6e2b");
  
exports.decryptData = function(r, n) {
  var o = e.CryptoJS.enc.Base64.stringify(e.CryptoJS.enc.Hex.parse(r)),
    s = e.CryptoJS.AES.decrypt(o, t, {
      iv: e.CryptoJS.enc.Utf8.parse(n),
      mode: e.CryptoJS.mode.CBC,
      padding: e.CryptoJS.pad.Pkcs7
    }).toString(e.CryptoJS.enc.Utf8);
  return s ? JSON.parse(s) : {};
}

exports.encryptData = /*#__PURE__*/ function() {
  var _ref = _asyncToGenerator2( /*#__PURE__*/ _regeneratorRuntime2().mark(function _callee(n) {
    var o, s, a, _yield$Promise$all, _yield$Promise$all2, p, c;
    return _regeneratorRuntime2().wrap(function _callee$(_context) {
      while (1) switch (_context.prev = _context.next) {
        case 0:
          _context.next = 2;
          return new Promise(function(t, r) {
            e.index.getUserCryptoManager().getLatestUserKey({
              success: function success(e) {
                return t(e.iv);
              },
              fail: r
            });
          });
        case 2:
          o = _context.sent;
          s = e.CryptoJS.enc.Utf8.parse(JSON.stringify(n));
          a = e.CryptoJS.AES.encrypt(s, t, {
            iv: e.CryptoJS.enc.Utf8.parse(o),
            mode: e.CryptoJS.mode.CBC,
            padding: e.CryptoJS.pad.Pkcs7
          }).ciphertext.toString().toUpperCase();
          _context.next = 7;
          return Promise.all([r(), r()]);
        case 7:
          _yield$Promise$all = _context.sent;
          _yield$Promise$all2 = _slicedToArray2(_yield$Promise$all, 2);
          p = _yield$Promise$all2[0];
          c = _yield$Promise$all2[1];
          return _context.abrupt("return", {
            iv: o,
            ciphertext: "".concat(p).concat(o).concat(c, " ").concat(a)
          });
        case 12:
        case "end":
          return _context.stop();
      }
    }, _callee);
  }));
  return function(_x) {
    return _ref.apply(this, arguments);
  };
}();

加解密的密钥 t 是一个定值. 其中加密函数还是使用了生成器. 加密时先进入case 0, 通过微信加密通道相关接口获取用户加密的初始化向量iv, 再进入 case 2, 使用密钥t,初始化向量iv, 对数据进行AES加密得到密文a, 然后调用两次函数r(), 函数的代码如下:

function r() {
  return new Promise(function(t) {
    e.index.getRandomValues({
      length: 12,
      success: function success(r) {
        return t(e.index.arrayBufferToBase64(r.randomValues).slice(0, 4));
      }
    });
  });
}

获得两个长度为4的随机字符串, 之后进入case 7, pc 是长度为4的随机字符串, o是初始化向量iv, a是密文.
调用微信api接口需要用户信息以及正确的AppID, 这使得编写相应的程序变得困难. 然而, 这一过程中存在漏洞, 初始化向量iv直接放在最后发送的密文中, 服务端直接从密文中提取出iv, 再使用密钥tiv进行解密, 因此, iv 无需从微信平台获取, 只需随即选取即可.
注意到, 这里的加密实现方式似乎完全就是在调用crypto-js, 然而, 为了排除其对crypto-js进行修改的可能性, 这里先抠出他这里的所有加密实现代码, 将其运行结果与直接调用crypto-js进行比对.抠的代码太长, 和小程序源码一起存蓝奏云上了.
image
那就可以放心地直接调用AES加密了. 然而在利用Python实现加解密过程时, 碰到了一个问题, Cryptojs并不检验密钥的长度, 而这个小程序使用的密钥长度恰好不符合规范, 因此难以直接调用Python的Crypto库. 这里选择用Python的实现并进行一定的修改, crypto-js找到了密钥长度与轮数的关系, 即 密钥长度 / 4 + 6 = 轮数, 因此, 这里主要需要修改的地方是对密钥长度的检验以及轮数的选择. 这里给出部分代码:

class AES:
    def __init__(self, master_key):
        self.n_rounds = len(master_key) // 4 + 6
        self._key_matrices = self._expand_key(master_key)

    def _expand_key(self, master_key):
        key_columns = bytes2matrix(master_key)
        iteration_size = len(master_key) // 4
        i = 1
        while len(key_columns) < (self.n_rounds + 1) * 4:
            word = list(key_columns[-1])
            if len(key_columns) % iteration_size == 0:
                word.append(word.pop(0))
                word = [s_box[b] for b in word]
                word[0] ^= r_con[i]
                i += 1
            elif iteration_size > 6 and len(key_columns) % iteration_size == 4:
                word = [s_box[b] for b in word]
            word = xor_bytes(word, key_columns[-iteration_size])
            key_columns.append(word)
        return [key_columns[4 * i: 4 * (i + 1)] for i in range(len(key_columns) // 4)]

有了加密的实现, 解密也就很简单了, 这里略去.
好了, 现在解决了加解密的问题. 接着考虑分析小程序相关的接口, 在一开始, 我们提到小程序编译运行后自动发起了两个请求, 一个是/notice/list, 从字面意思不难猜出与公告有关; 一个是/user/login, 显然是登录接口, 那接着来看看这个接口.

登录接口分析

还是老方法, 查看调用堆栈:
image
这里选择查看apis.js. 从调用栈跳转到如下位置:
image
这里可以看到, 首先调用了 e.index.login 方法, 直接将返回的code 作为 r 发送给对应接口. 进一步分析可知, 这里实际上调用的是微信小程序的login接口, 通过 BurpSuite 抓包可知, 这里主要使用到了newticketappid参数, 向/wxa-dev-logic/jslogin接口发起请求.
image
使用自己的AppID将无法登录该小程序, 伪造AppID将无法获取相应code. 所以这里选择换个思路. 直接对真实环境进行抓包.
所以下面利用 BurpSuite + Proxifier 对真实环境进行抓包.
image
通过分析抓包结果可知, 后端主要依赖 Authorization 的值进行身份认证. 所以脚本实现思路是, 抓包获取Authorization, 将该值作为输入, 运行脚本, 预约场馆.
预约场馆的相关api接口及参数也可通过抓包获取, 并结合之前的解密函数进行分析, 没什么花样, 这里由于篇幅原因就略去了.

这里给出主要的Python代码:

# -*- coding: utf-8 -*-
import json
import random

import requests

import aes

HEADER = {
    "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) "
                  "Version/15.0 Mobile/15E148 Safari/604.1 wechatdevtools/1.06.2409140 MicroMessenger/8.0.5 "
                  "Language/zh_CN webview/",
    "Authorization": ""}
HOST = "https://mini.ecnu-api2.ziyun188.com"
KEY = "ed79fe80db6ac20357e5f39fb42bda73c270d1258d9b81385ae0eb12fe4c6e2b"
MY_AES = aes.AES(KEY.encode('utf-8'))


def random_str(n: int) -> str:
    return ''.join(random.sample('abcdefghijklmnopqrstuvwxyz0123456789', n))


def get_ciphertext(data: dict, iv: str) -> str:
    data_json = json.dumps(data)
    return f"{random_str(4)}{iv}{random_str(4)}%20{MY_AES.encrypt_cbc(data_json.encode('utf-8'), iv.encode('utf-8')).hex().upper()}"


def decrypt(ciphertext: str, iv: str) -> str:
    return MY_AES.decrypt_cbc(bytes.fromhex(ciphertext), iv.encode('utf-8')).decode('utf-8')


if __name__ == '__main__':
    book_time = {"date": "2024-1-1", "hours": "2", "earliestTime": "18:00", "latestTime": "20:00",
                 "time": [{"start": "18:00", "end": "19:00"}, {"start": "19:00", "end": "20:00"}]}
    stu_num = ""
    name = ""
    phone = ""
    HEADER["Authorization"] = ""

    order_info = {"id": "", "bookAttr": "1", "bookOpenId": "", "bookUserName": "", "bookTime": book_time, "isAll": "",
                  "code": stu_num, "device": "", "contact": name, "phone": phone, "dept": "", "idCard": "", "unit": "",
                  "siteId": "4132b8f240704556a6fe71e93eabe900", "siteName": "中北校区大活多功能厅羽毛球",
                  "siteType": "羽毛球", "siteArea": "中北", "siteUnit": "3", "equipment": "", "publicity": "",
                  "car": "", "memo": "", "status": "7", "amount": "10", "cancelReason": "", "presentPeopleNum": "1",
                  "siteCode": "2"}

    iv = random_str(16)
    url = f"{HOST}/order/edit"
    response = requests.post(url, headers=HEADER, data=json.dumps({"ciphertext": get_ciphertext(order_info, iv)}),
                             verify=False)
    cipher = response.json().get('ciphertext', '')
    print(decrypt(cipher, iv))

BTW, 工位附近有对情侣, 天天乱我道心, 最近这工位是待不下去了

posted @ 2024-11-10 23:24  suxss  阅读(207)  评论(0)    收藏  举报