逆向第三方 SaaS 精华 SOP(方法 + 伪装合一)
#这份文档是干嘛的
把一个闭源 SaaS 产品(尤其"AI 包装类")拆到能自己复刻的程度——拿到它的接口契约、提示词、数据结构、技术栈、真实性能数字,同时把自己的暴露面控制住、事后能清干净、不在对方日志里显得像个在系统性研究接口的人。
这是 逆向第三方SaaS产品-SOP.md(方法论)+ 逆向伪装与暴露面管理-SOP.md(OPSEC)两份的合并精华版。以后逆向新目标,把这一份拖进新的 Claude Code 会话即可开始。配套 skill:~/.claude/skills/逆向/,说"逆向 xxx"会自动走全套。
沉淀自 2026-07-21 对 SpeedPainter(A1D.AI 旗下,文本→手绘白板视频)的一次完整逆向,从零到"拿到笔画排序算法"约 3 小时。
#先划边界(这不是攻击)
合法研究的 OPSEC = 不制造虚假的异常信号,让正常研究行为不被误判成攻击。我们是不想被误会,不是想逃避追责。清日志、绕 WAF、伪造身份实施欺诈、爆破遍历——任何情况都不做(见文末红线)。
#动手前必做:暴露面三问(要登录 / 要真跑任务前,念给用户听,别事后补交代)
- 会暴露什么身份:Google/GitHub 登录 = 对方拿到你的邮箱 + 姓名,等同正式注册;IP 一定留。
- 请求在对方日志里长什么样:非官方客户端、异常参数、连续畸形提交,都认得出。
- 事后能清到什么程度:本地 token/配置能删干净;对方服务器上的注册记录、任务记录、产物 URL、IP 关联删不掉。
讲完再问要不要做。
#环境前置
- macOS + Claude Code,
curl/git/ffmpeg/python3就绪 - Python 依赖开虚拟环境:
python3 -m venv .venv && ./.venv/bin/pip install numpy pillow opencv-python-headless(系统 python 常没 numpy) - 临时文件全放会话 scratchpad,不污染家目录
- 要登录:需用户本人在浏览器完成一次 OAuth,AI 代替不了
- 自己的 key/密码/本地文件,一个字节都不进发给对方的请求
#正确流程(照着做)
#第 0 步:判断值不值得逆
看它的 GitHub 仓库。如果仓库里只有提示词 + 一个远程服务地址,说明能力全在服务端,这类最值得逆——多半是"拼装第三方 API + 一层编排",护城河比看起来薄。
git clone --depth 1 <repo> ev && find ev -type f -not -path '*/.git/*'
grep -rhoE "https?://[a-zA-Z0-9./_%#?=-]+" ev --exclude-dir=.git | sort -u判据:拿到 API 域名 + 一份 SKILL.md / 提示词文件。
#第 1 步:先薅公开端点(零成本、零暴露,收益最大,往往占全部情报 60%)
API=https://api.example.com
curl -s $API/openapi.json | python3 -m json.tool | head -50 # ← 优先级最高
curl -s $API/ | head -c 500 # 根路径常是 SwaggerUI
curl -s $API/.well-known/oauth-authorization-server
curl -s $API/.well-known/oauth-protected-resource
curl -s $API/api/templates # 素材/配置端点常不鉴权实测:/openapi.json 完全不鉴权,直接拿到全部字段约束、枚举值、任务状态机,还暴露了更老产品的完整参数面(渲染引擎能力边界)。判据:拿到 JSON schema 或 SwaggerUI。
#第 2 步:读测试文件和 CI 配置
cat ev/test/*.test.* ev/.github/workflows/* 2>/dev/null开发者会写"防止文档泄漏供应商名"的检查,而那条正则本身就是供应商名单。那次一条 forbiddenSourceNames 直接坐实全部第三方模型商,还暴露了"BGM 已实现但对外隐藏"。
#第 3 步:读"高级模式/手动模式"文档
有"客户端本地做一部分"的高级模式,服务端提示词就等于泄漏了——客户端要执行同样逻辑,文档不得不写出规则。那次 advanced-review.md 直接给了分镜数公式、每 10 秒字数区间、生图"视觉锁"提示词全文、manifest 完整结构。
#第 4 步:需要登录时,自己搭 OAuth(PKCE + 动态注册)
先过完「暴露面三问」再动手。 脚本见文末。
lsof -ti:8765 | xargs kill -9 2>/dev/null # 先清端口
python3 -u sp_oauth.py # 必须 -u,否则后台看不到授权链接把打印的授权链接给用户 → 浏览器登录 → 本地回调拿 code → 换 token → 调 tools/list。判据:拿到 access_token + 完整工具 JSON Schema。
优先用官方客户端(
npx装官方插件走官方通道),指纹天然一致,比自搭更安全(见「伪装」章)。
#第 5 步:跑最少量真实任务,从产物反推(拿护城河的唯一途径)
跑前跟用户确认(消耗额度 + 叠加暴露)。原则:一条最短的 + 一条典型的就够。 产物到手挨个查:
# 1. 视频 metadata —— encoder 字段常直接泄漏渲染器
ffprobe -v error -show_format -show_streams v.mp4 | grep -Ei "encoder|codec|width|height|frame_rate|channels"
# 2. 有没有 BGM —— 静音检测是唯一可靠办法
ffmpeg -i v.mp4 -af silencedetect=n=-45dB:d=0.25 -f null - 2>&1 | grep silence_
# 3. 逐秒关键帧看版式/分阶段
ffmpeg -v error -i v.mp4 -vf "fps=1,scale=640:-1" frames/f%02d.png
# 4. 返回 JSON 里的 timings —— 对方各阶段真实耗时,最好的对标基准⭐ 最值钱的一招:如果它返回中间产物(SVG/图片/字幕),对中间产物做统计分析,能反推出算法。
那次它返回 outline.svg,统计 <path> 文档顺序 vs 起点坐标:
import re, glob, numpy as np
for f in glob.glob("*_outline.svg"):
ds = re.findall(r'<path d="([^"]+)"', open(f).read())
pts = [re.match(r'\s*[Mm]\s*(-?[\d.]+)[\s,]+(-?[\d.]+)', d) for d in ds]
ys = np.array([float(m.group(2)) for m in pts if m])
xs = np.array([float(m.group(1)) for m in pts if m])
print(f, f"y递增{np.mean(np.diff(ys)>0):.0%} x递增{np.mean(np.diff(xs)>0):.0%}")y 递增 80–100%、x ≈随机 ⇒ 核心算法就是一句 sort by 起点y。一行统计代码拆穿护城河。
#第 6 步:立刻落盘归档
产物 URL 常是无签名公开链接但可能过期,拿到全下。归档到桌面:
逆向素材/
00-这些是什么.md ← 索引:每个文件怎么来的、价值在哪
01-接口契约/ 02-官方提示词/ 03-他们的素材/ 0N-真实任务产物/每个产物目录配 00-拆解报告.md,「实测结论」和「推断」必须分开标注。
#第 7 步:清理痕迹
rm -f sp_token.json # access + refresh token
claude mcp remove --scope user <server-name> # 如果加过 MCP
security find-generic-password -s "Claude Code-credentials" -w | python3 -c "
import json,sys; d=json.load(sys.stdin)
print('钥匙串残留:', [k for k in d.get('mcpOAuth',{}) if '<name>' in k] or '无')"只能让用户自己做的:myaccount.google.com/permissions 撤销授权、去对方网站删账号。
清不掉的(如实告知):对方服务器上的 OAuth 客户端注册、任务记录、已产生的公开产物 URL、IP 与身份关联。
#伪装一:客户端指纹(动手前就设中性值)
| 指纹点 | 怎么暴露 | 正确做法 |
|---|---|---|
| User-Agent | 缺失 / python-urllib 默认 UA → Cloudflare 直接 403 |
带中性 UA(但见"一致性") |
OAuth client_name |
动态注册时原样入库 | 中性名 mcp-client,绝不 recon/scan/test/hack |
MCP clientInfo.name |
initialize 握手入库,最易漏 | 同上 |
redirect_uri 端口 |
自造端口 ≠ 官方(Claude Code 官方 3118) | 意识到它暴露"自写客户端" |
| TLS/HTTP2 指纹 (JA3/JA4) | 每个 HTTP 库固定,换 UA 换不掉 | 见"一致性" |
| 请求头数量/顺序 | 浏览器 15-25 个,脚本 3-5 个 | 用官方 SDK / 真实浏览器天然一致 |
审计清单(每次动手前逐条过):client_name、clientInfo.name、User-Agent、redirect_uri 端口、脚本里任何会被发出去的字符串——全设中性值。
一致性比伪装更重要:UA 说自己是浏览器、TLS 指纹却是 python httpx,这种不一致比老实用 python UA 更可疑。要么全真(官方 SDK / 真实浏览器),要么别假装。
#伪装二:行为指纹(什么序列会被判为侦察)
| 可疑行为 | 改成 |
|---|---|
| 请求节奏精确规整(每 2 秒) | 随机化间隔 |
不守 Retry-After/pollAfterSeconds |
严格遵守服务端节流值(最明显的非真实信号) |
| 顺序遍历 ID(1,2,3…) | 只查有明确意图的 |
| 先枚举 schema 再操作 | 少枚举,2-3 次收手 |
| 变量控制式参数(一中一英、10s/60s 其余全同) | 参数取"人会选的值",别搞整齐对照 |
| 极端参数(产品自己都警告"太短"的时长) | 用默认值 / 常见值 |
| 一次性批量下载全部产物 | 只下要分析的 |
| "太干净"(无失败无重试) | 不必刻意,但别追求完美整洁 |
#伪装三:身份与网络层(换不掉的诚实说明)
- IP 一定留,"同一 IP 多个新账号 + 行为相似"本身就是模式,换号不解决。
- 换号 ≠ 换指纹:只改 UA、TLS/HTTP2 指纹不变,仍识别为同一客户端多账号。
- 数据中心 IP(AWS/DO)被标记为非真实;真要用代理选住宅 IP 且保持单一。
- 告诉用户:想隔离主账号,最实际的办法是官方客户端 + 一个专门小号 + 少跑,而不是幻想"隐身"。
#十个坑(精选,按重要性)
- ❗身份字段带了"侦察"字样——
client_name="xxx-recon"、MCPclientInfo={"name":"recon"}(更隐蔽易漏),原样入对方库。动手前就设中性值。 - 测试素材自我指涉——第一条视频喂"白板手绘视频的原理",等于告诉对方"这人在研究我们"。用完全无关的真实内容(新闻/博客)。
- Cloudflare 按 UA 拦截——
urllib默认 UA 直接 403,curl 却成功。每个请求函数都要加 UA(别只加一处)。 - 后台跑 python 看不到输出——非 tty 全缓冲。用
python3 -u或print(flush=True);交互式脚本必须-u。 - OAuth 回调端口被占——跑前
lsof -ti:8765 | xargs kill -9;且把起监听挪到动态注册之前,端口起不来就退出,别白注册一个客户端。 /mcp授权没落盘但不报错——钥匙串accessToken是空字符串。别信"我认证过了",用命令验证;验不过就自搭 OAuth(token 还更好用,能 curl 能落盘)。- 下产物 403 但落了 0 字节文件——storage 域名也按 UA 拦截,
curl -O不看状态码照样落盘。用curl -s -A "Mozilla/5.0" -O,下完ls -la核对大小。 - 后缀 .png 实为 WebP——手搓解尺寸出天文数字。用
file看真实格式,或直接Image.open(p).size。 - 只读校验接口枚举 schema 是死路——对方错误信息脱敏,连试 16 次只有第一次有用。试 2-3 次收手,连续畸形提交在日志里最可疑、性价比最低。
nohup ... &在工具调用里存活不下来——用工具自己的run_in_background: true。- ❗复刻带界面的产品,动手前一定先截原站——只从压缩 JS 扒类名文案能拿到逻辑/数据结构/提示词,但拿不到布局骨架和交互形态(几栏、哪块是持久框、有几个 tab)。复刻
sl.imwsl.com时凭 JS 猜成了移动单列,原站其实是桌面左右分栏,还漏了整块功能,用户截图才发现返工。规矩:① 写前端前先截原站(桌面+移动两断点)存进0N-原站界面/;② 自己就能截——Chrome 扩展连不上就用 playwright(npx playwright install chromium+ 一个 .mjs 打开公开页截图存 /tmp,再 Read 看图);③ 做完必须截自己的站 vs 原站并排对照。详见~/Desktop/配置信息/给ClaudeCode装浏览器眼睛.md。
#已知死路(别浪费时间)
- 只读校验接口枚举 schema:错误信息脱敏后就是死路。
- 从生成结果反推 LLM 供应商:反推不出来,除非元数据带模型名;只能从耗时做弱推断。
- 拿服务端源码/算法本身:不可能,只能从中间产物统计分析间接反推(第 5 步)。
- 删对方服务器记录:动态注册响应没返回
registration_access_token就删不掉。
#红线(任何情况不做)
- ❌ 越权 / 绕鉴权 / 爆破 / 遍历他人 ID
- ❌ 压测 / DoS / 高频轰炸
- ❌ 注入 / WAF 绕过 / 清日志 / 反取证
- ❌ 为规避付费或额度批量注册账号(要的是情报不是白嫖额度)
- ❌ 把对方版权素材用进生产(只作构图分析,生产素材自己生成)
- ❌ 用户凭据进入发给对方的请求
#OPSEC Checklist(动手前逐条过)
前期
- 跟用户过完「暴露面三问」,得到明确同意
- 能不能全走公开端点?(不登录就零暴露)
- 要登录的话,能不能用官方客户端而非自搭?
身份
-
client_name中性 /clientInfo.name中性(别漏)/ UA 中性且不自相矛盾 - 脚本里没有 recon/scan/test/内部代号等字符串
行为
- 守服务端节流值 / 测试数据真实无关非自指 / 参数是"人会选的值"
- schema 枚举·畸形提交 ≤ 2-3 次 / 多任务隔开时间 / 只下要分析的产物
收尾
- 删本地 token / MCP 配置 / 钥匙串残留
- 提醒用户撤销授权、删对方账号
- 如实说明清不掉的部分(注册记录、IP 关联)
#可复用脚本:远程 MCP 的 OAuth(PKCE + 动态注册)
#!/usr/bin/env python3
"""跑起来打印授权链接 → 用户浏览器登录 → 本地回调拿 code → 换 token → 拉 tools/list。
必须用 python3 -u 跑,否则后台看不到链接。"""
import base64, hashlib, http.server, json, os, secrets, threading, time
import urllib.parse, urllib.request
BASE = "https://api.example.com" # ← 改成目标
REDIRECT = "http://localhost:8765/callback"
UA = {"User-Agent": "claude-code/1.0"} # 缺这个会被 Cloudflare 403
OUT = os.path.dirname(os.path.abspath(__file__))
captured = {}
def post_json(url, payload, headers=None):
h = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream", **UA}
if headers: h.update(headers)
req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=h, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode()
def post_form(url, payload):
req = urllib.request.Request(
url, data=urllib.parse.urlencode(payload).encode(), method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded", **UA})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
captured.update({k: v[0] for k, v in
urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).items()})
self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers(); self.wfile.write("<h2>授权完成,可以关掉本页</h2>".encode())
def log_message(self, *a): pass
def main():
# 先起监听(端口起不来就退出,避免白注册一个客户端)
srv = http.server.HTTPServer(("127.0.0.1", 8765), Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
reg = json.loads(post_json(f"{BASE}/oauth/register", {
"client_name": "mcp-client", # ← 中性名,别写 recon/scan/test
"redirect_uris": [REDIRECT],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}))
cid, csec = reg["client_id"], reg.get("client_secret")
verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).decode().rstrip("=")
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
print("\n=== 用浏览器打开完成登录授权 ===\n")
print(f"{BASE}/authorize?" + urllib.parse.urlencode({
"response_type": "code", "client_id": cid, "redirect_uri": REDIRECT,
"scope": reg.get("scope", ""), "state": secrets.token_urlsafe(16),
"code_challenge": challenge, "code_challenge_method": "S256"}))
for _ in range(300):
if "code" in captured or "error" in captured: break
time.sleep(1)
srv.shutdown()
if "code" not in captured:
print("[失败]", captured); return
form = {"grant_type": "authorization_code", "code": captured["code"],
"redirect_uri": REDIRECT, "client_id": cid, "code_verifier": verifier}
if csec: form["client_secret"] = csec
tok = post_form(f"{BASE}/oauth/token", form)
open(f"{OUT}/token.json", "w").write(json.dumps(tok, indent=2))
hdr = {"Authorization": f"Bearer {tok['access_token']}"}
print("[initialize]", post_json(f"{BASE}/mcp", {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "mcp-client", "version": "1.0"}}}, hdr)[:600]) # ← 中性名
open(f"{OUT}/tools.json", "w").write(
post_json(f"{BASE}/mcp", {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, hdr))
print("已保存 token.json / tools.json")
if __name__ == "__main__":
main()配套 mcp.py(拿到 token 后调工具):
import json, urllib.request
TOK = json.load(open("token.json"))["access_token"]
def rpc(method, params=None, _id=1):
body = {"jsonrpc": "2.0", "id": _id, "method": method}
if params is not None: body["params"] = params
req = urllib.request.Request(
"https://api.example.com/mcp", data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json", "User-Agent": "claude-code/1.0",
"Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {TOK}"})
try:
with urllib.request.urlopen(req, timeout=120) as r: return r.read().decode()
except urllib.error.HTTPError as e: return f"HTTP {e.code}: {e.read().decode()[:800]}"
def call(name, args): return rpc("tools/call", {"name": name, "arguments": args}, 9)#逆向一个新目标时,从这里开始
- 第 0 步判断值不值得逆。
- 重点砸在第 1 步(公开端点)和第 5 步(产物统计分析)——这两步性价比远高于其他所有步骤。
- 第 4 步(登录)能不做就不做;一旦要做,先念「暴露面三问」,再逐条过 OPSEC Checklist。
- 全程分开标注「实测结论」与「推断」,收尾清痕迹 + 如实交代清不掉的部分。
来源:沉淀/04-逆向与数据导出/逆向第三方SaaS-精华SOP.md(整理于 2026-08-18)