@dsh-fly/dsh-plugin-fly-mode 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/cordis.patch.yml +399 -0
  4. package/package.json +32 -4
  5. package/tools/dsh-plugin-fly-binary/index.js +520 -0
  6. package/tools/dsh-plugin-fly-binary/package.json +23 -0
  7. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
  8. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
  9. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
  10. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
  11. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
  12. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
  13. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
  14. package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
  15. package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
  16. package/tools/dsh-plugin-fly-diag/index.js +772 -0
  17. package/tools/dsh-plugin-fly-diag/package.json +24 -0
  18. package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
  19. package/tools/dsh-plugin-fly-guard/index.js +101 -0
  20. package/tools/dsh-plugin-fly-guard/package.json +26 -0
  21. package/tools/dsh-plugin-fly-pwn/index.js +583 -0
  22. package/tools/dsh-plugin-fly-pwn/package.json +23 -0
  23. package/tools/dsh-plugin-fly-recon/index.js +184 -0
  24. package/tools/dsh-plugin-fly-recon/package.json +23 -0
  25. package/tools/dsh-plugin-fly-reverse/index.js +178 -0
  26. package/tools/dsh-plugin-fly-reverse/package.json +23 -0
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: client-blackbox
3
+ description: 当用户报告某个客户端(小程序 / App / 桌面端)行为不对、接口不明、显示与提交不一致、提示语指向一个用户无法完成的动作时加载:建立中间人可观测性、判层(显示 / 提交 / 存储)、用假值与缺失区分真相、并按五阶段带 Gate 流程收口。
4
+ ---
5
+
6
+ # 客户端黑盒排查
7
+
8
+ 你手上只有客户端,没有源码、没有服务端日志、没有能对话的工程师。这套框架就是为这种局面写的:
9
+ **先建模,再动手;每一步都有 Gate,没过不要往下走。**
10
+
11
+ 配套资产(`Base directory for this skill:` 给出的目录下):
12
+
13
+ | 文件 | 用途 |
14
+ | --- | --- |
15
+ | `reference/playbook.md` | 五阶段全文:动作、判据、Gate、失败了回哪一步 |
16
+ | `reference/checklist.md` | 全流程可勾选清单(含 Phase 1 自检三连) |
17
+ | `reference/case-ttw.md` | 脱敏案例:三处独立缺陷、三次转折、工单结构 |
18
+ | `reference/rules.schema.md` | `rules.json` 的 schema 与"显式 off"纪律 |
19
+ | `code/mitm_addon_template.py` | 声明式 + 热加载的 mitmproxy addon(读 `FLY_ALLOW_INTERCEPT`) |
20
+ | `code/rules.example.json` | 规则示例(watch / patch_response / patch_request) |
21
+
22
+ ---
23
+
24
+ ## 0. 什么时候用 / 什么时候停手
25
+
26
+ **用**(命中任意两条就该上这套流程):
27
+
28
+ - 界面**显示有值 / 显示已完成**,但提交或下一步校验始终失败;
29
+ - 提示语指向一个**用户物理上无法完成的动作**("请先补充 X",而 X 页面没有编辑入口);
30
+ - 退出重登 / 重装 / 换设备 / 清缓存 / 换网络**全部无效**(症状一模一样);
31
+ - 提示语与操作结果**互相矛盾**;
32
+ - 客服复现不了,或只会说"你重新填一下"。
33
+
34
+ **停手**(本仓的硬边界,任何一条命中就停下来问人):
35
+
36
+ 1. **wxapkg 不做**。加密容器(`V1MMWX`)的离线解开不在本仓能力内 —— 不承诺、不试、不解释成"应该能行"。
37
+ 2. **不做半个代理**。TLS 拦截**没有纯仓内替代**:只转发 `CONNECT` 的代理看得到域名、看不到 body,
38
+ 而这类缺陷恰恰在 body 里(页面路由/渲染逻辑塞在 JSON 里,还有相当一部分响应体是图片、字体、gzip)。
39
+ 要么用 mitmproxy,要么以后做原生代理 —— 不拿"看得到域名"冒充"看得到内容"。
40
+ 3. **不自动装依赖**。缺 mitmproxy 时把安装命令**交给用户**(两行,点名解释器),不自己 pip install。
41
+ 4. **不自动提权**。`sudo` / `osascript … administrator privileges` 一律只输出成文本给人复制;
42
+ 工具侧执行前就拒绝(提权调用弹不出框、也没有超时,真会话会无超时挂住)。
43
+ 5. **不碰计费、资格、身份类规则**。只绕"展示 / 自检"这类坏掉的前端逻辑。
44
+ 6. **不写 `~/.dsh/skills`**,不把真实抓包与真实 ID 带进仓库。
45
+
46
+ ---
47
+
48
+ ## 1. 心智模型:三源一闸(S1 / S2 / S3 / G)
49
+
50
+ 任何"填写类 / 状态类"故障,本质都是下面四个位置的值或逻辑不一致。**先写下来,再动手。**
51
+
52
+ | 代号 | 名称 | 含义 | 出错时的典型症状 |
53
+ | --- | --- | --- | --- |
54
+ | **S1** | 渲染源 | 屏幕上显示的东西 | 显示"男",其实是 `range[0]` 兜底渲染 |
55
+ | **S2** | 提交源 | 前端真正装进请求体的值 | 校验拦住不发包;或发出去的是空值/默认值 |
56
+ | **S3** | 存储源 | 服务端真正生效的值 | 接口返回 `code:0`,字段纹丝不动 |
57
+ | **G** | 闸门 | 谁在判定"不通过" | 前端本地 `return`,还是服务端拒绝 |
58
+
59
+ **核心动作是对账(reconciliation)**:把 S1 / S2 / S3 分别取到手一一比对,同时确定 G 在哪一层。
60
+ **80% 的时间浪费,都源于把 S1 当成了 S3。**
61
+
62
+ ---
63
+
64
+ ## 2. 六条判据 T1–T6(每条:怎么测 / 什么算证伪)
65
+
66
+ | # | 判据 | 怎么测 | 什么算证伪 |
67
+ | --- | --- | --- | --- |
68
+ | **T1** | 用户看到的文字不是数据 | 界面显示 → 独立读接口取真值 | 显示值与读接口一致 ⇒ 这条陷阱不成立 |
69
+ | **T2** | 提示文案出现的位置决定责任层 | 把提示语原文丢进**全部响应体**搜 | 搜到 ⇒ 服务端判定;搜不到 ⇒ 纯前端文案 |
70
+ | **T3** | "没发出"和"发出被拒"是两种病 | 数该域名下的非 GET 请求条数 | 有保存请求 ⇒ 前端放行了,问题在服务端侧 |
71
+ | **T4** | 接口返回的可能是"回显请求对象" | 换一个**独立读接口**复核同一条记录 | 读接口与返回体一致 ⇒ 落库成功 |
72
+ | **T5** | 假值陷阱:`0` 是最贵的空值 | 用字符串 `"0"` 注入(JS 里它是真值) | 注入 `"0"` 仍被拦 ⇒ 不是 `!v` 校验的问题 |
73
+ | **T6** | 门禁分两类,一类可骗一类必须停手 | 读响应体:`confirm`/`Y`/`N`/`state` 在谁手里 | 服务端直接返回硬错误 ⇒ 服务端门禁,停手 |
74
+
75
+ **T5 的通用钥匙**:注入**字符串** `"0"`。JS 里只有 `""` 是假值,`"0"` 是真值 —— 它能同时过
76
+ `!v` 判假与 `==null` 判空,而服务端(Jackson / Gson / JSON 解析器)通常把它正常转成数字 `0`。
77
+
78
+ ---
79
+
80
+ ## 3. 五阶段(每阶段:做什么、Gate 是什么、失败回哪)
81
+
82
+ | 阶段 | 目标 | Gate | Gate 不过时回哪 |
83
+ | --- | --- | --- | --- |
84
+ | **Phase 0 症状分流** | 把症状翻译成假设 | 能用 S1/S2/S3/G 说清楚,且"为什么重登无效"有自洽解释 | 回用户的原始描述,补事实 |
85
+ | **Phase 1 建立可观测性** | 能看见每一帧流量 | **自检三连**全绿(HTTP 200 / HTTPS 200 / 目标客户端出现 `[NEW HOST]`) | 管道问题:换客户端 / 换端口 / 查 pinning |
86
+ | **Phase 2 协议测绘** | 把黑盒变成接口清单 | 能画出"操作 → 请求 → 谁判定"的流程图,并知道提示语出自哪层 | 回 Phase 1(没抓全) |
87
+ | **Phase 3 三源对账** | 定位到具体那一行 | 能写出"哪个接口、哪个字段、哪一层丢的" | 找不到写接口 → 存活性探测;前端不发包 → 诱出法 |
88
+ | **Phase 4 修复决策** | 用最小侵入达成用户目标 | 用户目标达成且**没有脏数据**(或已规划回滚) | 服务端门禁 ⇒ 停手转人工 |
89
+ | **Phase 5 收口交接** | 可还原、可复现、可追责 | 环境已还原 + 存档 + 工单可执行 | 回放过程补证据 |
90
+
91
+ **Phase 1 的自检三连**(Gate 1 的硬指标,顺序不能跳):
92
+
93
+ ```bash
94
+ # ① HTTP 能过
95
+ curl -s -o /dev/null -w '%{http_code}\n' -x http://127.0.0.1:8080 http://example.com/
96
+ # ② HTTPS 中间人能过(这一步才是真正的验证)
97
+ curl -s -o /dev/null -w '%{http_code}\n' -x http://127.0.0.1:8080 \
98
+ --cacert .fly-blackbox/mitmproxy/mitmproxy-ca-cert.pem https://example.com/
99
+ # ③ 目标客户端确实走了代理:看日志里有没有 [NEW HOST]
100
+ tail -f .fly-blackbox/capture/all.log
101
+ ```
102
+
103
+ > **在证明管道通畅之前,不要对"没抓到东西"做任何解释。** 抓不到东西的第一嫌疑永远是管道,
104
+ > 不是"目标有 pinning"。
105
+
106
+ ---
107
+
108
+ ## 4. 反模式(都是踩过的)
109
+
110
+ | # | 反模式 | 后果 | 正确做法 |
111
+ | --- | --- | --- | --- |
112
+ | 1 | 把 UI 显示当数据(T1) | 半天耗在"明明有值"上 | 回到数据源验证 |
113
+ | 2 | 不区分"没发出"与"被拒绝"(T3) | 在错误的分支上使劲 | 抓包,一秒区分 |
114
+ | 3 | 相信接口回显(T4) | 误判成功 | 独立读接口复核 |
115
+ | 4 | 用 `!v` 判断枚举(T5) | `0` 值永久不可达(很多线上 bug 的真身) | 显式判 `== null` / `=== ''` |
116
+ | 5 | 盲试 `save` / `insert` 类接口 | 数据被写坏 | 先 GET 探测方法,评估覆盖风险 |
117
+ | 6 | 一次改多个变量 | 无法归因 | 单一变量 + 明确观察点 |
118
+ | 7 | 没证明管道通就解释"没抓到" | 误判为 pinning 而放弃 | 自检三连 |
119
+ | 8 | 还原顺序颠倒(先杀进程再关代理) | 瞬断网 | 先关系统代理,再杀代理进程 |
120
+ | 9 | 忘了写还原脚本 | 留下长期代理 | 第一分钟就写 |
121
+ | 10 | 反复注销 / 重注册 / 找回 | 纯浪费时间(数据在服务端表里) | 先建模,再操作 |
122
+ | 11 | 拿前端文案当证据 | 定位错层 | 全文搜响应体(T2) |
123
+ | 12 | 把"客服复现不了"当成自己没说清 | 自责并放弃 | 用**字段级 diff** 逼他们进同一条路径 |
124
+ | 13 | 只抓不改 / 只改不验 | 拿不到结论 | 每次变更后用独立读接口复核 |
125
+ | 14 | 绕过计费 / 资格类校验 | 越界 | 只绕"展示 / 自检",业务规则一律不碰 |
126
+
127
+ ---
128
+
129
+ ## 5. 合规红线(不可谈判)
130
+
131
+ 1. **只操作你自己的账号与数据。** 不做横向探测、不碰他人 ID。
132
+ 2. **不做破坏性写入。** 涉及账号资料的写入前逐条过 Phase 4 的风险清单(打码字段、整行覆盖、`save` 类接口)。
133
+ 3. **不绕过计费、资格、身份类规则。** 属"业务规则"的门禁一律停手。
134
+ 4. **发现漏洞 → 负责任披露**,不利用、不传播、不变现。
135
+ 5. **抓包存档包含凭据**(token / 手机号 / 身份证)。归档前脱敏;发人之前打码。
136
+
137
+ ---
138
+
139
+ ## 6. 工具与资产(本包提供)
140
+
141
+ | 工具 | 干什么 | 授权 |
142
+ | --- | --- | --- |
143
+ | `bx_proxy` | 起 / 停 / 看本地拦截代理(`status` 回显状态文件、confdir、dump、`mitmdump --version`) | `start`/`stop`/`restart` 需 `FLY_ALLOW_INTERCEPT=1` |
144
+ | `bx_doctor` | 五段判决:HTTP / **HTTPS 的签发者是否就是 confdir 里的 mitm CA** / 目标客户端是否出现 / 冲突代理 / 系统代理 `Enabled` | 需 `FLY_ALLOW_INTERCEPT=1` |
145
+ | `bx_sysproxy` | 看 / 开关 macOS 系统代理(判决默认路由服务名,附一条还原命令) | `on`/`off` 需 `FLY_ALLOW_INTERCEPT=1` |
146
+ | `bx_ca` | CA 信任状态 + **要人跑的命令文本** + `security verify-cert` 事后证据 | 不受门控(永不提权) |
147
+ | `bx_flows` | 离线读 dump:`hosts` / `list` / `show` / `grep`(版本闸 + 拒绝截断) | 不受门控(只读) |
148
+ | `bx_diff` | 改前/改后字段级 diff,显式区分**缺失** / **假值** / **类型变化** | 不受门控(只读) |
149
+
150
+ 规则文件固定 `<workspace>/.fly-blackbox/rules.json`,由**你自己用文件工具写**,`bx_proxy start` 只读并校验;
151
+ schema 与"显式 off、无静默默认值"的纪律见 `reference/rules.schema.md`。
152
+
153
+ ---
154
+
155
+ ## 7. 人机分工
156
+
157
+ | 事 | 谁做 |
158
+ | --- | --- |
159
+ | 起代理、判管道、读 dump、比对前后 | 工具(`bx_*`) |
160
+ | 系统代理 / 信任 CA(要密码) | **人**(工具只给命令与事后证据) |
161
+ | ⌘Q 再重开目标客户端(`WeChatAppEx` 只在启动时读系统代理) | **人** |
162
+ | 屏幕上发生了什么("现在显示女了") | **人** —— 屏幕本身就是 oracle,这一条不可替代 |
163
+ | 写入类动作的取舍(要不要动这条记录) | **人**(风险清单逐条过) |
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ mitm_addon_template.py —— 客户端黑盒排查用的 mitmproxy 插件(声明式 + 热加载)
5
+
6
+ 设计原则(都是实战踩坑换来的)
7
+ 1. **声明式**:行为全写在 rules.json 里,不写死在代码里 —— 换个目标只改 json
8
+ 2. **热加载**:改完 rules.json 立即生效,**不重启代理**
9
+ (排查中最浪费时间的就是"改一行配置 → 重启 → 等客户端重连")
10
+ 3. **永远先 watch,再 patch**;patch 一次只动一个字段
11
+ 4. **每一处篡改都在日志里吼一声**,让你不可能忘记自己改过什么
12
+ 5. **缺规则 / 规则非法 = 拒绝启动**,绝不回退到"默认注入"
13
+ (反面教材:一次性脚本把规则文件写空,addon 静默继续改包,结论跟着一起坏掉)
14
+ 6. **改动路径要显式授权**:只有 `FLY_ALLOW_INTERCEPT=1` 时才允许注入;
15
+ 否则退化成只读记录 —— 只读路径永远可用
16
+
17
+ 用法(由 `bx_proxy start` 拼出来,这里是它的形状)
18
+ mitmdump -s mitm_addon_template.py -w <workspace>/.fly-blackbox/flows.mitm \
19
+ --set confdir=<workspace>/.fly-blackbox/mitmproxy \
20
+ -p 8080
21
+ # 规则路径与授权开关走**环境变量**(不是 `--set`):
22
+ # MITM_RULES=<workspace>/.fly-blackbox/rules.json
23
+ # MITM_CAPTURE=<workspace>/.fly-blackbox/capture
24
+ # FLY_ALLOW_INTERCEPT=1 (只有 =1 才允许注入;否则只读记录)
25
+ #
26
+ # 为什么不用 `--set fly_rules=…`:实测 mitmdump 12.2.3 里 `--set` 的值要到 addon 的
27
+ # `load()` **之后**才写进 `ctx.options`(`load()` 里读到的是空串、`running()` 里才是
28
+ # 给定值),而本 addon 的纪律是"规则文件缺失/非法 = 在 `load()` 里拒绝启动"。用
29
+ # `--set` 传规则路径会静默退回默认的 `rules.json`、当场抛 RuleError、mitmdump 退出 1
30
+ # —— 代理根本起不来,而症状看起来像"规则文件没写"。
31
+
32
+ 规则示例见同目录 rules.example.json,schema 见 reference/rules.schema.md。
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ import re
40
+ import time
41
+ from pathlib import Path
42
+
43
+ from mitmproxy import ctx, http
44
+
45
+ # ---------------------------------------------------------------- 基础配置
46
+
47
+ # 规则路径走**环境变量**:这是唯一在 addon 的 import / load() 期就生效的通道
48
+ # (`--set` 要等 load() 之后才写进 ctx.options —— 见文件头"为什么不用 --set")。
49
+ RULES_PATH = Path(
50
+ os.environ.get("MITM_RULES")
51
+ or getattr(ctx.options, "fly_rules", "")
52
+ or "rules.json"
53
+ )
54
+ CAPTURE_DIR = Path(os.environ.get("MITM_CAPTURE", "./capture"))
55
+
56
+ # 值是"空"的判定 —— 注意 0 与 "0" 都算空(这正是大量线上 bug 的来源,见 playbook T5)。
57
+ # patch 时按需选择 mode。
58
+ EMPTY_VALUES = (None, "", 0, "0", [], {})
59
+
60
+ _rules_cache: dict | None = None
61
+ _rules_mtime: float = -1.0
62
+ _seen_hosts: set[str] = set()
63
+
64
+
65
+ def _intercept_allowed() -> bool:
66
+ """改动路径的授权开关。未设/不等于 1 → 只记录、不注入。"""
67
+ return os.environ.get("FLY_ALLOW_INTERCEPT") == "1"
68
+
69
+
70
+ # ---------------------------------------------------------------- 规则加载
71
+
72
+
73
+ class RuleError(RuntimeError):
74
+ """规则文件缺失或非法 —— 一律拒绝启动,绝不静默回退。"""
75
+
76
+
77
+ def _load_rules() -> dict:
78
+ """
79
+ 读规则。**缺文件、空文件、非法 JSON、缺关键键都抛错**。
80
+
81
+ 这里刻意不提供"默认规则":默认规则意味着"用户以为关掉了,其实还在注入",
82
+ 而那种静默正是本框架最贵的一个坑。
83
+ """
84
+ path = RULES_PATH
85
+ if not path.is_file():
86
+ raise RuleError(f"规则文件不存在:{path} —— 拒绝启动(不提供默认注入)")
87
+ raw = path.read_text(encoding="utf-8")
88
+ if raw.strip() == "":
89
+ raise RuleError(
90
+ f"规则文件是空的:{path} —— 空文件曾被静默当成'默认注入',"
91
+ '所以这里一律当非法。要关掉注入请显式写 {"enabled": false}'
92
+ )
93
+ try:
94
+ loaded = json.loads(raw)
95
+ except Exception as exc: # noqa: BLE001 - 原话要透给用户
96
+ raise RuleError(f"规则文件不是合法 JSON:{path} —— {exc}") from exc
97
+ if not isinstance(loaded, dict):
98
+ raise RuleError(f"规则文件顶层必须是对象:{path}")
99
+
100
+ watch = loaded.get("watch") or {}
101
+ if not isinstance(watch, dict):
102
+ raise RuleError(f"watch 必须是对象:{path}")
103
+ for key in ("patch_request", "patch_response"):
104
+ if key in loaded and not isinstance(loaded[key], list):
105
+ raise RuleError(f"{key} 必须是数组:{path}")
106
+ return {
107
+ "enabled": bool(loaded.get("enabled", True)),
108
+ "hosts": list(loaded.get("hosts") or []),
109
+ "watch": {
110
+ "paths": list(watch.get("paths") or []),
111
+ "log_bodies": bool(watch.get("log_bodies", True)),
112
+ "log_all_requests": bool(watch.get("log_all_requests", True)),
113
+ "noise_hosts": list(watch.get("noise_hosts") or []),
114
+ },
115
+ "patch_request": list(loaded.get("patch_request") or []),
116
+ "patch_response": list(loaded.get("patch_response") or []),
117
+ }
118
+
119
+
120
+ def rules() -> dict:
121
+ """热加载:mtime 变了就重读。所以改完 json 不用重启代理。"""
122
+ global _rules_cache, _rules_mtime
123
+ try:
124
+ mtime = RULES_PATH.stat().st_mtime
125
+ except OSError:
126
+ # 文件在运行中被删了:缓存还在就继续用缓存(进程内的规则不会凭空变),
127
+ # 没有缓存说明启动时就没读到 —— 那时已经抛错退出了。
128
+ if _rules_cache is None:
129
+ raise RuleError(f"规则文件不见了:{RULES_PATH}") from None
130
+ return _rules_cache
131
+ if _rules_cache is None or mtime != _rules_mtime:
132
+ loaded = _load_rules() # 非法就在下一次请求时抛出来
133
+ _rules_cache = loaded
134
+ _rules_mtime = mtime
135
+ ctx.log.warn(
136
+ f"[RULES] 已加载 {RULES_PATH}(改动自动生效):enabled={loaded['enabled']} "
137
+ f"patch_request {len(loaded['patch_request'])} 条 / patch_response {len(loaded['patch_response'])} 条"
138
+ )
139
+ return _rules_cache
140
+
141
+
142
+ # ---------------------------------------------------------------- 工具函数
143
+
144
+
145
+ def _log_file(name: str, line: str) -> None:
146
+ CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
147
+ with (CAPTURE_DIR / name).open("a", encoding="utf-8") as fh:
148
+ fh.write(line + "\n")
149
+
150
+
151
+ def _host_ok(host: str, r: dict) -> bool:
152
+ hosts = r.get("hosts") or []
153
+ return (not hosts) or any(h in host for h in hosts)
154
+
155
+
156
+ def _match(patterns, path: str) -> bool:
157
+ return any(re.search(p, path, re.I) for p in (patterns or []))
158
+
159
+
160
+ def _json_of(text: str):
161
+ try:
162
+ return json.loads(text)
163
+ except Exception:
164
+ return None
165
+
166
+
167
+ def _apply(node, updates: dict, mode: str, create_missing: bool) -> list[str]:
168
+ """递归地把 updates 应用到 JSON 树里所有"含有这些 key"的 dict。
169
+
170
+ mode:
171
+ set_if_empty 仅当原值为空(None/""/0/"0"/[]/{})时写入
172
+ force 无条件覆盖
173
+ only_missing 仅当 key 不存在时新增(配合 create_missing)
174
+ """
175
+ hits: list[str] = []
176
+
177
+ if isinstance(node, dict):
178
+ for key, value in updates.items():
179
+ if key in node:
180
+ old = node[key]
181
+ if mode == "force" or (mode == "set_if_empty" and old in EMPTY_VALUES):
182
+ node[key] = value
183
+ hits.append(f"{key}: {old!r} -> {value!r}")
184
+ elif create_missing and mode == "only_missing":
185
+ node[key] = value
186
+ hits.append(f"{key}: <缺失> -> {value!r}")
187
+ for value_node in node.values():
188
+ hits.extend(_apply(value_node, updates, mode, create_missing))
189
+ elif isinstance(node, list):
190
+ for item in node:
191
+ hits.extend(_apply(item, updates, mode, create_missing))
192
+
193
+ return hits
194
+
195
+
196
+ def _patch_message(message, rule: dict, kind: str) -> None:
197
+ """kind: 'REQ' | 'RESP'。只处理 JSON。"""
198
+ if not _intercept_allowed():
199
+ ctx.log.warn(
200
+ f"[PATCH-{kind}] 规则命中 {rule.get('path')},但 FLY_ALLOW_INTERCEPT != 1 —— "
201
+ "只读模式,拒绝改动。要注入请先显式授权。"
202
+ )
203
+ return
204
+ ctype = (message.headers.get("content-type", "") or "").lower()
205
+ text = message.get_text(strict=False) or ""
206
+ data = _json_of(text)
207
+ if data is None:
208
+ if "json" in ctype:
209
+ ctx.log.warn(f"[PATCH-{kind}] {rule.get('path')} 命中但 body 不是合法 JSON,跳过")
210
+ return
211
+
212
+ mode = rule.get("mode", "set_if_empty")
213
+ hits = _apply(data, rule.get("set", {}), mode, bool(rule.get("create_missing")))
214
+ if hits:
215
+ message.set_text(json.dumps(data, ensure_ascii=False))
216
+ ctx.log.warn(f"[PATCH-{kind}] {rule.get('path')} :: " + "; ".join(hits))
217
+
218
+
219
+ # ---------------------------------------------------------------- hooks
220
+
221
+
222
+ def request(flow: http.HTTPFlow) -> None:
223
+ r = rules()
224
+ host = flow.request.pretty_host
225
+ if not _host_ok(host, r):
226
+ return
227
+
228
+ watch = r["watch"]
229
+ if watch.get("log_all_requests", True):
230
+ line = f"{time.strftime('%H:%M:%S')} REQ {flow.request.method} {flow.request.pretty_url}"
231
+ _log_file("all.log", line)
232
+ if host and host not in _seen_hosts:
233
+ _seen_hosts.add(host)
234
+ ctx.log.warn(f"[NEW HOST] {host}")
235
+ _log_file("all.log", f"*** NEW HOST: {host}")
236
+ if not any(n in host for n in (watch.get("noise_hosts") or [])):
237
+ ctx.log.info(line)
238
+
239
+ if watch.get("log_bodies") and _match(watch.get("paths"), flow.request.path):
240
+ body = flow.request.get_text(strict=False) or ""
241
+ _log_file(
242
+ "watch.log",
243
+ f"\n===== REQ {time.strftime('%H:%M:%S')} =====\n"
244
+ f"{flow.request.method} {flow.request.pretty_url}\n"
245
+ f"--- headers ---\n"
246
+ + "\n".join(f"{k}: {v}" for k, v in flow.request.headers.items())
247
+ + f"\n--- body ---\n{body[:4000]}",
248
+ )
249
+
250
+ if not r["enabled"]:
251
+ return
252
+ for rule in r.get("patch_request", []):
253
+ if not _match([rule.get("path", "")], flow.request.path):
254
+ continue
255
+ methods = [m.upper() for m in rule.get("methods", ["POST", "PUT", "PATCH"])]
256
+ if flow.request.method.upper() not in methods:
257
+ continue
258
+ _patch_message(flow.request, rule, "REQ")
259
+
260
+
261
+ def response(flow: http.HTTPFlow) -> None:
262
+ r = rules()
263
+ host = flow.request.pretty_host
264
+ if not _host_ok(host, r):
265
+ return
266
+
267
+ watch = r["watch"]
268
+ if watch.get("log_all_requests", True):
269
+ _log_file(
270
+ "all.log",
271
+ f"{time.strftime('%H:%M:%S')} RESP {flow.response.status_code} "
272
+ f"{flow.request.method} {flow.request.pretty_url}",
273
+ )
274
+
275
+ # 先记录原始响应(存档优先于篡改 —— 证据不能被自己改没了)
276
+ if watch.get("log_bodies") and _match(watch.get("paths"), flow.request.path):
277
+ _log_file(
278
+ "watch.log",
279
+ f"\n----- RESP {time.strftime('%H:%M:%S')} {flow.response.status_code} "
280
+ f"{flow.request.path} -----\n"
281
+ f"{(flow.response.get_text(strict=False) or '')[:4000]}",
282
+ )
283
+
284
+ if not r["enabled"]:
285
+ return
286
+ for rule in r.get("patch_response", []):
287
+ if not _match([rule.get("path", "")], flow.request.path):
288
+ continue
289
+ _patch_message(flow.response, rule, "RESP")
290
+
291
+
292
+ def load(_loader) -> None:
293
+ """启动即校验规则:缺文件 / 空文件 / 非法 → 直接抛,进程不进入服务状态。"""
294
+ loaded = rules()
295
+ ctx.log.info(
296
+ f"fly-blackbox addon 就绪:规则 {RULES_PATH} · enabled={loaded['enabled']} · "
297
+ f"注入授权 FLY_ALLOW_INTERCEPT={'1(开)' if _intercept_allowed() else '未设(只读)'}"
298
+ )
299
+
300
+
301
+ def done() -> None:
302
+ ctx.log.info(f"抓包存档目录:{CAPTURE_DIR}(all.log / watch.log)")
@@ -0,0 +1,52 @@
1
+ {
2
+ "_说明": [
3
+ "声明式排查规则。改完保存即生效,不需要重启 mitmdump(addon 按 mtime 热加载)。",
4
+ "纪律:永远先只开 watch 跑一遍,看清真实流量,再加 patch。",
5
+ "patch 一次只动一个字段,并在心里明确『观察点』是什么(界面显示?发包?响应码?)。",
6
+ "关掉注入必须显式写 enabled: false —— 空文件是非法配置,bx_proxy start 会拒绝启动。"
7
+ ],
8
+
9
+ "enabled": true,
10
+
11
+ "hosts": ["api.example.com"],
12
+
13
+ "watch": {
14
+ "paths": ["user|member|profile|account|enroll|apply|check"],
15
+ "log_bodies": true,
16
+ "log_all_requests": true,
17
+ "noise_hosts": ["weixin.qq.com", "qpic.cn", "gtimg.cn"]
18
+ },
19
+
20
+ "patch_response": [
21
+ {
22
+ "_场景": "服务端把『拦不拦』的决定权交给了客户端(state/confirm/Y/N)→ 客户端门禁,可解除",
23
+ "path": "member/checkMemberMe",
24
+ "mode": "force",
25
+ "set": { "state": true, "confirm": false }
26
+ },
27
+ {
28
+ "_场景": "诱出法:把空字段注入成非空,骗过前端必填校验,逼它把请求发出去",
29
+ "_技巧": "枚举字段优先试字符串 \"0\" —— JS 里它是真值,能同时过 !v 判假和 ==null 判空",
30
+ "path": "member/getMemberById",
31
+ "mode": "set_if_empty",
32
+ "set": { "gender": "0" }
33
+ },
34
+ {
35
+ "_场景": "给缺失字段补值(key 不存在时才新增)",
36
+ "path": "some/path",
37
+ "mode": "only_missing",
38
+ "create_missing": true,
39
+ "set": { "extraFlag": true }
40
+ }
41
+ ],
42
+
43
+ "patch_request": [
44
+ {
45
+ "_场景": "请求已经发出去了但字段是空的,直接在传输层补上",
46
+ "path": "member/updateMember",
47
+ "methods": ["POST"],
48
+ "mode": "set_if_empty",
49
+ "set": { "gender": "0" }
50
+ }
51
+ ]
52
+ }