Author SHA1 Message Date
Spade A e6d2706e2b Update README.md 2026-06-07 11:58:03 +08:00
牟不懂哎 a31a7d4d03 Update README.md 2026-06-01 16:40:59 +08:00
牟不懂哎 2f77fabd76 Add files via upload 2026-06-01 16:37:03 +08:00
牟不懂哎 43abf4fe8c v1.1.0 2026-06-01 16:35:53 +08:00
牟不懂哎 c71c22f7d1 v1.1.0 2026-06-01 16:34:09 +08:00
牟不懂哎 970c0bad7d v1.0.9 2026-06-01 11:34:56 +08:00
牟不懂哎 69983bfbf0 v 2026-06-01 11:34:36 +08:00
牟不懂哎 e419752a5c v 2026-06-01 11:33:37 +08:00
牟不懂哎 68598ed26d v1.0.9 2026-06-01 10:41:51 +08:00
牟不懂哎 e7d33319c8 Merge pull request #15 from XingXiaoWu/main
feat: addresses.19823.json
2026-05-24 21:05:02 +08:00
Xing.Wu 6a94b31be7 fix: 修复指针链解析的问题,并从WMPFDebugger同步大佬们的解析 2026-05-14 23:16:08 +08:00
Xing.Wu c7653cc698 feat: addresses.19823.json 2026-05-14 22:50:00 +08:00
39 changed files with 941 additions and 231 deletions
+41 -171
View File
@@ -1,145 +1,68 @@
# First
> 微信小程序安全调试工具 —— 基于 Frida + CDP 代理,支持 Windows / macOS 双平台,GUI 与 CLI 双模式
微信小程序安全调试工具 — Frida + CDP,支持 macOS / Windows
---
> 自v1.1.0版本后开始闭源,往后版本更新只上传编译好的版本,下载最新版请去release下载。
## 截图预览
## 免责声明
### 主界面
本工具仅供安全研究与学习使用,请勿用于未授权的目标,使用者须自行承担相关法律责任。
![主界面](https://s1.galgame.fun/imgb/u0/20260408_69d655f49ca7e.png)
## 功能
### 路由导航
- Frida 注入微信,CDP 代理桥接 Chrome DevTools
- 云函数动态捕获(Hook wx.cloud.callFunction,参数修改重放)
- wx.* API 捕获(login / request / getUserProfile 等)
- 路由枚举与导航,跳转守卫检测
- wxapkg 解密解包 + 敏感信息扫描
- MCP Server 集成(AI 辅助分析)
- 偏移量 / Skills 在线更新
![路由导航](https://s1.galgame.fun/imgb/u0/20260408_69d6560ca1a39.png)
## 截图
### 主页
![11071](https://s1.galgame.fun/imgb/u55/20260601_6a1d43338429a.png)
### 云函数捕获
![11069](https://s1.galgame.fun/imgb/u55/20260601_6a1d4333632a4.png)
### wx.api捕获
![11072](https://s1.galgame.fun/imgb/u55/20260601_6a1d43338219d.png)
### 反编译文件编辑搜索
![11073](https://s1.galgame.fun/imgb/u55/20260601_6a1d433372934.png)
### Frist-MCP
![11070](https://s1.galgame.fun/imgb/u55/20260601_6a1d433358324.png)
![11074](https://s1.galgame.fun/imgb/u55/20260601_6a1d43367a592.png)
### 自动更新
![3915](https://s1.galgame.fun/imgb/u55/20260601_6a1d4537482b8.png)
### 云函数分析
## 下载
![云函数分析](https://s1.galgame.fun/imgb/u0/20260402_69ce527ca5480.png)
[Releases](https://github.com/Spade-sec/First/releases)
### 调试开关(JSRPC / wx.cloud 调用)
![调试开关](https://s1.galgame.fun/imgb/u0/20260408_69d6564195009.png)
### 敏感信息提取
![敏感信息提取-1](https://s1.galgame.fun/imgb/u55/20260416_69e0be0e1c1d7.png)
<img src="https://s1.galgame.fun/imgb/u55/20260416_69e0be0c38e39.png" alt="敏感信息提取-2" />
![敏感信息提取-3](https://s1.galgame.fun/imgb/u55/20260416_69e0be0c70d46.png)
---
## 功能特性
- Frida 动态注入微信客户端,转发小程序调试协议
- CDP 代理桥接,Chrome DevTools 直连调试
- 路由枚举、分类与一键跳转
- 云函数调用监控与参数分析(动态 Hook + 静态扫描)
- UserScript 自动注入(支持 URL 匹配、run-at 时机控制)
- wxapkg 解密解包 + 敏感信息扫描(IP / 密钥 / 云存储 / JWT 等)
- 深色 / 浅色主题切换
- GUI(PySide6)与 CLI 双模式
---
## 环境要求
| 依赖 | 版本 |
| 平台 | 文件 |
|------|------|
| Python | >= 3.10 |
| frida | >= 17.0.0 |
| websockets | >= 12.0 |
| protobuf | >= 4.0.0 |
| PySide6 | >= 6.5.0 |
| pycryptodome | 最新版 |
安装依赖:
```bash
pip install -r requirements.txt
```
---
| macOS (Apple Silicon) | `First-ARM.dmg` |
| macOS (Intel) | `First-Intel.dmg` |
| Windows | `First-Windows.zip` |
## 支持的微信版本
### Windows
- WMPF 版本:11581, 11633, 13331, 13341, 13487, 13639, 13655, 13871, 13909, 14161, 14199, 14315, 16133, 16203, 16389, 16467, 16771, 16815, 16965, 17037, 17071, 17127, 18055, 18151, 18787, 18891, 18955, 19027, 19201
- WMPF 版本:11581, 11633, 13331, 13341, 13487, 13639, 13655, 13871, 13909, 14161, 14199, 14315, 16133, 16203, 16389, 16467, 16771, 16815, 16965, 17037, 17071, 17127, 18055, 18151, 18787, 18891, 18955, 19027, 19201(实时更新)
- 推荐微信版本:**4.1.0.30**
- 下载地址:[weixin/4.1.0.30](https://github.com/vs-olitus/wx-version/releases/tag/4.1.0.30)
### macOS
- WMPF 版本:18152, 18788
- WMPF 版本:18152, 18788(实时更新)
- 推荐微信版本:**4.1.7.30**
- 下载地址:[weixin/4.1.7.30](https://github.com/vs-olitus/wx-version/releases/tag/4.1.7.30)
---
## 快速开始
### Windows
**一键启动:** 双击 `启动.bat`,首次运行会自动安装依赖。
**手动启动:**
```bash
python gui.py
```
### macOS
**一键启动:** 终端执行 `./启动.sh`,首次运行会自动安装依赖。
**手动启动:**
```bash
python3 gui.py
```
启动后在主界面点击 **启动调试**,然后再打开小程序即可(请勿在启动调试前打开小程序)。
### CLI 模式
```bash
# 默认端口启动
python main.py
# 自定义端口
python main.py --cdp-port 62000
# 开启详细日志
python main.py --debug-main --debug-frida
```
### 连接 Chrome DevTools
启动后,在 Chrome 地址栏输入:
```
devtools://devtools/bundled/inspector.html?ws=127.0.0.1:62000
```
---
## 服务号调试操作
1. 打开一个小程序页面作为占位(保持不关闭)
2. 访问需要调试的服务号内容(如 H5 网页)
![PixPin_2026-05-04_22-48-42](https://s1.galgame.fun/imgb/u55/20260504_69f8b299a7d85.png)
3. 在工具栏「服务」目标列表中找到目标网页,双击后切换至调试视图即可开始调试
![PixPin_2026-05-04_22-51-14](https://s1.galgame.fun/imgb/u55/20260504_69f8b299a7d94.png)
![PixPin_2026-05-04_22-51-39](https://s1.galgame.fun/imgb/u55/20260504_69f8b299a804b.png)
---
1. 打开 First
2. 点击启动 — Frida 自动注入微信
3. 在微信中打开小程序
4. 使用 DevTools / 云捕获 / 导航等功能
## macOS 注意事项
@@ -155,53 +78,7 @@ devtools://devtools/bundled/inspector.html?ws=127.0.0.1:62000
sudo codesign --force --deep --sign - /Applications/WeChat.app
```
---
## 参数说明
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--debug-port` | `9421` | 远程调试服务端口 |
| `--cdp-port` | `62000` | CDP 代理监听端口 |
| `--debug-main` | 关闭 | 输出主进程调试日志 |
| `--debug-frida` | 关闭 | 输出 Frida 客户端日志 |
| `--scripts-dir` | `./userscripts` | UserScript 目录路径 |
| `--script` | — | 指定单个 .js 文件注入(可多次使用) |
---
## UserScript 注入
将 `.js` 脚本放入 `userscripts/` 目录,启动时自动加载并注入。也可手动指定:
```bash
python main.py --script ./my_hook.js --script ./another.js
```
---
## 打包为可执行文件
```bash
pyinstaller WMPFDebugger.spec
```
---
## 常见问题
**Q: Frida 已连接,但小程序端显示未连接或无法断点调试?**
确认操作顺序无误后,尝试以下步骤:
1. 彻底卸载微信并重启电脑(重要聊天记录请提前备份)
2. 删除 `C:\Users\用户名\AppData\Roaming\Tencent\xwechat\XPlugin\Plugins\RadiumWMPF` 下所有数字命名的文件夹
3. 再次重启后安装微信 4.1.0.30 版本
4. 检查上述路径,确认文件夹编号为 `16389`
---
## 参考项目
## 参考
- [evi0s/WMPFDebugger](https://github.com/evi0s/WMPFDebugger)
- [0xsdeo/HeartK](https://github.com/0xsdeo/HeartK)
@@ -209,22 +86,15 @@ pyinstaller WMPFDebugger.spec
- [进击的HACK / JSRPC 与调用 wx.cloud](https://mp.weixin.qq.com/s/hTlekrCPiMJCvsHYx7CAxw)
- [linguo2625469/WMPFDebugger-mac](https://github.com/linguo2625469/WMPFDebugger-mac)
---
## 致谢
感谢 **0xsdeo** 师傅的大力支持与思路提供。
---
## 交流群
群满 200 人后需要手动邀请,请加我拉群:
![微信二维码](https://s1.galgame.fun/imgb/u55/20260413_69dcaf1310fc4.jpg)
---
## 免责声明
本工具仅供安全研究与学习使用,请勿用于未授权的目标,使用者须自行承担相关法律责任。
<img src="https://s1.galgame.fun/imgb/u55/20260601_6a1d196618084.png" alt="微信二维码" width="300" />
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 11581,
"LoadStartHookOffset": "0x28E9190",
"CDPFilterHookOffset": "0x38C4350",
"SceneOffsets": [1208, 1160, 488]
"SceneOffsets": [56, 1208, 8, 1160, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 11633,
"LoadStartHookOffset": "0x28F22A0",
"CDPFilterHookOffset": "0x38D41E0",
"SceneOffsets": [1208, 1160, 488]
"SceneOffsets": [56, 1208, 8, 1160, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13331,
"LoadStartHookOffset": "0x0FFC200",
"CDPFilterHookOffset": "0x2420100",
"SceneOffsets": [1272, 1224, 488]
"SceneOffsets": [56, 1272, 8, 1224, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13341,
"LoadStartHookOffset": "0x10009E0",
"CDPFilterHookOffset": "0x242E8E0",
"SceneOffsets": [1272, 1224, 488]
"SceneOffsets": [56, 1272, 8, 1224, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13487,
"LoadStartHookOffset": "0x0FFB600",
"CDPFilterHookOffset": "0x241FEB0",
"SceneOffsets": [1272, 1224, 488]
"SceneOffsets": [56, 1272, 8, 1224, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13639,
"LoadStartHookOffset": "0x1000990",
"CDPFilterHookOffset": "0x2424DE0",
"SceneOffsets": [1272, 1224, 488]
"SceneOffsets": [56, 1272, 8, 1224, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13655,
"LoadStartHookOffset": "0x100F4B0",
"CDPFilterHookOffset": "0x244A9E0",
"SceneOffsets": [1280, 1232, 488]
"SceneOffsets": [56, 1280, 8, 1232, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13871,
"LoadStartHookOffset": "0x101F160",
"CDPFilterHookOffset": "0x246FC40",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 13909,
"LoadStartHookOffset": "0x101F0E0",
"CDPFilterHookOffset": "0x246FDC0",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 14161,
"LoadStartHookOffset": "0x10246C0",
"CDPFilterHookOffset": "0x24839B0",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 14199,
"LoadStartHookOffset": "0x10246F0",
"CDPFilterHookOffset": "0x24839E0",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 14315,
"LoadStartHookOffset": "0x10004C0",
"CDPFilterHookOffset": "0x2424B50",
"SceneOffsets": [1272, 1224, 488]
"SceneOffsets": [56, 1272, 8, 1224, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16133,
"LoadStartHookOffset": "0x470FAD0",
"CDPFilterHookOffset": "0x90FC7E0",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16203,
"LoadStartHookOffset": "0x4710890",
"CDPFilterHookOffset": "0x90FD640",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16389,
"LoadStartHookOffset": "0x24E4830",
"CDPFilterHookOffset": "0x2E2A880",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16467,
"LoadStartHookOffset": "0x24E4FD0",
"CDPFilterHookOffset": "0x2E2CC90",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16771,
"LoadStartHookOffset": "0x24E9130",
"CDPFilterHookOffset": "0x2E3C470",
"SceneOffsets": [1360, 1312, 488]
"SceneOffsets": [56, 1360, 8, 1312, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16815,
"LoadStartHookOffset": "0x2509690",
"CDPFilterHookOffset": "0x2E768D0",
"SceneOffsets": [1416, 1360, 488]
"SceneOffsets": [56, 1416, 8, 1360, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 16965,
"LoadStartHookOffset": "0x2570220",
"CDPFilterHookOffset": "0x2F844A0",
"SceneOffsets": [1416, 1360, 488]
"SceneOffsets": [56, 1416, 8, 1360, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 17037,
"LoadStartHookOffset": "0x257D0A0",
"CDPFilterHookOffset": "0x2FB2310",
"SceneOffsets": [1408, 1352, 488]
"SceneOffsets": [56, 1408, 8, 1352, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 17071,
"LoadStartHookOffset": "0x258F370",
"CDPFilterHookOffset": "0x2FD3080",
"SceneOffsets": [1408, 1352, 488]
"SceneOffsets": [56, 1408, 8, 1352, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 17127,
"LoadStartHookOffset": "0x2590910",
"CDPFilterHookOffset": "0x2FD4040",
"SceneOffsets": [1408, 1352, 488]
"SceneOffsets": [56, 1408, 8, 1352, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 18055,
"LoadStartHookOffset": "0x25A1040",
"CDPFilterHookOffset": "0x30031E0",
"SceneOffsets": [1416, 1352, 488]
"SceneOffsets": [56, 1416, 8, 1352, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 18151,
"LoadStartHookOffset": "0x25A2E20",
"CDPFilterHookOffset": "0x3013D20",
"SceneOffsets": [1416, 1352, 488]
"SceneOffsets": [56, 1416, 8, 1352, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 18787,
"LoadStartHookOffset": "0x25B2870",
"CDPFilterHookOffset": "0x3028AD0",
"SceneOffsets": [1408, 1344, 488]
"SceneOffsets": [56, 1408, 8, 1344, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 18891,
"LoadStartHookOffset": "0x25B50C0",
"CDPFilterHookOffset": "0x30245E0",
"SceneOffsets": [1408, 1344, 488]
"SceneOffsets": [56, 1408, 8, 1344, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 18955,
"LoadStartHookOffset": "0x25B52C0",
"CDPFilterHookOffset": "0x30248B0",
"SceneOffsets": [1408, 1344, 488]
"SceneOffsets": [56, 1408, 8, 1344, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 19027,
"LoadStartHookOffset": "0x25B52D0",
"CDPFilterHookOffset": "0x3024AD0",
"SceneOffsets": [1408, 1344, 488]
"SceneOffsets": [56, 1408, 8, 1344, 16, 488]
}
+1 -1
View File
@@ -2,5 +2,5 @@
"Version": 19201,
"LoadStartHookOffset": "0x25B5DD0",
"CDPFilterHookOffset": "0x301B3C0",
"SceneOffsets": [1376, 1312, 456]
"SceneOffsets": [56, 1376, 8, 1312, 16, 456]
}
+6
View File
@@ -0,0 +1,6 @@
{
"Version": 19339,
"LoadStartHookOffset": "0x25B5DD0",
"CDPFilterHookOffset": "0x301BA00",
"SceneOffsets": [56, 1376, 8, 1312, 16, 456]
}
+6
View File
@@ -0,0 +1,6 @@
{
"Version": 19459,
"LoadStartHookOffset": "0x25BB580",
"CDPFilterHookOffset": "0x3022F20",
"SceneOffsets": [56, 1376, 8, 1312, 16, 456]
}
+6
View File
@@ -0,0 +1,6 @@
{
"Version": 19481,
"LoadStartHookOffset": "0x25BBA80",
"CDPFilterHookOffset": "0x3023420",
"SceneOffsets": [56, 1376, 8, 1312, 16, 456]
}
+6
View File
@@ -0,0 +1,6 @@
{
"Version": 19749,
"LoadStartHookOffset": "0x25E77C0",
"CDPFilterHookOffset": "0x30ABC60",
"SceneOffsets": [64, 1408, 8, 1344, 16, 456]
}
+6
View File
@@ -0,0 +1,6 @@
{
"Version": 19769,
"LoadStartHookOffset": "0x25E7990",
"CDPFilterHookOffset": "0x30AC0A0",
"SceneOffsets": [64, 1408, 8, 1344, 16, 456]
}
+6
View File
@@ -0,0 +1,6 @@
{
"Version": 19823,
"LoadStartHookOffset": "0x25ED1C0",
"CDPFilterHookOffset": "0x30B3320",
"SceneOffsets": [64, 1408, 8, 1344, 16, 456]
}
+14 -3
View File
@@ -124,8 +124,19 @@ const winPatchLoadStart = (base, config) => {
// Scene hijack via SceneOffsets pointer chain
try {
const offsets = config.SceneOffsets;
const ptr1 = this.context.rcx.add(56).readPointer().add(offsets[0]).readPointer();
const scenePtr = ptr1.add(8).readPointer().add(offsets[1]).readPointer().add(16).readPointer().add(488);
const miniappConfigPtr = this.context.rcx
.add(offsets[0])
.readPointer()
.add(offsets[1])
.readPointer();
const scenePtr = miniappConfigPtr
.add(offsets[2])
.readPointer()
.add(offsets[3])
.readPointer()
.add(offsets[4])
.readPointer()
.add(offsets[5]);
const scene = scenePtr.readInt();
send(`[hook] scene: ${scene}`);
if (SCENE_WHITELIST.includes(scene)) {
@@ -164,7 +175,7 @@ const parseConfig = () => {
Version: 18955,
LoadStartHookOffset: "0x25B52C0",
CDPFilterHookOffset: "0x30248B0",
SceneOffsets: [1408, 1344, 488],
SceneOffsets: [56, 1408, 8, 1344, 16, 488],
};
}
return JSON.parse(rawConfig);
+581 -18
View File
@@ -30,7 +30,6 @@ from src.logger import Logger
from src.engine import DebugEngine
from src.navigator import MiniProgramNavigator
from src.cloud_audit import CloudAuditor
# ══════════════════════════════════════════
# 配色
# ══════════════════════════════════════════
@@ -64,6 +63,7 @@ _MENU = [
("hook", "◈", "Hook"),
("targets", "◎", "服务目标"),
("cloud", "☁", "云扫描"),
("wxapi", "⬢", "WxAPI捕获"),
("extract", "◆", "敏感信息提取"),
("vconsole", "◇", "调试开关"),
("logs", "≡", "运行日志"),
@@ -706,6 +706,12 @@ class App(QMainWindow):
self._cloud_call_history = {}
self._cloud_all_items = []
self._cloud_row_results = {}
self._cloud_live_items = [] # 动态捕获完整 call 对象列表
self._wxapi_auditor = None
self._wxapi_items = []
self._wxapi_active = False
self._wxapi_poll_timer = None
self._wxapi_selected_idx = -1
self._cancel_ev = None
self._route_poll_id = None
self._all_routes = []
@@ -724,6 +730,7 @@ class App(QMainWindow):
self._sts_q = queue.Queue()
self._rte_q = queue.Queue()
self._cld_q = queue.Queue()
self._wxapi_q = queue.Queue()
self._tgt_q = queue.Queue()
self._nav_route_idx = -1
@@ -869,7 +876,7 @@ class App(QMainWindow):
QDesktopServices.openUrl(QUrl("https://github.com/Spade-sec/First")),
self._log_add("info", "[gui] 已打开 GitHub 页面"))
sb_lay.addWidget(sb_gh)
sb_ver = QLabel("v1.0.8")
sb_ver = QLabel("v1.0.9")
sb_ver.setObjectName("sb_theme")
sb_ver.setAlignment(Qt.AlignCenter)
sb_ver.setFont(_qfn(-2))
@@ -914,6 +921,7 @@ class App(QMainWindow):
self._build_hook()
self._build_targets()
self._build_cloud()
self._build_wxapi()
self._build_extract()
self._build_vconsole()
self._build_logs()
@@ -1471,12 +1479,22 @@ class App(QMainWindow):
def _build_cloud(self):
page = QWidget()
lay = QVBoxLayout(page)
lay.setContentsMargins(24, 12, 24, 16)
lay.setSpacing(10)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
# 内嵌 QStackedWidget: 主视图(0) + 动态捕获视图(1)
self._cloud_stack = QStackedWidget()
lay.addWidget(self._cloud_stack)
# ── 主视图 (index 0) ──
main_page = QWidget()
main_lay = QVBoxLayout(main_page)
main_lay.setContentsMargins(24, 12, 24, 16)
main_lay.setSpacing(10)
ctrl = QHBoxLayout()
self._btn_cloud_toggle = _make_btn("停止捕获", self._cloud_do_toggle)
ctrl.addWidget(self._btn_cloud_toggle)
self._btn_cloud_live = _make_btn("动态捕获", self._cloud_show_live)
ctrl.addWidget(self._btn_cloud_live)
self._btn_cloud_static = _make_btn("静态扫描", self._cloud_do_static_scan)
ctrl.addWidget(self._btn_cloud_static)
self._btn_cloud_clear = _make_btn("清空记录", self._cloud_do_clear)
@@ -1486,7 +1504,7 @@ class App(QMainWindow):
ctrl.addStretch()
self._btn_cloud_export = _make_btn("导出报告", self._cloud_do_export)
ctrl.addWidget(self._btn_cloud_export)
lay.addLayout(ctrl)
main_lay.addLayout(ctrl)
tc = _make_card()
tc_lay = QVBoxLayout(tc)
@@ -1529,7 +1547,7 @@ class App(QMainWindow):
self._cloud_tree.setContextMenuPolicy(Qt.CustomContextMenu)
self._cloud_tree.customContextMenuRequested.connect(self._cloud_tree_context_menu)
tc_lay.addWidget(self._cloud_tree)
lay.addWidget(tc, 1)
main_lay.addWidget(tc, 1)
call_row = QHBoxLayout()
call_row.addWidget(QLabel("手动调用"))
@@ -1541,23 +1559,223 @@ class App(QMainWindow):
call_row.addWidget(self._cloud_data_ent, 1)
self._btn_cloud_call = _make_btn("调用", self._cloud_do_call)
call_row.addWidget(self._btn_cloud_call)
lay.addLayout(call_row)
main_lay.addLayout(call_row)
self._cloud_result = QTextEdit()
self._cloud_result.setReadOnly(True)
self._cloud_result.setFixedHeight(120)
self._cloud_result.setFont(_qfm())
lay.addWidget(self._cloud_result)
main_lay.addWidget(self._cloud_result)
bot = QHBoxLayout()
self._cloud_status_lbl = QLabel("捕获: 0 条")
bot.addWidget(self._cloud_status_lbl)
bot.addStretch()
lay.addLayout(bot)
main_lay.addLayout(bot)
self._cloud_stack.addWidget(main_page) # index 0: 主视图
# ── 动态捕获视图 (index 1) ──
self._build_cloud_live_page()
self._stack.addWidget(page)
self._page_map["cloud"] = self._stack.count() - 1
def _build_cloud_live_page(self):
"""构建动态捕获子页面 (cloud_stack index 1)"""
live_page = QWidget()
live_lay = QVBoxLayout(live_page)
live_lay.setContentsMargins(24, 12, 24, 16)
live_lay.setSpacing(10)
# 顶部控制栏
top_bar = QHBoxLayout()
self._btn_cloud_live_back = _make_btn("← 返回", self._cloud_live_back)
top_bar.addWidget(self._btn_cloud_live_back)
top_bar.addWidget(_make_label("动态捕获", bold=True))
self._cloud_live_status_lbl = QLabel("捕获中...")
top_bar.addWidget(self._cloud_live_status_lbl)
top_bar.addStretch()
self._cloud_live_search_ent = _make_entry(width=160)
self._cloud_live_search_ent.setPlaceholderText("搜索...")
self._cloud_live_search_ent.textChanged.connect(self._cloud_live_filter)
top_bar.addWidget(self._cloud_live_search_ent)
self._btn_cloud_live_clear = _make_btn("清空", self._cloud_live_clear)
top_bar.addWidget(self._btn_cloud_live_clear)
live_lay.addLayout(top_bar)
# 左右分栏
splitter = QHBoxLayout()
# 左侧: 捕获列表
left_card = _make_card()
left_lay = QVBoxLayout(left_card)
left_lay.setContentsMargins(8, 8, 8, 8)
left_lay.setSpacing(4)
self._cloud_live_tree = QTreeWidget()
self._cloud_live_tree.setFont(_qfn())
self._cloud_live_tree.header().setFont(_qfn())
self._cloud_live_tree.setRootIsDecorated(False)
self._cloud_live_tree.setIndentation(0)
self._cloud_live_tree.setHeaderLabels(["类型", "名称", "状态", "时间"])
lh = self._cloud_live_tree.header()
lh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
lh.setStretchLastSection(False)
lh.setSectionResizeMode(0, QHeaderView.Interactive)
lh.setSectionResizeMode(1, QHeaderView.Stretch)
lh.setSectionResizeMode(2, QHeaderView.Interactive)
lh.setSectionResizeMode(3, QHeaderView.Interactive)
self._cloud_live_tree.setColumnWidth(0, 70)
self._cloud_live_tree.setColumnWidth(2, 50)
self._cloud_live_tree.setColumnWidth(3, 70)
self._cloud_live_tree.setSelectionMode(QAbstractItemView.SingleSelection)
self._cloud_live_tree.itemClicked.connect(self._cloud_live_select)
left_lay.addWidget(self._cloud_live_tree)
splitter.addWidget(left_card, 2)
# 右侧: 详情面板
right_card = _make_card()
right_lay = QVBoxLayout(right_card)
right_lay.setContentsMargins(12, 8, 12, 8)
right_lay.setSpacing(6)
right_lay.addWidget(_make_label("请求参数", bold=True))
self._cloud_live_req = QTextEdit()
self._cloud_live_req.setReadOnly(True)
self._cloud_live_req.setFont(_qfm())
self._cloud_live_req.setPlaceholderText("选择左侧记录查看请求参数")
right_lay.addWidget(self._cloud_live_req, 1)
right_lay.addWidget(_make_label("返回结果", bold=True))
self._cloud_live_resp = QTextEdit()
self._cloud_live_resp.setReadOnly(True)
self._cloud_live_resp.setFont(_qfm())
self._cloud_live_resp.setPlaceholderText("选择左侧记录查看返回结果")
right_lay.addWidget(self._cloud_live_resp, 1)
btn_row = QHBoxLayout()
btn_row.addStretch()
self._btn_cloud_live_replay = _make_btn("一键重放", self._cloud_live_replay)
self._btn_cloud_live_replay.setEnabled(False)
btn_row.addWidget(self._btn_cloud_live_replay)
right_lay.addLayout(btn_row)
splitter.addWidget(right_card, 3)
live_lay.addLayout(splitter, 1)
self._cloud_stack.addWidget(live_page) # index 1: 动态捕获视图
self._cloud_live_selected_idx = -1
# ── WxAPI 捕获 ──
def _build_wxapi(self):
page = QWidget()
lay = QVBoxLayout(page)
lay.setContentsMargins(24, 12, 24, 16)
lay.setSpacing(10)
# 顶部控制栏
top_bar = QHBoxLayout()
top_bar.addWidget(_make_label("WxAPI 捕获", bold=True))
self._btn_wxapi_toggle = _make_btn("开启捕获", self._wxapi_toggle)
top_bar.addWidget(self._btn_wxapi_toggle)
self._wxapi_status_lbl = QLabel("已关闭")
top_bar.addWidget(self._wxapi_status_lbl)
top_bar.addStretch()
# 原生桥接开关(默认关闭)
self._wxapi_bridge_cb = QCheckBox("原生桥接")
self._wxapi_bridge_cb.setChecked(False)
self._wxapi_bridge_cb.setToolTip("捕获 WeixinJSBridge 底层调用(会产生大量噪音)")
top_bar.addWidget(self._wxapi_bridge_cb)
# wx.request 开关(默认关闭)
self._wxapi_request_cb = QCheckBox("wx.request")
self._wxapi_request_cb.setChecked(False)
self._wxapi_request_cb.setToolTip("捕获 wx.request 网络请求(数量较多)")
top_bar.addWidget(self._wxapi_request_cb)
# wx.storage 开关(默认关闭)
self._wxapi_storage_cb = QCheckBox("缓存")
self._wxapi_storage_cb.setChecked(False)
self._wxapi_storage_cb.setToolTip("捕获 wx.setStorage/getStorage 等缓存操作")
top_bar.addWidget(self._wxapi_storage_cb)
self._wxapi_search_ent = _make_entry(width=160)
self._wxapi_search_ent.setPlaceholderText("搜索...")
self._wxapi_search_ent.textChanged.connect(self._wxapi_filter)
top_bar.addWidget(self._wxapi_search_ent)
self._btn_wxapi_clear = _make_btn("清空", self._wxapi_clear)
top_bar.addWidget(self._btn_wxapi_clear)
lay.addLayout(top_bar)
# 左右分栏
splitter = QHBoxLayout()
# 左侧: 捕获列表
left_card = _make_card()
left_lay = QVBoxLayout(left_card)
left_lay.setContentsMargins(8, 8, 8, 8)
left_lay.setSpacing(4)
self._wxapi_tree = QTreeWidget()
self._wxapi_tree.setFont(_qfn())
self._wxapi_tree.header().setFont(_qfn())
self._wxapi_tree.setRootIsDecorated(False)
self._wxapi_tree.setIndentation(0)
self._wxapi_tree.setHeaderLabels(["类型", "名称", "状态", "时间"])
wh = self._wxapi_tree.header()
wh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
wh.setStretchLastSection(False)
wh.setSectionResizeMode(0, QHeaderView.Interactive)
wh.setSectionResizeMode(1, QHeaderView.Stretch)
wh.setSectionResizeMode(2, QHeaderView.Interactive)
wh.setSectionResizeMode(3, QHeaderView.Interactive)
self._wxapi_tree.setColumnWidth(0, 90)
self._wxapi_tree.setColumnWidth(2, 60)
self._wxapi_tree.setColumnWidth(3, 80)
self._wxapi_tree.setSelectionMode(QAbstractItemView.SingleSelection)
self._wxapi_tree.itemClicked.connect(self._wxapi_select)
left_lay.addWidget(self._wxapi_tree)
splitter.addWidget(left_card, 2)
# 右侧: 详情面板
right_card = _make_card()
right_lay = QVBoxLayout(right_card)
right_lay.setContentsMargins(12, 8, 12, 8)
right_lay.setSpacing(6)
# 类型 + 名称标题
self._wxapi_detail_title = _make_label("", bold=True)
self._wxapi_detail_title.setWordWrap(True)
right_lay.addWidget(self._wxapi_detail_title)
right_lay.addWidget(_make_label("请求参数", bold=False))
self._wxapi_req = QTextEdit()
self._wxapi_req.setReadOnly(True)
self._wxapi_req.setFont(_qfm())
self._wxapi_req.setPlaceholderText("选择左侧记录查看请求参数")
self._wxapi_req.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
right_lay.addWidget(self._wxapi_req, 1)
right_lay.addWidget(_make_label("返回结果", bold=False))
self._wxapi_resp = QTextEdit()
self._wxapi_resp.setReadOnly(True)
self._wxapi_resp.setFont(_qfm())
self._wxapi_resp.setPlaceholderText("选择左侧记录查看返回结果")
self._wxapi_resp.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
right_lay.addWidget(self._wxapi_resp, 1)
btn_row = QHBoxLayout()
btn_row.addStretch()
self._btn_wxapi_replay = _make_btn("一键重放", self._wxapi_replay)
self._btn_wxapi_replay.setEnabled(False)
btn_row.addWidget(self._btn_wxapi_replay)
right_lay.addLayout(btn_row)
splitter.addWidget(right_card, 3)
lay.addLayout(splitter, 1)
self._stack.addWidget(page)
self._page_map["wxapi"] = self._stack.count() - 1
# ── 敏感信息提取 ──
def _build_extract(self):
@@ -3219,6 +3437,11 @@ class App(QMainWindow):
if self._cloud_scan_poll_timer:
self._cloud_scan_poll_timer.stop()
self._cloud_scan_poll_timer = None
if self._wxapi_active:
self._wxapi_active = False
if self._wxapi_poll_timer:
self._wxapi_poll_timer.stop()
self._wxapi_poll_timer = None
if self._cancel_ev:
self._cancel_ev.set()
if self._engine and self._loop and self._loop.is_running():
@@ -3326,6 +3549,7 @@ class App(QMainWindow):
if not self._cloud_scan_active and self._auditor:
self._cloud_start_scan()
self._log_add("info", "[云扫描] 小程序连接后自动恢复捕获")
# 自动启动 WxAPI 捕获(不自动启动,用户手动开启)
def _delayed_fetch_app_info(self, gen):
"""延迟调用,只有最后一次触发的 gen 匹配才执行。"""
@@ -3715,19 +3939,120 @@ class App(QMainWindow):
return True
def _cloud_do_toggle(self):
"""兼容:现在改为切换到动态捕获页面"""
self._cloud_show_live()
def _cloud_show_live(self):
"""切换到动态捕获子页面"""
self._cloud_stack.setCurrentIndex(1)
# 确保捕获已启动
if not self._cloud_scan_active:
self._cloud_start_scan()
self._cloud_live_update_status()
def _cloud_live_back(self):
"""返回主视图"""
self._cloud_stack.setCurrentIndex(0)
def _cloud_live_select(self, item):
"""点击动态捕获列表项,显示详情"""
idx = self._cloud_live_tree.indexOfTopLevelItem(item)
if idx < 0 or idx >= len(self._cloud_live_items):
return
self._cloud_live_selected_idx = idx
call = self._cloud_live_items[idx]
# 显示请求参数
req_data = call.get("data", {})
self._cloud_live_req.setPlainText(
json.dumps(req_data, ensure_ascii=False, indent=2, default=str))
# 显示返回结果
c = _TH[self._tn]
status = call.get("status", "")
if call.get("result") is not None:
resp_text = json.dumps(call["result"], ensure_ascii=False, indent=2, default=str)
self._cloud_live_resp.setHtml(
f'<span style="color:{c["success"]}">{resp_text}</span>')
elif call.get("error") is not None:
err_text = json.dumps(call["error"], ensure_ascii=False, indent=2, default=str)
self._cloud_live_resp.setHtml(
f'<span style="color:{c["error"]}">{err_text}</span>')
else:
self._cloud_live_resp.setPlainText(f"状态: {status} (无返回数据)")
self._btn_cloud_live_replay.setEnabled(True)
def _cloud_live_replay(self):
"""一键重放选中的云函数调用"""
idx = self._cloud_live_selected_idx
if idx < 0 or idx >= len(self._cloud_live_items):
return
if not self._cloud_ensure_auditor():
return
call = self._cloud_live_items[idx]
name = call.get("name", "")
data = call.get("data", {})
if not name:
return
self._btn_cloud_live_replay.setEnabled(False)
self._cloud_live_resp.setPlainText(f"正在重放 {name} ...")
asyncio.run_coroutine_threadsafe(
self._acloud_live_replay(name, data), self._loop)
async def _acloud_live_replay(self, name, data):
try:
res = await self._auditor.call_function(name, data)
self._cld_q.put(("live_replay_result", name, res))
except Exception as e:
self._cld_q.put(("live_replay_result", name,
{"ok": False, "status": "fail", "error": str(e)}))
def _cloud_live_clear(self):
"""清空动态捕获列表"""
self._cloud_live_items.clear()
self._cloud_live_tree.clear()
self._cloud_live_req.clear()
self._cloud_live_resp.clear()
self._btn_cloud_live_replay.setEnabled(False)
self._cloud_live_selected_idx = -1
self._cloud_live_update_status()
def _cloud_live_filter(self):
"""动态捕获列表搜索过滤"""
kw = self._cloud_live_search_ent.text().strip().lower()
self._cloud_live_tree.clear()
_type_cn = {"function": "云函数", "storage": "存储", "container": "容器",
"wx.auth": "认证", "wx.request": "请求", "wx.pay": "支付",
"wx.storage": "缓存", "wx.share": "分享", "wx.location": "位置",
"wx.device": "设备", "wx.nav": "导航", "wx.media": "媒体",
"wx.clipboard": "剪贴板", "wx.bridge": "原生桥接"}
for call in self._cloud_live_items:
ctype = call.get("type", "function")
type_label = _type_cn.get(ctype, ctype)
if ctype.startswith("db"):
type_label = "数据库"
name = call.get("name", "")
status = call.get("status", "")
ts = call.get("timestamp", "")
if kw and kw not in type_label.lower() and kw not in name.lower():
continue
tree_item = QTreeWidgetItem([type_label, name, status, str(ts)])
self._cloud_live_tree.addTopLevelItem(tree_item)
def _cloud_live_update_status(self):
count = len(self._cloud_live_items)
if self._cloud_scan_active:
self._cloud_stop_scan()
self._cloud_live_status_lbl.setText(f"捕获中... {count} 条")
c = _TH[self._tn]
self._cloud_live_status_lbl.setStyleSheet(f"color: {c['success']};")
else:
self._cloud_start_scan()
self._cloud_live_status_lbl.setText(f"已停止 ({count} 条)")
c = _TH[self._tn]
self._cloud_live_status_lbl.setStyleSheet(f"color: {c['text3']};")
def _cloud_start_scan(self):
if not self._cloud_ensure_auditor():
return
self._cloud_scan_active = True
c = _TH[self._tn]
self._btn_cloud_toggle.setText("停止捕获")
self._cloud_scan_lbl.setText("捕获中...")
self._cloud_scan_lbl.setStyleSheet(f"color: {c['success']};")
self._log_add("info", "[云扫描] 全局捕获已启动")
@@ -3743,7 +4068,6 @@ class App(QMainWindow):
def _cloud_stop_scan(self):
self._cloud_scan_active = False
c = _TH[self._tn]
self._btn_cloud_toggle.setText("开启捕获")
self._cloud_scan_lbl.setText("已停止")
self._cloud_scan_lbl.setStyleSheet(f"color: {c['text3']};")
if self._cloud_scan_poll_timer:
@@ -3834,6 +4158,209 @@ class App(QMainWindow):
except Exception as e:
self._log_add("error", f"[云扫描] 导出失败: {e}")
# ──────────────────────────────────
# WxAPI 捕获逻辑
# ──────────────────────────────────
def _wxapi_ensure(self):
if not self._engine or not self._loop or not self._loop.is_running():
self._log_add("error", "[WxAPI] 请先启动调试")
return False
if not self._wxapi_auditor:
self._wxapi_auditor = WxApiAuditor(self._engine)
return True
def _wxapi_toggle(self):
if self._wxapi_active:
self._wxapi_stop()
self._btn_wxapi_toggle.setText("开启捕获")
c = _TH[self._tn]
self._wxapi_status_lbl.setText("已关闭")
self._wxapi_status_lbl.setStyleSheet(f"color: {c['text3']};")
else:
self._wxapi_start()
self._btn_wxapi_toggle.setText("停止捕获")
def _wxapi_start(self):
if not self._wxapi_ensure():
return
if self._wxapi_active:
return
self._wxapi_active = True
c = _TH[self._tn]
self._wxapi_status_lbl.setText("捕获中...")
self._wxapi_status_lbl.setStyleSheet(f"color: {c['success']};")
self._log_add("info", "[WxAPI] 捕获已启动")
asyncio.run_coroutine_threadsafe(self._awxapi_start(), self._loop)
self._wxapi_poll()
async def _awxapi_start(self):
try:
await self._wxapi_auditor.start()
except Exception as e:
self._log_q.put(("error", f"[WxAPI] Hook 启动异常: {e}"))
def _wxapi_stop(self):
self._wxapi_active = False
if self._wxapi_poll_timer:
self._wxapi_poll_timer.stop()
self._wxapi_poll_timer = None
if self._wxapi_auditor and self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(self._wxapi_auditor.stop(), self._loop)
def _wxapi_poll(self):
if not self._wxapi_active or not self._wxapi_auditor:
return
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(self._awxapi_poll(), self._loop)
self._wxapi_poll_timer = QTimer()
self._wxapi_poll_timer.setSingleShot(True)
self._wxapi_poll_timer.timeout.connect(self._wxapi_poll)
self._wxapi_poll_timer.start(2000)
async def _awxapi_poll(self):
try:
new_calls = await self._wxapi_auditor.poll()
if new_calls:
self._wxapi_q.put(("new_calls", new_calls))
except Exception:
pass
def _wxapi_select(self, item):
idx = self._wxapi_tree.indexOfTopLevelItem(item)
if idx < 0 or idx >= len(self._wxapi_items):
return
self._wxapi_selected_idx = idx
call = self._wxapi_items[idx]
# 显示类型 + 名称标题
ctype = call.get("type", "wx.api")
name = call.get("name", "")
self._wxapi_detail_title.setText(f"[{ctype}] {name}")
# 请求参数 - 使用 setPlainText 确保可复制
req_data = call.get("data", {})
self._wxapi_req.setPlainText(
json.dumps(req_data, ensure_ascii=False, indent=2, default=str))
# 返回结果
if call.get("result") is not None:
resp_text = json.dumps(call["result"], ensure_ascii=False, indent=2, default=str)
self._wxapi_resp.setPlainText(resp_text)
elif call.get("error") is not None:
err_text = json.dumps(call["error"], ensure_ascii=False, indent=2, default=str)
self._wxapi_resp.setPlainText(err_text)
else:
self._wxapi_resp.setPlainText(f"状态: {call.get('status', '')} (无返回数据)")
self._btn_wxapi_replay.setEnabled(True)
def _wxapi_replay(self):
idx = self._wxapi_selected_idx
if idx < 0 or idx >= len(self._wxapi_items):
return
if not self._wxapi_ensure():
return
call = self._wxapi_items[idx]
# 从 name 中提取原始 API 名称
api_name = call.get("name", "")
# wx.request 的 name 格式是 "GET https://..." 需要用 "request"
if call.get("type") == "wx.request":
api_name = "request"
data = call.get("data", {})
self._btn_wxapi_replay.setEnabled(False)
self._wxapi_resp.setPlainText(f"正在重放 {api_name} ...")
asyncio.run_coroutine_threadsafe(
self._awxapi_replay(api_name, data), self._loop)
async def _awxapi_replay(self, api_name, data):
try:
res = await self._wxapi_auditor.replay(api_name, data)
self._wxapi_q.put(("replay_result", api_name, res))
except Exception as e:
self._wxapi_q.put(("replay_result", api_name,
{"ok": False, "status": "fail", "error": str(e)}))
def _wxapi_clear(self):
self._wxapi_items.clear()
self._wxapi_tree.clear()
self._wxapi_req.clear()
self._wxapi_resp.clear()
self._btn_wxapi_replay.setEnabled(False)
self._wxapi_selected_idx = -1
self._wxapi_status_lbl.setText(f"捕获中... 0 条" if self._wxapi_active else "已停止")
if self._wxapi_auditor and self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(self._wxapi_auditor.clear(), self._loop)
def _wxapi_filter(self):
kw = self._wxapi_search_ent.text().strip().lower()
self._wxapi_tree.clear()
_type_cn = {"wx.auth": "认证", "wx.request": "请求", "wx.pay": "支付",
"wx.storage": "缓存", "wx.share": "分享", "wx.location": "位置",
"wx.device": "设备", "wx.nav": "导航", "wx.media": "媒体",
"wx.clipboard": "剪贴板"}
for call in self._wxapi_items:
ctype = call.get("type", "wx.api")
type_label = _type_cn.get(ctype, ctype)
name = call.get("name", "")
status = call.get("status", "")
ts = call.get("timestamp", "")
if kw and kw not in type_label.lower() and kw not in name.lower():
continue
tree_item = QTreeWidgetItem([type_label, name, status, str(ts)])
self._wxapi_tree.addTopLevelItem(tree_item)
def _handle_wxapi(self, item):
kind = item[0]
c = _TH[self._tn]
_type_cn = {"wx.auth": "认证", "wx.request": "请求", "wx.pay": "支付",
"wx.storage": "缓存", "wx.share": "分享", "wx.location": "位置",
"wx.device": "设备", "wx.nav": "导航", "wx.media": "媒体",
"wx.clipboard": "剪贴板", "wx.bridge": "原生桥接"}
if kind == "new_calls":
calls = item[1]
if calls:
kw = self._wxapi_search_ent.text().strip().lower()
show_bridge = self._wxapi_bridge_cb.isChecked()
show_request = self._wxapi_request_cb.isChecked()
show_storage = self._wxapi_storage_cb.isChecked()
for call in calls:
ctype = call.get("type", "wx.api")
# 原生桥接开关关闭时跳过 bridge 类型
if ctype == "wx.bridge" and not show_bridge:
continue
# wx.request 开关关闭时跳过
if ctype == "wx.request" and not show_request:
continue
# wx.storage 开关关闭时跳过
if ctype == "wx.storage" and not show_storage:
continue
self._wxapi_items.append(call)
type_label = _type_cn.get(ctype, ctype)
name = call.get("name", "")
status = call.get("status", "")
ts = call.get("timestamp", "")
if kw and kw not in type_label.lower() and kw not in name.lower():
continue
tree_item = QTreeWidgetItem([type_label, name, status, str(ts)])
self._wxapi_tree.addTopLevelItem(tree_item)
self._wxapi_tree.scrollToBottom()
count = len(self._wxapi_items)
self._wxapi_status_lbl.setText(f"捕获中... {count} 条")
self._wxapi_status_lbl.setStyleSheet(f"color: {c['success']};")
elif kind == "replay_result":
_, name, res = item
self._btn_wxapi_replay.setEnabled(True)
status = res.get("status", "unknown")
if status == "success":
detail = json.dumps(res.get("result", {}), ensure_ascii=False, indent=2, default=str)
self._wxapi_resp.setHtml(
f'<span style="color:{c["success"]}">重放成功:\n{detail}</span>')
elif status == "fail":
err = res.get("error", "") or res.get("reason", "未知错误")
self._wxapi_resp.setHtml(
f'<span style="color:{c["error"]}">重放失败: {err}</span>')
else:
detail = json.dumps(res, ensure_ascii=False, indent=2, default=str)
self._wxapi_resp.setHtml(
f'<span style="color:{c["warning"]}">重放结果:\n{detail}</span>')
# ──────────────────────────────────
# 轮询
# ──────────────────────────────────
@@ -3882,6 +4409,12 @@ class App(QMainWindow):
except queue.Empty:
break
self._handle_ext(item)
for _ in range(50):
try:
item = self._wxapi_q.get_nowait()
except queue.Empty:
break
self._handle_wxapi(item)
def _apply_sts(self, sts):
c = _TH[self._tn]
@@ -4009,11 +4542,16 @@ class App(QMainWindow):
def _handle_cld(self, item):
kind = item[0]
c = _TH[self._tn]
_type_cn = {"function": "云函数", "storage": "存储", "container": "容器"}
_type_cn = {"function": "云函数", "storage": "存储", "container": "容器",
"wx.auth": "认证", "wx.request": "请求", "wx.pay": "支付",
"wx.storage": "缓存", "wx.share": "分享", "wx.location": "位置",
"wx.device": "设备", "wx.nav": "导航", "wx.media": "媒体",
"wx.clipboard": "剪贴板", "wx.bridge": "原生桥接"}
if kind == "new_calls":
calls = item[1]
if calls:
kw = self._cloud_search_ent.text().strip().lower()
live_kw = self._cloud_live_search_ent.text().strip().lower()
for call in calls:
data_str = json.dumps(call.get("data", {}), ensure_ascii=False)
if len(data_str) > 80:
@@ -4027,8 +4565,7 @@ class App(QMainWindow):
call.get("name", ""), data_str,
status, call.get("timestamp", ""))
self._cloud_all_items.append(vals)
if kw and not any(kw in str(v).lower() for v in vals):
continue
if not (kw and not any(kw in str(v).lower() for v in vals)):
tree_item = QTreeWidgetItem([str(v) for v in vals])
self._cloud_tree.addTopLevelItem(tree_item)
result_data = call.get("result") or call.get("error")
@@ -4039,8 +4576,18 @@ class App(QMainWindow):
"error": call.get("error"),
"data": call.get("data"),
}
# 同时推送到动态捕获列表
self._cloud_live_items.append(call)
name = call.get("name", "")
ts = call.get("timestamp", "")
if not (live_kw and live_kw not in type_label.lower()
and live_kw not in name.lower()):
live_item = QTreeWidgetItem([type_label, name, status, str(ts)])
self._cloud_live_tree.addTopLevelItem(live_item)
self._cloud_tree.scrollToBottom()
self._cloud_live_tree.scrollToBottom()
self._cloud_update_status()
self._cloud_live_update_status()
self._cloud_scan_lbl.setText(f"捕获中... {len(self._cloud_all_items)} 条")
self._cloud_scan_lbl.setStyleSheet(f"color: {c['success']};")
elif kind == "static_results":
@@ -4082,6 +4629,22 @@ class App(QMainWindow):
detail = json.dumps(res, ensure_ascii=False, default=str)
self._cloud_result.setHtml(
f'<span style="color:{c["warning"]}">{name} -> {detail}</span>')
elif kind == "live_replay_result":
_, name, res = item
self._btn_cloud_live_replay.setEnabled(True)
status = res.get("status", "unknown")
if status == "success":
detail = json.dumps(res.get("result", {}), ensure_ascii=False, indent=2, default=str)
self._cloud_live_resp.setHtml(
f'<span style="color:{c["success"]}">重放成功:\n{detail}</span>')
elif status == "fail":
err = res.get("error", "") or res.get("reason", "未知错误")
self._cloud_live_resp.setHtml(
f'<span style="color:{c["error"]}">重放失败: {err}</span>')
else:
detail = json.dumps(res, ensure_ascii=False, indent=2, default=str)
self._cloud_live_resp.setHtml(
f'<span style="color:{c["warning"]}">重放结果:\n{detail}</span>')
def _handle_ext(self, item):
kind = item.get("type", "")
+230
View File
@@ -0,0 +1,230 @@
# First
> 微信小程序安全调试工具 —— 基于 Frida + CDP 代理,支持 Windows / macOS 双平台,GUI 与 CLI 双模式
---
## 截图预览
### 主界面
![主界面](https://s1.galgame.fun/imgb/u0/20260408_69d655f49ca7e.png)
### 路由导航
![路由导航](https://s1.galgame.fun/imgb/u0/20260408_69d6560ca1a39.png)
### 云函数分析
![云函数分析](https://s1.galgame.fun/imgb/u0/20260402_69ce527ca5480.png)
### 调试开关(JSRPC / wx.cloud 调用)
![调试开关](https://s1.galgame.fun/imgb/u0/20260408_69d6564195009.png)
### 敏感信息提取
![敏感信息提取-1](https://s1.galgame.fun/imgb/u55/20260416_69e0be0e1c1d7.png)
<img src="https://s1.galgame.fun/imgb/u55/20260416_69e0be0c38e39.png" alt="敏感信息提取-2" />
![敏感信息提取-3](https://s1.galgame.fun/imgb/u55/20260416_69e0be0c70d46.png)
---
## 功能特性
- Frida 动态注入微信客户端,转发小程序调试协议
- CDP 代理桥接,Chrome DevTools 直连调试
- 路由枚举、分类与一键跳转
- 云函数调用监控与参数分析(动态 Hook + 静态扫描)
- UserScript 自动注入(支持 URL 匹配、run-at 时机控制)
- wxapkg 解密解包 + 敏感信息扫描(IP / 密钥 / 云存储 / JWT 等)
- 深色 / 浅色主题切换
- GUI(PySide6)与 CLI 双模式
---
## 环境要求
| 依赖 | 版本 |
|------|------|
| Python | >= 3.10 |
| frida | >= 17.0.0 |
| websockets | >= 12.0 |
| protobuf | >= 4.0.0 |
| PySide6 | >= 6.5.0 |
| pycryptodome | 最新版 |
安装依赖:
```bash
pip install -r requirements.txt
```
---
## 支持的微信版本
### Windows
- WMPF 版本:11581, 11633, 13331, 13341, 13487, 13639, 13655, 13871, 13909, 14161, 14199, 14315, 16133, 16203, 16389, 16467, 16771, 16815, 16965, 17037, 17071, 17127, 18055, 18151, 18787, 18891, 18955, 19027, 19201
- 推荐微信版本:**4.1.0.30**
- 下载地址:[weixin/4.1.0.30](https://github.com/vs-olitus/wx-version/releases/tag/4.1.0.30)
### macOS
- WMPF 版本:18152, 18788
- 推荐微信版本:**4.1.7.30**
- 下载地址:[weixin/4.1.7.30](https://github.com/vs-olitus/wx-version/releases/tag/4.1.7.30)
---
## 快速开始
### Windows
**一键启动:** 双击 `启动.bat`,首次运行会自动安装依赖。
**手动启动:**
```bash
python gui.py
```
### macOS
**一键启动:** 终端执行 `./启动.sh`,首次运行会自动安装依赖。
**手动启动:**
```bash
python3 gui.py
```
启动后在主界面点击 **启动调试**,然后再打开小程序即可(请勿在启动调试前打开小程序)。
### CLI 模式
```bash
# 默认端口启动
python main.py
# 自定义端口
python main.py --cdp-port 62000
# 开启详细日志
python main.py --debug-main --debug-frida
```
### 连接 Chrome DevTools
启动后,在 Chrome 地址栏输入:
```
devtools://devtools/bundled/inspector.html?ws=127.0.0.1:62000
```
---
## 服务号调试操作
1. 打开一个小程序页面作为占位(保持不关闭)
2. 访问需要调试的服务号内容(如 H5 网页)
![PixPin_2026-05-04_22-48-42](https://s1.galgame.fun/imgb/u55/20260504_69f8b299a7d85.png)
3. 在工具栏「服务」目标列表中找到目标网页,双击后切换至调试视图即可开始调试
![PixPin_2026-05-04_22-51-14](https://s1.galgame.fun/imgb/u55/20260504_69f8b299a7d94.png)
![PixPin_2026-05-04_22-51-39](https://s1.galgame.fun/imgb/u55/20260504_69f8b299a804b.png)
---
## macOS 注意事项
如果 Frida 注入报错,需要解除系统对进程附加的限制,任选其一:
**方案一:关闭 SIP(系统完整性保护)**
> 关闭后 Frida 才能正常注入进程。参考教程:[macOS SIP 开启关闭教程](https://cloud.tencent.com/developer/article/1496058)
**方案二:强制重签名 WeChat**
```bash
sudo codesign --force --deep --sign - /Applications/WeChat.app
```
---
## 参数说明
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--debug-port` | `9421` | 远程调试服务端口 |
| `--cdp-port` | `62000` | CDP 代理监听端口 |
| `--debug-main` | 关闭 | 输出主进程调试日志 |
| `--debug-frida` | 关闭 | 输出 Frida 客户端日志 |
| `--scripts-dir` | `./userscripts` | UserScript 目录路径 |
| `--script` | — | 指定单个 .js 文件注入(可多次使用) |
---
## UserScript 注入
将 `.js` 脚本放入 `userscripts/` 目录,启动时自动加载并注入。也可手动指定:
```bash
python main.py --script ./my_hook.js --script ./another.js
```
---
## 打包为可执行文件
```bash
pyinstaller WMPFDebugger.spec
```
---
## 常见问题
**Q: Frida 已连接,但小程序端显示未连接或无法断点调试?**
确认操作顺序无误后,尝试以下步骤:
1. 彻底卸载微信并重启电脑(重要聊天记录请提前备份)
2. 删除 `C:\Users\用户名\AppData\Roaming\Tencent\xwechat\XPlugin\Plugins\RadiumWMPF` 下所有数字命名的文件夹
3. 再次重启后安装微信 4.1.0.30 版本
4. 检查上述路径,确认文件夹编号为 `16389`
---
## 参考项目
- [evi0s/WMPFDebugger](https://github.com/evi0s/WMPFDebugger)
- [0xsdeo/HeartK](https://github.com/0xsdeo/HeartK)
- [残笑/FindSomething](https://github.com/momosecurity/FindSomething)
- [进击的HACK / JSRPC 与调用 wx.cloud](https://mp.weixin.qq.com/s/hTlekrCPiMJCvsHYx7CAxw)
- [linguo2625469/WMPFDebugger-mac](https://github.com/linguo2625469/WMPFDebugger-mac)
---
## 致谢
感谢 **0xsdeo** 师傅的大力支持与思路提供。
---
## 交流群
群满 200 人后需要手动邀请,请加我拉群:
![微信二维码](https://s1.galgame.fun/imgb/u55/20260413_69dcaf1310fc4.jpg)
---
## 免责声明
本工具仅供安全研究与学习使用,请勿用于未授权的目标,使用者须自行承担相关法律责任。