Imported from 7452323/Nexus (
Hermes/skills/reverse-engineering/network-protocol-reverse/SKILL.md). Install upstream withnpx skills add 7452323/Nexus --skill network-protocol-reverse. Copyright stays with the author.
network-protocol-reverse
Merged from: protocol-reverse-engineering, har-to-proxy-script, s3-presigned-upload-reverse, web-font-anti-scraping Source count: 4
Table of Contents
protocol-reverse-engineering
协议逆向工程
触发条件
用户需要:
- 分析未知网络协议结构
- 还原API请求/响应格式
- 解析自定义二进制协议
- 分析IoT/游戏/设备通信协议
- 分析WebSocket游戏协议
- 绕过客户端版本检查
工具链
protobuf-inspector(Protobuf专用)
# 安装
pip install protobuf-inspector
# 使用(无需.proto文件)
cat unknown_protobuf.bin | python -m protobuf_inspector
# 输出:自动推断的消息结构
netzob(通用协议逆向)
# 安装
pip install netzob
# Python API
from netzob.all import *
# 导入捕获的数据
messages = PCAPImporter.readFile("capture.pcap")
# 协议推断
from netzob.Inference.all import *
format = Format(messages)
Wireshark(网络抓包)
# 命令行抓包
tshark -i eth0 -w capture.pcap
# 过滤特定流量
tshark -r capture.pcap -Y "tcp.port == 8080"
分析流程
Step 1: 数据捕获
# 抓包
tcpdump -i any -w capture.pcap port 8080
# 或用Wireshark GUI
Step 2: 协议识别
# 检查是否为已知协议
file capture.pcap
strings capture.pcap | grep -i "http\|json\|xml\|protobuf"
# 检查是否为TLS/SSL
tshark -r capture.pcap -Y "tls"
Step 3: 消息格式分析
# Protobuf分析
cat message.bin | python -m protobuf_inspector
# 自定义二进制分析
# 1. 识别固定头部(长度/类型/序列号)
# 2. 识别分隔符
# 3. 识别字段边界
# 4. 推断数据类型
Step 4: 状态机推断
# 使用netzob
# 1. 导入多条消息
# 2. 自动推断状态转换
# 3. 生成协议模型
常见协议模式
TLV格式
[Type 1-4B][Length 1-4B][Value NB]
固定头部+载荷
[Magic 2B][Version 1B][Length 2B][Payload NB][CRC 4B]
长度前缀
[Length 4B][Data NB]
分隔符分隔
[field1]\r\n[field2]\r\n[field3]\r\n
知识库引用
~/.hermes/knowledge/re-engineering/protocol-re/— protobuf-inspector + netzob工具references/websocket-version-check-bypass.md— WebSocket 游戏协议版本检查绕过实战(QQ农场案例)
Protobuf 逆向工具箱(从全网搜索吸收)
| 工具 | 用途 | 安装 |
|---|---|---|
| protobuf-inspector | 无.proto文件盲解析protobuf流 | pip install protobuf-inspector |
| protorev | protobuf逆向工作台,保留偏移/wire type/递归length-delimited候选/语料级字段存在性/草稿schema | pip install protorev |
| protoc --decode_raw | 官方原始解码(无类型信息) | protobuf编译器自带 |
| Protobuf Decoder (在线) | 纯JS本地解码,无需上传 | protobuf-decoder.serenetia.com |
| arkadiyt/protobuf-from-binary | 从编译后二进制提取原始.proto定义 | 参考arkadiyt.com博客 |
Protobuf逆向流程:
1. 识别protobuf(wire type 0-5的字节模式)
2. protoc --decode_raw 或 protobuf-inspector 盲解析
3. protorev 保持证据链(偏移/wire type/字段存在性)
4. 多消息对比 → 推断字段类型和名称
5. 逐步定义字段 → 直到parser无需猜测
6. 从编译后二进制提取.proto(arkadiyt方法)
从编译后二进制提取.proto定义(arkadiyt方法):
- Protobuf编译后在二进制中保留FileDescriptorProto
- 搜索
\n\x0C(Tag=1,Length=12)开头的protobuf编码 - 或搜索字符串特征:
.proto文件路径、message名、field名 - 工具:
protoc --decode=google.protobuf.FileDescriptorSet解码
CTF协议逆向方法论(从reverse-skill/CTF-Sandbox-Orchestrator吸收)
自定义协议重放5步法:
- 角色+流ID+端口+会话重置+握手边界+成功transcript
- 分离传输分段与应用消息
- 帧边界 → 方向序列 → 完整性字段(checksum/MAC/counter/nonce/signature)
- 压缩/编码/加密边界识别
- Accepted vs Rejected transcript delta → 最小重放证明
PCAP协议解码5步法:
- 流重组(不要从单包推理)
- 帧恢复+方向
- 压缩/编码/加密/传输边界
- 提取解码消息/传输对象
- 关联主机行为
常见pitfall: 混合不相关流(共享host/port但不同session state)/ 命名协议但不解码内容 / 未理解帧和状态依赖就尝试重放
其他工具
| 工具 | 用途 |
|---|---|
| URH (jopohl/urh) | 无线协议逆向(SDR支持) |
| Scapy | 数据包构造/解析 |
| CyberChef | 数据编解码 |
har-to-proxy-script
HAR to Proxy Script — 抓包转代理脚本
转换流程
- 获取 HAR 文件(Charles/Surfboard/浏览器导出)
- 检查技能 references/ 目录是否有该 App/HAR 的现有分析 — 同名 HAR 文件名或同域名可能已有 pre-analysis,直接引用可跳过步骤 3-4
- 解析 HAR 提取请求/响应数据
- 识别目标端点和参数
- 选择脚本类型(Unlock/Checkin/Cookie/AdBlock)
- 生成对应平台脚本
- 测试脚本可用性
常用转换模式
| 场景 | 提取内容 | 生成脚本 |
|---|---|---|
| 解锁会员 | 响应 JSON 结构 | 响应体修改 Unlock 脚本 |
| 签到 | Token/Cookie + API 地址 | Cron 签到脚本 |
| Cookie 采集 | 请求头 Cookie/Authorization | Header 捕获脚本 |
| 去广告 | 广告 API 地址 | Reject/空响应脚本 |
⚡ HAR 程序化分析脚本
直接在 Hermes 里跑这段 Python,自动提取所有域名和路径。
先用 execute_code(首选)。如果被拒绝(BLOCKED: cron mode 等安全限制),fallback 到 terminal 用 python3 -c 跑:
python3 -c "
import json, urllib.parse
with open('/path/to/file.har') as f:
har = json.load(f)
entries = har['log']['entries']
# ... 粘贴下面的代码内容 ...
"
注意:terminal 里的 Python 字符串要用双引号套单引号避免 escape 问题(见下面的代码模板里的 \" 转义)。
execute_code 版本(推荐):
import json
with open('your.har') as f: # 或从 /tmp/xxx.har 读
har = json.load(f)
entries = har['log']['entries']
# 1) 域名分组统计
domains = {}
for e in entries:
from urllib.parse import urlparse
domain = urlparse(e['request']['url']).netloc
domains[domain] = domains.get(domain, 0) + 1
print("=== 域名统计 ===")
for d, c in sorted(domains.items(), key=lambda x: -x[1]):
print(f" {c:>4}x {d}")
# 2) 路径清单
print("\n=== 请求路径 ===")
seen = set()
for e in entries:
url = e['request']['url']
path = url.split('.com')[1].split('?')[0] if '.com' in url else url
method = e['request']['method']
status = e['response']['status']
key = f"{method} {status} {path}"
if key not in seen and 'budingscan.com' in url:
seen.add(key)
print(f"[{method:>4}] {status} {path}")
# 3) 关键词搜端点
print("\n=== 含 used/remain/count/consume 的路径 ===")
for e in entries:
url = e['request']['url']
path = url.split('.com')[1] if '.com' in url else url
if any(kw in path.lower() for kw in ['used','remain','count','consume','deduct','cost','spend']):
print(f" [{e['request']['method']}] {path}")
# 4) 查看某个端点的完整响应
for e in entries:
url = e['request']['url']
if 'paid_modules' in url:
text = e['response'].get('content', {}).get('text', '')
if text.startswith('{'):
obj = json.loads(text)
print(json.dumps(obj, indent=2, ensure_ascii=False))
break
🚨 签名检测 — HAR 分析第一步(此用户实战重要教训)
拿到 HAR 后,第一件事不是搜 is_vip,而是检查请求是否有签名验证!
常见签名信号
| 签名类型 | 特征 | 示例 |
|---|---|---|
| Header HMAC | X-CC-Signature + X-CC-Access-Key-ID |
句读/有诗 — 阿里云/火山引擎风格 HMAC-SHA256 |
| URL 参数签名 | URL 带 signature=、sign=、_sign= |
有诗 — MD5 参数签名 |
| JWT Token | Authorization: Bearer <jwt>,body 加密 |
各种带 sk- 的 |
| 请求体加密 | body 是 base64 或加密 hex 串 | 需要先解密才能看到字段 |
⚠️ iOS Swift App 签名验证的陷阱
核心事实:iOS 原生 App(Swift/ObjC)的签名 key 编译在 arm64 Mach-O 二进制中,且通常被混淆/加密。strings 搜不到签名 key 是正常情况,不要花精力在二进制里找 key。
实战判断流程:
- HAR 中看到签名 headers(如
X-CC-Signature、X-CC-Access-Key-ID) - 提取一次完整的请求和响应,尝试用常见 key 候选验签(空字符串、access key 本身、SHA256 of access key 等)
- 所有候选都匹配不上 → 签名 key 被混淆在二进制中,找不到
- 结论:直接放弃,iOS Swift App 改 response 方案不可行
为什么改 response 不生效(iOS Swift App):
- 不是因为 response 有签名(response 没有签名),而是 App 本地存储了会员状态
- 恢复购买完全走苹果收据本地验证,不走网络请求。HAR 里没有恢复购买请求 = App 根本没联网验证你的会员身份
- 即使用户点了"恢复购买"显示成功,那也是客户端自己骗自己
- 下一次请求
users/wechat或users/me时,服务端仍然返回is_member: false
北京才程科技(有诗/句读)的 App:
- 签名位置:句读用
X-CC-Signatureheader,有诗用 URLsignature=参数 - 同公司所有 App 都走同一套签名系统
- 两次试错已浪费。响应体改得再完美也没用。
- 不要再尝试同公司的任何 App(有诗、句读等)
仅限弱签名的 App 可以继续尝试:
- 签名只用于请求完整性,响应体不签名 → 尝试改 response
- 签名用的是固定 key,hack 后能重新计算 → 可以继续
- 注意:即使签名是前端的(用于服务端验签),改 response 后客户端可能缓存或校验原始数据
⚠️ 服务端限制 vs 客户端限制 — 可行性预判
改 JSON 响应不是万能的。 有些限制在服务端,MITM 根本改不了。在分析 HAR 时,必须先判断限制类型:
限制类型判定矩阵
| 限制类型 | 特征信号 | MITM 能解? |
|---|---|---|
| 客户端开关 | 响应中有 is_vip: false、unlocked: false、feature_enabled: 0 |
✅ 改字段值 |
| 服务端过滤数据 | 响应中 steps: []、content: null、关键字段被截断 |
❌ 数据根本不发,改标志位也没内容 |
| 服务端次数验证 | POST 请求到类似 paid_module_used、consume_times 的扣减端点 |
✅ Mock 返回 {code:0} |
| 服务端签名校验 | 请求带 sk= JWT、api_sign、encrypt_text 加密参数 |
⚠️ 只有无签名/弱校验的 key 才能生效 |
| CDN 视频分段 | 视频 URL 带 v.0_10000.(低清试看)、或 Range 分段限制 |
✅ URL-rewrite 替换版本号 |
| 客户端 UI 假锁 | 按钮灰色但 API 实际返回了所需数据 | ✅ 改 unlocked/is_enabled 等标志 |
| CDN Token 限时 | 视频 URL 带 t=、expires=、sign= 等时效参数 |
❌ CDN 服务端控制,动态生成 |
实操判断方法
在 HAR 中找到锁定/未锁的对比样本:
- 找到两个同源 entry(一个免费、一个会员锁住的)
- 对比响应体结构:免费有
steps: [实际步骤]、会员steps: []→ 服务端限制,不了 - 对比
watch_type、unlocked、is_prime差异 → 可解的标志位 - 检查付费操作后的下一个请求:如果紧跟
POST paid_module_used/consume→ 可拦截的扣减
什么时候该放弃(纯服务端限制):
- 数据从服务端就不返回(steps 为空、content 被截断)
- 签名校验强(JWT + nonce + timestamp,每次都变)
- 视频 CDN 使用动态时效 Token(
t=,us=,sign=) - 菜谱步骤/文章正文不是分段 API 而是同个接口一次性返回
仍然能做的事(不要空手放弃):
- 所有标志位全开(
is_prime=true,unlocked=true,watch_type=1,expires_time=2099) - 次数扣减全部 mock 成功
- CDN 视频 URL-rewrite 替换试看版本号
- 引导购买的文案/popup/banner 清空
- 去广告
不可解限制的事后应对(本会话实战教训)
用户发现脚本能跑但部分功能仍然受限时:
- 不要跟用户争辩什么是服务端限制什么是客户端限制。用户只看结果。
- 回退到已知可用的版本:
git checkout <last-good-commit> -- script/xxx.js - 只改一行就推一次,让用户逐条验证
- 用户说脚本废物时:承认问题,不解释限制原因,问具体哪个路径/功能还不行
- 别自己发挥:不加任何用户没要求的字段或替换
懒饭类混合限制模式(菜谱 App 参考)
懒饭、下厨房等菜谱 App 的会员限制是典型混合模式:
- 服务端限制(不可解):
recipe/page_detail中会员菜谱的steps: []、tips: "购买会员..." - 客户端标志(可解):
unlocked: false→true、watch_type: 2→1、is_prime: false→true - CDN 视频限制(可解):视频 URL
v.0_10000.→v.替换为高清版 - 次数扣减(可解):培训计划/会员课程等操作后的扣减请求
HAR 关键端点清单(懒饭 v2.4.3):
| 端点 | 策略 |
|---|---|
user/prime.json |
改 is_prime:true、prime.expires_time:2099-12-31 |
recipe/page_detail.json |
改 unlocked:true、watch_type:1、清 tips |
homepage/feed.json |
改所有嵌套 unlocked:true、watch_type:1 |
story/get_v2.json |
改 unlocked:true、watch_type:1 |
plan/paged.json |
改 watch_type:1 |
prime/promotion_banner.json |
设 null |
chuimg.com 视频 URL |
URL-rewrite v.0_10000. → v. |
脚本结构(递归遍历 JSON,不用正则替换):
let body = $response.body;
try {
const obj = JSON.parse(body);
function unlock(obj) {
if (!obj || typeof obj !== 'object') return;
for (const k in obj) {
const v = obj[k];
if (k === 'is_prime') obj[k] = true;
else if (k === 'unlocked') obj[k] = true;
else if (k === 'watch_type') obj[k] = 1;
else if (k === 'watermark') obj[k] = false;
else if (k === 'tips' && typeof v === 'string' && v.includes('会员')) obj[k] = '';
if (typeof v === 'object') unlock(v);
}
}
unlock(obj);
body = JSON.stringify(obj);
} catch (_) {}
$done({ body });
⚠️ 正则替换 vs JSON 解析 — 路线选择(关键教训)
如果用户给了已知可用的参考(如 Surge Body Rewrite 用纯正则),严格复制其替换方式,不要自做主张改成 JSON 解析版。
纯正则可用的脚本,改为 JSON 解析 + JSON.stringify() 可能改变原始响应格式,触发客户端额外的兜底逻辑(如触发"1500+精选 限时特惠"弹窗)。已经工作的东西不要换底层。
决策规则:
- 用户给了参考 → 照抄,同种替换方式。不要升格(纯正则→JSON解析)。
- 用户没给参考,自己分析 → JSON 解析更稳妥。
- 不要混搭 — 一个已生效的脚本中途不要改替换方式。
- 对用户说"这做不到"前,先确认:是否已经按用户给的参考试过了? 如果试了还不行,用户给的数据里是否存在你没覆盖的端点。
- JSON 递归解析在性能和准确率上优于正则,但如果用户给的参考已经能工作(哪怕看起来简陋),保持不动
- 三平台模块文件(
.sgmodule/.plugin)同理:用户仓库里已有的格式就是标准,不要重新发明格式
扫描/翻译/OCR 类 App HAR 分析要点
这类 App(布丁扫描、扫描全能王、白描等)的付费校验分散在多个端点,分析 HAR 时需系统性识别:
分析步骤
- 按域名分组 — 先用脚本统计 HAR 中所有域名及请求数,找到业务主域名
- 识别会员信息端点 — 搜索响应体中含
user_type、vip、subscribe、end_time的条目 - 识别付费模块端点 — 搜索含
paid_modules、products、packages的路径 - 识别次数扣减端点 — 🔑 关键:搜索 POST 请求,路径含
used、consume、deduct、cost、spend的端点 - 识别剩余次数端点 — 搜索含
remain、remaining、count、usage的路径 - 识别广告端点 — 搜索含
ad、banner、splash的路径
⚠️ 容易漏掉的端点
| 路径特征 | 为什么容易漏 | 应该怎么处理 |
|---|---|---|
paid_module_used、consume_times、deduct_count |
响应体小(200B+),通常是加密数据,容易被当作"不重要" | 必须拦截,否则实际使用时次数会扣光 |
paid_module_usage、remain_times |
响应体也是加密的,看起来"动不了" | 原样透传即可,次数扣减停了它就不重要 |
plans、products、packages |
看起来只是展示,不影响功能 | 过滤掉续费计划可以减少用户看到付费弹窗 |
get_dynamic_config、get_config |
以为是客户端配置 | 检查是否有功能开关、白名单,可能有惊喜 |
加密体处理
有些端点的请求/响应体是加密的(如 encrypt_text 字段,base64 AES 加密)。对这些端点:
- 向后端扣减的加密 POST(如
paid_module_used):直接拦截返回{code:0, msg:"ok"},不需要解密 - 向前端返回的加密 GET(如
paid_module_usage):原样透传不改,因为次数扣减已经停了 - 不需要为了破解加密投入精力——服务端校验的端点直接 mock 成功即可
脚本模板快速生成
// Unlock 脚本模板
const url = $request.url;
if (url.includes('api/subscribe')) {
let body = JSON.parse($response.body);
body.data.vip = true;
body.data.expire = '2099-12-31';
$done({body: JSON.stringify(body)});
} else {
$done({});
}
// 签到脚本模板
const cookie = $prefs.valueForKey('cookie_name');
$httpClient.get({url: 'https://api.example.com/checkin', headers: {Cookie: cookie}}, function(err, resp, data) {
if (err) $done();
$notification.post('签到结果', '', data);
$done();
});
🎯 签到脚本实现模式(酷我音乐案例)
当目标 App 有签到/积分功能,但 HAR 中没有签到执行请求(因为已签到过了),可以反向利用已有的开源签到脚本来推导接口:
方法
- 从 HAR 分析业务域 — 找到 API 主域名(
integralapi.kuwo.cn) - 从 HAR 提取认证参数 — 提取
loginUid、loginSid等认证参数 - 从已有开源脚本找 API 定义 — 已经有别人写好的签到脚本(如
kuwo.js)包含了所有任务接口 - 结合两个信息源:
- HAR 确认了当前 App 版本的域名/参数格式
- 开源脚本提供了接口路径和参数格式
- 验证:用
doListen?from=mobile等接口模拟任务,用taskList验证状态变化
实际案例(酷我音乐升级脚本)
| 来源 | 提供的信息 |
|---|---|
| HAR 包 (integralapi.kuwo.cn) | 域名、认证参数(loginUid、loginSid)、请求头格式 |
| kuwo.js (General74110) | 任务 API 路径 (doListen)、参数 (from 不同任务类型) |
升级系统 taskList 响应 |
任务类型列表(sign/music/novel/comment/advert) |
执行任务接口(酷我音乐积分系统):
GET /api/v1/online/sign/v1/earningSignIn/everydaymusic/doListen
params: loginUid, loginSid, from= {mobile|novel|collect|videoadver|sign|comment|clock}, goldNum= {18|58|10|...}
升级系统状态查询:
GET /openapi/v1/usersystem/taskList
params: appUid, loginUid, loginSid, version, src
升级等级查询:
GET /openapi/v1/usersystem/userRank
params: appUid, loginUid, loginSid, type=1
何时使用此模式
- HAR 中只有状态查询(
taskList、todayStatus),没有任务执行请求 - 目标 App 有成熟的第三方签到脚本
- 签到任务 + 升级任务是两套独立 API 系统,但共享同一套认证
注意事项
- HAR文件可能很大(50MB+),优先用
curl直接下载到/tmp/ - 关键数据在
log.entries[].response.content.text(响应体)和request.postData.text(请求体) log.entries[].request.url是最重要的识别字段- Telegram 传输 HAR 被拒 — 先在
/root/.hermes/config.yaml里查gateway.max_file_size_mb(默认只有 10MB)。修复命令:hermes config set gateway.max_file_size_mb 200。注意重启网关后方生效。禁止上来就说"TG限20MB"——先查自己的配置再下结论。 - 大部分 .har 里的响应体是脱敏/空的(Charles 导出选项),需用真实设备+代理抓包
- Charles 导出的 .har 可能包含多个域名,先提取目标域名条目再分析
- ⚠️ QX 导出的 HAR 可能只含系统加密连接 — Quantumult X 导出 HAR 可能只有 MMTLS(微信)、Apple ls(
gspe1-ssl.ls.apple.com)等加密连接,无 App HTTP API 流量。第一步域名统计如果只有这些系统域名,直接告诉用户 HAR 无效需重抓。 - HAR 无效时的 Bundle ID 查询 — 用 iTunes API 直接拿:
curl -s "https://itunes.apple.com/lookup?id=APP_ID" | python3 -c "import json,sys; [print(r.get('bundleId')) for r in json.load(sys.stdin).get('results',[])]"
🔍 Rewrite 匹配精度规则(HAR 在手的铁律)
当你有 HAR 数据(抓包已知确切 URL)时,rewrite 正则必须精确匹配,不要加没必要的通配符。
错误写法(本会话被骂过的)
/* ❌ 明明 HAR 里抓到了 api.revenuecat.com/v1/subscribers/xxx
却写了通配子域名 + 两个域名 + 两个版本 + 额外端点匹配 */
^https?:\/\/([a-z0-9-]+\.)*revenuecat\.com\/(v[12]\/)?(receipts$|subscribers\/[^?#]+)
^https?:\/\/([a-z0-9-]+\.)*rc-backup\.com\/(v[12]\/)?(receipts$|subscribers\/[^?#]+)
正确写法
/* ✅ HAR 里只有这一个精确 URL,就只写这一个 */
^https:\/\/api\.revenuecat\.com\/v1\/subscribers\/.+ url script-response-body ...
^https:\/\/api\.revenuecat\.com\/v1\/subscribers\/.+ url script-request-header ...
[mitm]
hostname = api.revenuecat.com
规则
- HAR 里有精确 URL → 写精确正则。不要自加泛化(备份域名、子域名通配、版本范围)。
- 每多加一个
|、\.*、(v[12])、(receipts|subscribers)都是多余的,除非 HAR 确认该 App 确实用了多个域名/版本。 - MITM hostname 同理 — HAR 里只出现
api.revenuecat.com,就只写api.revenuecat.com。不要写*.revenuecat.com, *.rc-backup.com。 - 只有用户没有 HAR 数据(盲写通杀脚本)时才用宽泛匹配。
- 如果 App 后续更新了 API 路径,用户会再抓包更新。不需要提前覆盖。
为什么
- 多配符增加误触风险(其他 App 的请求被匹配执行了不该执行的脚本)
- 备份域名不存在于真实流量中,加了纯属浪费字符
- 用户看到你对着精确 HAR 写通配符会觉得你在偷懒/模板化
脚本长度控制
HAR 在手时,脚本应该极短。 参考结构(60 行以内):
/*
App名 解锁
[rewrite_local]
^精确URL url script-response-body ...
^精确URL url script-request-header ...
[mitm]
---
*[Content truncated: 589 lines total]*
---
**HAR 在手时,脚本应该极短。** 参考结构(60 行以内):
```javascript
/*
App名 解锁
[rewrite_local]
^精确URL url script-response-body ...
^精确URL url script-request-header ...
[mitm]
hostname = 精确域名
*/
var body = JSON.parse($response.body);
if ($response && body && body.xxx) {
body.xxx = 值;
body.yyy = 值;
$done({ body: JSON.stringify(body) });
} else {
// 删 ETag
delete $request.headers["if-none-match"];
$done({ headers: $request.headers });
}
禁止:模式A/模式B 分支、hasRealData 检测、递归遍历函数、大段注释说明。HAR 在手就知道确切结构,直接注入即可。
代码质量标准(此用户特严)
| 违禁模式 | 判断标准 | 示例 | 正确做法 |
|---|---|---|---|
| 废话去重 | 两行操作互相抵消 | delete obj.desc; obj.desc = "success" |
直接赋值一次或不要这行 |
| 注释污染 | 注释比代码多且无信息量 | // 下面从1开始循环 + for(i=1;...) |
只写 Why,不写 What |
| 多余变量 | 定义后只用一次 | const x = obj.field; if(x>0) |
直接 if(obj.field>0) |
| 空 catch | catch 块只打 log 无逻辑 | catch(e) { console.log(e) } |
可接受,但更好是 catch(_){} 如果 debug 已够 |
| 正则误伤 | 替换会破坏 JSON 结构 | body.replace(/\"unlocked\":\w+/g, ...) 可能替换到嵌套数据中的同名字段 |
用 JSON 递归遍历 |
| 漏端点 | 没处理次数扣减接口 | 只改 is_prime 和 paid_modules | 必须覆盖 paid_module_used 类端点 |
| 本地交付 | 文件只存本地没推 GitHub | 写到 /root/xxx.js 让用户下载 |
推送到用户 QX 仓库对应目录 |
核心原则:每行代码必须有实际作用,无废话、无冗余、无两行抵消。
🚨 必守前置:检查仓库现有结构(7452323 铁律)
在写任何代码之前,必须先看仓库里已经有什么! 不然写出来的全是垃圾。
第一步:了解仓库结构
# 克隆仓库并查看结构
gh repo clone 7452323/QuantumultX /tmp/qx-repo
ls /tmp/qx-repo/script/
cat /tmp/qx-repo/script/RevenueCat.js # 看看到底有什么通用脚本
仓库现有结构(2026-06)
script/ ← QX JS 脚本,内嵌 rewrite/mitm 注释
RevenueCat.js ← 通用 RC 解锁(revenuecat.com + rc-backup.com,含 ETag 剥离 + UAMapping)
Foodie.js ← 单个 App 脚本,自包含
Hireader.js
PicSeed.js
读不舍手.js
...
surge/ ← Surge sgmodule(每个 App 一个 .sgmodule)
RevenueCat.sgmodule
PicSeed.sgmodule
Foodie.sgmodule
读不舍手.sgmodule
...
Rule/ ← QX 格式过滤列表(HOST-SUFFIX, ...)
漫画.list
关键约束
-
RevenueCat.js 已经通用 — 覆盖
revenuecat.com+rc-backup.com的subscribers/receipts端点,双阶段(ETag剥离 + 响应修改),还有 UAMapping 后备。绝大多数 RC App 无需额外脚本。不要重复造轮子。 -
脚本风格 = 内嵌 rewrite 注释 — 单个 .js 文件顶部的
/* */注释块里写[rewrite_local]和[mitm]规则。不要单独写 .conf 文件。看 Foodie.js(35行)的写法:/* [rewrite_local] ^https?://... url script-response-body https://raw.githubusercontent.com/7452323/QuantumultX/main/script/Foodie.js [mitm] hostname = ... */ // 然后只有业务逻辑,没有多余 -
不要混合 RC 解锁和去广告 — RC 解锁归
RevenueCat.js,去广告归独立的规则列表。一个脚本只干一件事。 -
去广告用 Rule/ 下的 .list 文件 — QX 过滤列表格式(
HOST-SUFFIX,domain,reject)。不做成 JS。 -
不要删除仓库原有目录 —
mitm/等目录虽然看着没用,用户不开口就别碰。用户说了才能删。
仓库交付规范(7452323)
生成的脚本文件不能只存本地,必须推送到用户的 GitHub 仓库 7452323/QuantumultX。每次必须同时推 QX + Surge 两份文件,缺一不可。 用户发现只推了 QX 没推 Surge 会问"Surge没同步吗?"。
| 文件类型 | 目标路径 | 示例 |
|---|---|---|
| QX JS 脚本 | script/<appname>.js |
script/Foodie.js |
| Surge sgmodule | surge/<app名>.sgmodule |
surge/Foodie.sgmodule |
| 过滤列表 | Rule/<app名>.list |
Rule/漫画.list |
Surge sgmodule 格式(RC 解锁双阶段模式):
#!name=读不舍手
#!desc=读不舍手 - RC解锁
#!author=7452323
#!homepage=https://github.com/7452323/QuantumultX
[Script]
# 清 ETag 防 304
http-request ^https?:\/\/([a-z0-9-]+\.)*(revenuecat|rc-backup)\.com\/(v[12]\/)?(receipts$|subscribers\/[^?#]+) requires-body=false, script-path=https://raw.githubusercontent.com/7452323/QuantumultX/main/script/读不舍手.js, tag=读不舍手-清ETag
# 改订阅日期
http-response ^https?:\/\/([a-z0-9-]+\.)*(revenuecat|rc-backup)\.com\/(v[12]\/)?(receipts$|subscribers\/[^?#]+) requires-body=true, script-path=https://raw.githubusercontent.com/7452323/QuantumultX/main/script/读不舍手.js, tag=读不舍手-解锁
[MITM]
hostname = %APPEND% *.revenuecat.com, *.rc-backup.com
注意:Surge 的 http-request + http-response = QX 的 script-request-header + script-response-body。同一个 .js 脚本同时处理两个阶段,脚本内部用 typeof $response === "undefined" 区分。
Loon .plugin / .lpx 格式(与 .sgmodule 结构相同,但 script-path 后加 , requires-body=true 而非 =true):
#!name=Retouch
#!desc=TouchRetouch - RC解锁
#!author=7452323
#!icon=https://raw.githubusercontent.com/7452323/QuantumultX/main/icon/Loon.png
#!homepage=https://github.com/7452323/QuantumultX
[Script]
http-response ^https:\/\/([a-z0-9-]+\.)*(revenuecat|rc-backup)\.com\/(v[12]\/)?(receipts$|subscribers\/[^?#]+) script-path=https://raw.githubusercontent.com/7452323/QuantumultX/main/script/Retouch.js, requires-body=true, tag=Retouch-解锁
http-request ^https:\/\/([a-z0-9-]+\.)*(revenuecat|rc-backup)\.com\/(v[12]\/)?(receipts$|subscribers\/[^?#]+) script-path=https://raw.githubusercontent.com/7452323/QuantumultX/main/script/Retouch.js, requires-body=false, tag=Retouch-清ETag
[MITM]
hostname = *.revenuecat.com, *.rc-backup.com
推送流程:
cd /tmp/qx-repo
cp <生成的脚本> script/<appname>.js
cp <生成的sgmodule> surge/<app名>.sgmodule
git add -A && git commit -m "feat: <app名> - 解锁 [+ Surge]" && git push
推完立刻发 raw 链接给用户 — 不要等用户问"链接给我啊"。直接在回复里贴两个 URL:
https://raw.githubusercontent.com/7452323/QuantumultX/main/script/<app名>.js
https://raw.githubusercontent.com/7452323/QuantumultX/main/surge/<app名>.sgmodule
⚠️ 注意:仓库已有通用 script/RevenueCat.js(双阶段版本,已含 ETag 剥离),
不要为每个 App 重复造 RC 解锁轮子。专用脚本只在存在独有字段/逻辑时创建。
独占逻辑包括:去广告规则(按 App 特有的广告 SDK 域名)、非 RC 的独有 API 端点、特定 App 的参数格式等。
特别警惕:不要画蛇添足
- 如果 App 用的是 RevenueCat + rc-backup.com,先确认 RevenueCat.js 的 UA Mapping 是否匹配。RevenueCat.js 的 UAMapping 字典里如果已经有一个名字相似的 entry(如 "Reader" 映射到 "vd_monthly_999"),它可能属于另一个 App。
reader/46的 UA 会误触这个 mapping,给错误的 product ID。 - RevenueCat.js 的策略是:模式A(有真实数据→改过期时间)→ 模式B(无数据→UAMapping 伪造)。对于无购买历史的新用户,模式A会跳过,模式B用错 mapping 的话就翻车。
- 如果 RevenueCat.js 的 UAMapping 不匹配当前 App 的 product ID,写一个独立的稳定脚本(只改 expires_date,不注入伪造数据)是正确做法,不是画蛇添足。
- 如果 HAR 里的 RC 请求全是 304,这是正常情况——先开 request-header 阶段清 ETag 强制拿 200 之后再看响应结构。
- 先看已有脚本再动手,别让用户说你踏马写的什么鬼东西
限制可行性预判速查
拿到 HAR 文件后,必须先判断限制类型再动手写脚本:
| 响应特征 | 限制类型 | 能解? | 方案 |
|---|---|---|---|
unlocked: false, is_vip: false |
客户端标志 | ✅ | 改字段值 |
steps: [], content: null, 关键字段缺失 |
服务端返回过滤 | ❌ | 数据不发出,放弃 |
"tips":"购买会员..." |
客户端引导 | ✅ | 清空文案 |
POST paid_module_used |
服务端次数校验 | ✅ | mock {code:0} |
watch_type: 2 |
限制等级 | ✅ | 改 1(更宽松) |
带签名时效的视频 URL(t=) |
CDN Token 限时 | ❌ | 动态生成,不可伪造 |
| 纯 CDN URL(无签名参数) | 客户端播放器限制 | ❌ | 客户端行为,rewrite 改不了 |
视频 URL 含 v.0_10000. |
低清版 | ✅ | URL-rewrite v.0_10000. → v. |
Pitfalls
-
先看仓库再动手(7452323 铁律 #1) — 用户仓库已有
RevenueCat.js,写脚本前必须ls script/看看有哪些通用脚本。没检查就写 = 被骂。 -
不要混搭脚本功能 — RC 解锁归
RevenueCat.js,去广告归Rule/*.list。一个脚本文件只干一件事。不要写"RC解锁+去广告"大杂烩脚本。 -
脚本格式跟着仓库走 — 用户仓库的 .js 文件全部内嵌
[rewrite_local]注释。不要另写 .conf 文件。看一个已有脚本的写法再写新的。 -
Charles 默认不保存 response body — 勾选
Preferences → Recording → Include response bodies -
RevenueCat 304 缓存问题 — QX 抓到的 RC 请求可能全是 304(无 body)。必须用双阶段脚本:
script-request-header剥离 ETag headers(X-RevenueCat-ETag/If-None-Match)→ 强制返回 200 →script-response-body改订阅数据。仓库的RevenueCat.js已经实现好了,直接引用即可。详见references/revenuecat-dual-phase-unlock.md -
Telegram 不支持 .har 后缀 — 让用户改名为
.json再发送 -
paid_module_used 是必漏端点 — Openclaw 脚本多次遗漏此接口。App 在每个付费操作结束后调用扣减接口,不拦截则 VIP 用户实际使用一次扣一次。每次写脚本都要专项搜索这个端点,搜
POST+used/consume/deduct/cost/spend -
用户给参考了就别自作聪明 — 用户已经有可用的写法(哪怕是
body.replace()纯正则),就严格照搬,不要换成 JSON 解析版。纯正则看起来"简陋",但JSON.stringify()可能改变原始响应换行/缩进格式,让客户端检测到差异后弹非预期提示("1500+精选 限时特惠"那种)。能用的东西不要重新发明。 -
不要对用户说"做不到"——先试 — 即使觉得某个限制是服务端控制的,也要按用户给的参考方案试一遍。用户的实际测试比你的理论分析重要。
-
加载了这个技能就必须按步骤执行,不能看一眼就自己写 — 这个技能的流程顺序是:①克隆/检查仓库结构 → ②HAR域名分析 → ③定位关键端点 → ④判定限制类型 → ⑤写脚本 → ⑥推送到仓库。直接跳到第⑤步,跳过①-④,写出来的脚本100%是垃圾。每次"看了技能但还是自己乱写"都要被用户骂。如果已经加载了这个技能,commit to follow it — 每个步骤都有存在的理由。
-
QX + Surge 必须同步推,不能只写一边 — 7452323 仓库是双平台(QX + Surge)。新建一个 App 脚本时,必须在同一个 commit 里同时推
script/<app>.js和surge/<app>.sgmodule。只推 QX 脚本会导致用户问"Surge没同步吗?"。先 ls 两个目录看两边都有什么,再创建。 -
不同 App 即使解锁模式相同也要分别建文件 — 仓库里已有同名功能脚本(如
Epub.js拦截verifyReceipt)不代表当前 App 能用它。每个 App 的 bundle ID 不同,永远新建script/<不同名>.js+surge/<不同名>.sgmodule。不要修改或扩展现有同名脚本——用户会说"仓库的别动,两个不是同一个软件"。 -
Flutter/Dart App 的 RevenueCat UA 特征 — HAR 中 User-Agent 为
Dart/x.y (dart:io)或reader/xx CFNetwork/...的 App 是 Flutter 项目。其 UA 不会匹配 RevenueCat.js 中任何 URL-encoded AppName mapping(如Reader→vd_monthly_999),导致模式B匹配失败,回退到通用com.rc.universal.pro.yearly。正确做法:写独立脚本只改 expires_date(模式A),不依赖 UAMapping。
s3-presigned-upload-reverse
S3 Presigned URL 上传模式逆向
适用判断
- 网站提供文件/图片上传功能
- 上传不走传统
multipart/form-dataPOST - JS bundle 中有
s3、presigned、PUT、upload关键词 - 响应返回短链接/文件 ID 而非直接的文件 URL
三步工作流
Step 1: POST /api/s3 → 获取 presigned URL + key
Step 2: PUT {presigned_url} → 直传文件到 S3
Step 3: POST /api/upload → 提交元数据 → 返回短链接
端点模式
| Step | Method | URL | Body | 返回 |
|---|---|---|---|---|
| 获取凭证 | POST | /api/s3 |
{"content_type":"image/png"} |
{"url":"https://...s3...","key":"abc.png"} |
| 上传文件 | PUT | {url} (S3 presigned) |
文件 bytes, Content-Type, Content-Encoding: base64 |
HTTP 200 |
| 提交链接 | POST | /api/upload |
{"content_type":"image","file_names":["abc.png"],"expire_days":30} |
{"data":"X7nBTBl"} |
可选参数(提交时)
password— 访问密码memo— 备注max_views— 最大查看次数
纯短网址变体
POST /api/shortUrl
Body: {"long_url":"https://example.com"}
→ {"data":{"short_url":"abc123"}}
完整 Python 示例
import base64, requests
BASE = "https://target.com"
resp = requests.post(f"{BASE}/api/s3", json={"content_type":"image/png"})
data = resp.json()
with open("file.png","rb") as f:
b64 = base64.b64encode(f.read()).decode()
requests.put(data["url"], data=b64,
headers={"Content-Type":"image/png","Content-Encoding":"base64"})
resp = requests.post(f"{BASE}/api/upload", json={
"content_type":"image","file_names":[data["key"]],"expire_days":30
})
print(f"{BASE}/{resp.json()['data']}")
发现方法
- 搜索 JS bundle 中的
s3关键词 - 查看网络请求中的 PUT 方法
- S3 URL 特征:
amazonaws.com、X-Amz-查询参数
案例
- meee.com.tw — 免费图床,无认证,Nuxt 3 前端
web-font-anti-scraping
Web Font Anti-Scraping — 字体反爬逆向
适用场景
- 目标站点的正文使用自定义字体渲染,直接提取 HTML 得到乱码/PUA 字符
- 字体文件为 woff2/woff/otf 格式,通过
@font-face加载 - 同一站点每次请求可能使用不同字体文件(动态字体)
- 字体 cmap 表将标准汉字映射到 PUA 区码点 (U+E000-U+F8FF)
识别特征
| 特征 | 说明 |
|---|---|
| HTML 含不可见字符 | 复制粘贴得到乱码或空白字符 |
CSS @font-face |
引用 woff2/woff 字体文件 |
| 字体子集 | 字体文件远小于完整字体(47KB vs 16MB) |
| PUA 码点 | 文本中大量 U+E000-U+F8FF 范围字符 |
| 字体来源声明 | name 表可能含 "subset font of XXX" |
方案优先级
方案 A: Glyph 轮廓对比(最可靠)
原理:混淆字体是标准字体的子集,glyph 轮廓完全相同。
from fontTools.ttLib import TTFont
from fontTools.pens.recordingPen import RecordingPen
def extract_outlines(font_path):
font = TTFont(font_path)
gset = font.getGlyphSet()
outlines = {}
for name in gset.keys():
pen = RecordingPen()
gset[name].draw(pen)
ops = tuple(
(op_type, tuple(tuple(a) if isinstance(a, (list, tuple)) else a for a in args))
for op_type, args in pen.value
)
outlines[name] = ops
font.close()
return outlines
fq_outlines = extract_outlines('/tmp/fq_font.woff2')
src_outlines = extract_outlines('/tmp/SourceHanSansSC-Regular.otf')
src_outline_to_name = {v: k for k, v in src_outlines.items()}
fq_to_src = {}
for fq_name, fq_outline in fq_outlines.items():
if fq_outline in src_outline_to_name:
fq_to_src[fq_name] = src_outline_to_name[fq_outline]
# 通过 cmap 获取真实 Unicode
fq_font = TTFont('/tmp/fq_font.woff2')
src_font = TTFont('/tmp/SourceHanSansSC-Regular.otf')
fq_cmap = fq_font.getBestCmap()
src_cmap = src_font.getBestCmap()
fq_rev = {v: k for k, v in fq_cmap.items()}
src_rev = {v: k for k, v in src_cmap.items()}
pua_to_real = {}
for fq_name, src_name in fq_to_src.items():
pua_unicode = fq_rev.get(fq_name)
real_unicode = src_rev.get(src_name)
if pua_unicode and real_unicode:
pua_to_real[pua_unicode] = real_unicode
关键:需要获取原始字体文件作为参考。Noto Sans SC 与 SourceHanSansSC 轮廓不匹配。
方案 B: Playwright 渲染提取(无需参考字体)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until='networkidle', timeout=30000)
page.wait_for_timeout(3000)
content = page.evaluate('''() => {
const el = document.querySelector('.chapter-content, .read-content');
return el ? el.innerText : '';
}''')
browser.close()
注意:必须用 innerText(渲染后),不是 textContent。
方案 C: 上下文推导映射表(兜底)
根据上下文语义推断 PUA 字符对应的真实汉字。
Pitfalls
- Noto Sans SC ≠ SourceHanSansSC — 不同家族,轮廓不匹配
- Adobe 字体链接 404 — GitHub adobe-fonts 经常失效
- 多对一映射 — 多个 PUA 字符映射到同一汉字
- innerText vs textContent — 必须用 innerText
- Desktop UA 必需 — 番茄小说 iPhone UA 返回空数据
实战案例
详见 references/fanqie-font-obfuscation.md(番茄小说完整逆向笔记)