某医院小程序接口逆向分析

某医院小程序接口逆向分析

最近使用某医院小程序时,发现只能查询最近半年的检查报告。那么有没有可能通过修改请求参数,获取到更早时间的检查报告呢?

环境&工具

配置Brup Suite

  1. Proxy -> Proxy Settings -> Import/export CA certificate -> Certificate in DER format,选择CA文件的路径并命名为xxx.crt
    export CA
  2. 双击生成的CA文件,导入钥匙串
  3. 双击PortSwiger CA,信任设置为"始终信任"
    PortSwiger CA

配置Proxifier

  1. 配置Proxy: 依次点击Rules -> Add,地址为127.0.0.1,端口为Brup Suite对应的端口
    Proxifier
  2. 配置Rule: 依次点击Rules -> Add-> +,选择WeChatAppEx Helper.app

    快捷键command+shift+G搜索/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Frameworks/WeChatAppEx Framework.framework/,点击Helpers

    Proxifier Rule
    Proxifier Rule
  3. 打开Brup Suite和WeChat小程序,此时已经可以看到小程序发出的网络请求

小程序反编译

  1. 进入目录~/Library/Containers/com.tencent.xinWeChat/Data/.wxapplet/packages/,会看到一堆命名为wx开头的{AppID}的文件夹,找到对应🏥小程序所在的目录
    • 可删除所有wx开头的{AppID}文件夹后,重新打开🏥小程序
    • 也可在移动端Wechat->开发者资料->AppID中直接获取AppID
  2. 进入{AppID}所在目录,找到名为__APP__.wxapkg的文件
  3. 准备wxappUnpacker环境
    1
    2
    3
    4
    5
    6
    7
    8
    9
    git clone https://gitee.com/ksd/wxappUnpacker.git
    cd wxappUnpacker
    npm install
    npm install esprima
    npm install css-tree
    npm install cssbeautify
    npm install vm2
    npm install uglify-es
    npm install js-beautify
  4. 执行反编译,得到小程序源码,可能会出现TypeError: subPackage.pages is not iterabl的报错,忽略即可
    1
    ~$ ./bingo.sh ../__APP__.wxapkg

代码审计

  1. 比较相邻2次请求,发现除请求参数外,只有X-Api-KeyRequest-No不同
    Compare
  2. Request-No盲猜是请求序号,直接在app-server.js中搜索X-Api-Key
    X-Api-Key
  3. 找到a对应的n(465)函数,发现最终调用了i.encrypt方法,p应该是用来计算hashDigest
    n(465)
  4. 分析p(e, t)函数,f数组由以下几部分组成:
  • pathname + query

  • i.REQUEST_HASH_HEADERS中的header按照{key}={value}的格式用,连接

  • PUTPOST请求的bodyJSON.stringify后的字符串

    过滤掉数组中的空字符串,用&&连接剩余部分,然后计算MD5值

p(e,t)
5. 找到i对应的n(392)函数,发现被混淆了;
n(392)
n(392)函数的代码拷贝至n392.js,模拟混淆过程,用正则批量替换得到反混淆后的n391_decrypt.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var n = i => {
return e[i - 224];
};
var r = e;

for (let index = 0; index < e.length; index++) {
try {
if (
773152 == -parseInt(n(243)) / 1 +
(parseInt(n(254)) / 2) * (-parseInt(n(250)) / 3) +
(-parseInt(n(226)) / 4) * (-parseInt(n(237)) / 5) +
parseInt(n(252)) / 6 + parseInt(n(231)) / 7 +
(-parseInt(n(227)) / 8) * (-parseInt(n(248)) / 9) +
(parseInt(n(235)) / 10) * (-parseInt(n(258)) / 11)
)
break;
r.push(r.shift());
} catch (e) {
r.push(r.shift());
}
}

const fs = require('fs');

let data = fs.readFileSync('n392.js', 'utf8');

for (let [index, item] of e.entries()) {
data = data.replace(new RegExp(`r\\(${224 + index}\\)|a\\(${224 + index}\\)`, 'g'), `"${item}"`);
}

console.log(data);
fs.writeFileSync('n392_decrypted.js', data);

  1. 分析反混淆后的n392_decrypt.js,得到AES加密用的KEYIV、计算MD5用的REQUEST_HASH_HEADERS
    n392 decrypt
  • DEFAULT_ENCRYPT_KEY = ""

  • DEFAULT_ENCRYPT_IV = ""

  • REQUEST_HASH_HEADERS = ["X-User-Id", "X-Hos-Id", "X-Auth-Token', "X-Api-Ver"]

    尝试解密一个X-Api-Key的值,得到被AES加密前的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from Crypto.Cipher import AES
    from base64 import b64decode, b64encode
    from Crypto.Util.Padding import pad, unpad

    DEFAULT_ENCRYPT_KEY = b"<DEFAULT_ENCRYPT_KEY>"
    DEFAULT_ENCRYPT_IV = b"<DEFAULT_ENCRYPT_IV>"

    encrypted_b64 = "<X-Api-Key>"

    cipher = AES.new(DEFAULT_ENCRYPT_KEY, AES.MODE_CBC, iv=DEFAULT_ENCRYPT_IV)
    decrypted = unpad(cipher.decrypt(b64decode(encrypted_b64)), AES.block_size).decode("utf-8")

    json.loads(decrypted)后的数据:

    1
    2
    3
    4
    5
    6
    {
    "accessEntry": "local-patient-miniapp",
    "timestamp": 1735565655493,
    "replayNo": "4a43104a-5b76-4e21-9e7f-dee01c45b648",
    "hashDigest": "91e260d5bf94fc1eed38769b6cc71cd6",
    }
  1. 综上,如果想要获取其他时间范围的检查报告,只需修改查询参数后,按照新的查询参数重新计算MD5值,替换被签名的原始数据中的hashDigest后,重新AES加密计算新的<X-Api-Key>即可
    1
    2
    3
    4
    data["hashDigest"] = "<NEW MD5>"
    plain = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
    encrypted = cipher.encrypt(pad(plain.encode("utf-8"), AES.block_size))
    encrypted_b64 = b64encode(encrypted).decode("utf-8")
  2. 参照p(e, t)REQUEST_HASH_HEADERS计算MD5值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import hashlib

    def calculate_md5(input_string):
    md5_hash = hashlib.md5()
    md5_hash.update(input_string.encode("utf-8"))
    return md5_hash.hexdigest()

    headers = {
    "X-User-Id": 12345678,
    "X-Hos-Id": 12345678,
    "X-Auth-Token": '<X-Auth-Token>',
    "X-Api-Ver": "2.26.7",
    }

    lst = list()
    lst.append(
    "/patient/v1/report/record?inspectType=UC&reportType=3&cardNos=xxxxx&startTime=2024-12-24&endTime=2024-12-31"
    )
    lst.append(",".join([f"{k.lower()}={v}" for k, v in headers.items()]))
    string_to_sign = "&&".join(lst)
    md5 = calculate_md5(string_to_sign)

修改请求

  1. 打开BrupSuiteProxy Intercept开关,以拦截小程序的请求
  2. 打开🏥小程序,点击"查报告"
    查询报告
  3. Brup Suite中复制Path+QueryREQUEST_HASH_HEADERS<X-Auth-Token><X-Api-Key>对应的值
  4. 执行Python脚本计算新的<X-Api-Key>,然后更新Brup Suite中的请求参数和X-Api-Key,点击Forward
    Brup Suite forward

参考

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import hashlib
import json
from base64 import b64decode, b64encode

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad


def calculate_md5(input_string):
md5_hash = hashlib.md5()
md5_hash.update(input_string.encode("utf-8"))
return md5_hash.hexdigest()



DEFAULT_ENCRYPT_KEY = b"<DEFAULT_ENCRYPT_KEY>" # 32 字节密钥
DEFAULT_ENCRYPT_IV = b"<DEFAULT_ENCRYPT_IV>" # 16 字节初始化向量

token = "<X-Auth-Token>"
x_user_id = "<X-User-Id>"
x_hos_id = "<X-Hos-Id>"

headers = {
"X-User-Id": x_user_id,
"X-Hos-Id": x_hos_id,
"X-Auth-Token": token,
"X-Api-Ver": "2.26.7",
}
encrypted_b64 = "<X-Api-Key>"

lst = list()
lst.append(
"/patient/v1/report/record?inspectType=UL&reportType=3&cardNos=xxxxxx&startTime=2024-01-01&endTime=2024-12-31"
) # 检验
lst.append(",".join([f"{k.lower()}={v}" for k, v in headers.items()]))
string_to_sign = "&&".join(lst)
md5 = calculate_md5(string_to_sign)
print("MD5", md5)

cipher = AES.new(DEFAULT_ENCRYPT_KEY, AES.MODE_CBC, iv=DEFAULT_ENCRYPT_IV)
decrypted = unpad(cipher.decrypt(b64decode(encrypted_b64)), AES.block_size).decode("utf-8")
print("decrypted", decrypted)

data = json.loads(decrypted)
data["hashDigest"] = md5

# Encrypt
cipher = AES.new(DEFAULT_ENCRYPT_KEY, AES.MODE_CBC, iv=DEFAULT_ENCRYPT_IV)
plain = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
encrypted = cipher.encrypt(pad(plain.encode("utf-8"), AES.block_size))
encrypted_b64 = b64encode(encrypted).decode("utf-8")
print("encrypted_b64", encrypted_b64)
您的支持是我继续创作最大的动力!

欢迎关注我的其它发布渠道