📚 离职知识库

逆向第三方 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、伪造身份实施欺诈、爆破遍历——任何情况都不做(见文末红线)。


#动手前必做:暴露面三问(要登录 / 要真跑任务前,念给用户听,别事后补交代)

  1. 会暴露什么身份:Google/GitHub 登录 = 对方拿到你的邮箱 + 姓名,等同正式注册;IP 一定留。
  2. 请求在对方日志里长什么样:非官方客户端、异常参数、连续畸形提交,都认得出。
  3. 事后能清到什么程度:本地 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_nameclientInfo.nameUser-Agentredirect_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 且保持单一
  • 告诉用户:想隔离主账号,最实际的办法是官方客户端 + 一个专门小号 + 少跑,而不是幻想"隐身"

#十个坑(精选,按重要性)

  1. ❗身份字段带了"侦察"字样——client_name="xxx-recon"MCP clientInfo={"name":"recon"}(更隐蔽易漏),原样入对方库。动手前就设中性值。
  2. 测试素材自我指涉——第一条视频喂"白板手绘视频的原理",等于告诉对方"这人在研究我们"。用完全无关的真实内容(新闻/博客)。
  3. Cloudflare 按 UA 拦截——urllib 默认 UA 直接 403,curl 却成功。每个请求函数都要加 UA(别只加一处)。
  4. 后台跑 python 看不到输出——非 tty 全缓冲。用 python3 -uprint(flush=True);交互式脚本必须 -u
  5. OAuth 回调端口被占——跑前 lsof -ti:8765 | xargs kill -9;且把起监听挪到动态注册之前,端口起不来就退出,别白注册一个客户端。
  6. /mcp 授权没落盘但不报错——钥匙串 accessToken 是空字符串。别信"我认证过了",用命令验证;验不过就自搭 OAuth(token 还更好用,能 curl 能落盘)。
  7. 下产物 403 但落了 0 字节文件——storage 域名也按 UA 拦截,curl -O 不看状态码照样落盘。用 curl -s -A "Mozilla/5.0" -O,下完 ls -la 核对大小。
  8. 后缀 .png 实为 WebP——手搓解尺寸出天文数字。用 file 看真实格式,或直接 Image.open(p).size
  9. 只读校验接口枚举 schema 是死路——对方错误信息脱敏,连试 16 次只有第一次有用。试 2-3 次收手,连续畸形提交在日志里最可疑、性价比最低。
  10. nohup ... & 在工具调用里存活不下来——用工具自己的 run_in_background: true
  11. ❗复刻带界面的产品,动手前一定先截原站——只从压缩 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)

#逆向一个新目标时,从这里开始

  1. 第 0 步判断值不值得逆。
  2. 重点砸在第 1 步(公开端点)和第 5 步(产物统计分析)——这两步性价比远高于其他所有步骤。
  3. 第 4 步(登录)能不做就不做;一旦要做,先念「暴露面三问」,再逐条过 OPSEC Checklist。
  4. 全程分开标注「实测结论」与「推断」,收尾清痕迹 + 如实交代清不掉的部分。

来源:沉淀/04-逆向与数据导出/逆向第三方SaaS-精华SOP.md(整理于 2026-08-18)