纯协议逆向某追剧站:HmacSHA256 签名 + AES 双层解密 + CDN 防盗链 403 全链路拆解

做视频站逆向,真正难的从来不是"看懂一段加密函数",而是把散落在十几个混淆 chunk、几十个请求字段、还有一层 CDN 防盗链里的线索,一条条对上,最后串成一条能稳定跑通的链路。任何一环猜错,你都会卡在一个"看起来像 bug、其实是设计"的地方动弹不得。

这篇文章记录的就是这么一条链路:不开浏览器、纯协议 ,拿到某追剧站(下文统一称「某站」)免费剧集的真实直链并下载下来。目标很克制------只要免登录能看的标清(SD),不碰需要会员的 HD/4K。但麻雀虽小,五脏俱全:它同时踩中了请求签名、响应加密、二次加密、字段陷阱、防盗链 IP 绑定五类典型反爬。

⚠️ 全文所有网站名称、域名、CDN 厂商、链接一律用「某站 / 某域名 / 某 CDN」脱敏,只保留可复用的逆向方法与协议结构。代码是真实可跑的实现,仅供学习与个人备份研究。

战果先放这

先把结论摆出来,免得你读完发现是"理论上可行":

  • 端到端跑通:输入一个剧集 ID,自动定位到某一集 → 解出真实直链 → 下载成可播放的 mp4。
  • 实测某免费剧 E1 完整下载:174MB / 时长约 41 分钟 / H.264 960×540 + AAC ,ffprobe 校验通过、可正常播放。
  • 整条链路不依赖浏览器、不依赖登录态 (标清),纯 node 脚本 + 内置 crypto

下面按"关卡"拆,每一关都给出:怎么发现的、怎么验证的、最后的代码长什么样

全链路一图流

把整条链路画出来,后面每一节都是在啃其中一格:

text 复制代码
            ┌─────────────────────────────────────────────────────────┐
            │  第①关 请求签名:每个请求头带 x-ca-sign = HmacSHA256(签名串) │
            ▼                                                         │
  GET /m-station/drama/page?dramaId&isAgeLimit=0[&episodeSid]  ──────┘
            │
            ▼  第②关 响应解密:body 是 base64 → AES-128-ECB → JSON
       { data: { newSign, watchInfo:{ m3u8:{ url: <密文> } }, episodeList[], authorityInfo:{playRestricted} } }
            │
            ▼  第③关 播放地址解密:AES-128-CBC, key = newSign[4:20], iv = 固定
       https://<某CDN直链>/xxxx-ld.mp4?key=..&time=..&rk=..&uid=..   ← 真实直链(带签名,4 小时有效)
            │
            ▼  第④关 CDN 防盗链:token 按客户端 IP 绑定 → 出口不对就 403
       失败就重新签发 + 重试,直到下载出口与签发出口对齐 → 200/206 → 下完

接下来逐关拆。

第①关:请求签名 x-ca-sign

抓包看任意一个接口请求,头里都有一组固定的"指纹":x-ca-signtclientTypeclientVersiondeviceIdaliIdumiduettoken。其中 t 是毫秒时间戳,x-ca-sign 显然是签名------改一个字节服务端就 4xx。

在混淆后的前端 chunk 里顺着 x-ca-sign 往回找,能定位到签名是 HmacSHA256 + base64 ,而被签名的不是 body,而是一段按固定顺序拼出来的字符串 。复原后的签名串长这样(注意是真正的换行 \n):

text 复制代码
{METHOD}\naliId:{aliId}\nct:{ct}\ncv:{cv}\nt:{t}\n{规范化路径}

这里有两个容易翻车的细节,也是逆向里最值得记下来的经验:

  1. "规范化路径"必须和真实请求 URL 完全一致。它把 query 参数按 key 排序后拼回 path,签名用它、发请求也用它------一旦你发请求时参数顺序和签名时不一样,服务端用它自己排序后的串去验签就对不上。
  2. 签名串里的 aliId/ct/cv/t 要和请求头里的同名字段一一对应,少一个、错一个都验不过。

签名所需的密钥是写死在前端的常量。复原成 Node 实现就是这样(域名已脱敏,密钥即源码原样):

javascript 复制代码
import crypto from "node:crypto";

const SIGN_KEY = "ES513W0B1CsdUrR13Qk5EgDAKPeeKZY"; // HmacSHA256 的密钥(前端硬编码)
const DEC_KEY  = "3b744389882a4067";                // 响应解密 AES-ECB 的 key(下一关用)
const BASE     = "https://api.某站.com";             // API 域名(已脱敏)

// 规范化路径:参数按 key 排序后拼回 path ------ 签名串与真实请求都用它
function canon(url, params) {
  const sp = new URLSearchParams(params); sp.sort();
  const r = sp.toString();
  let o = url.indexOf("?") > -1 ? `${url}&${r}` : (r ? `${url}?${r}` : url);
  if (o.endsWith("&")) o = o.replace(/&$/, "");
  return o;
}

const hmac = (m, k) => crypto.createHmac("sha256", k).update(m, "utf8").digest("base64");

封装成一个通用 api(),把签名头和那一堆"指纹头"都补齐。这里有个反 429 的小技巧 :umid / aliId 这类设备指纹每次换成随机值,可以明显降低被限流的概率。

javascript 复制代码
const uuid = () => crypto.randomUUID().toUpperCase();
const DEVICE = uuid();

export async function api(path, params = {}, {
  method = "GET", ct = "web_pc", cv = "1.0.0",
  token = "", umid = "", aliId = "", deviceId = DEVICE, extraHeaders = {},
} = {}) {
  const t = Date.now();
  const cpath = canon(path, params);
  // 签名串:方法\naliId:..\nct:..\ncv:..\nt:..\n规范化路径
  const signStr = `${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${t}\n${cpath}`;
  const headers = {
    "x-ca-sign": hmac(signStr, SIGN_KEY), t: String(t),
    clientType: ct, ct, clientVersion: cv, cv,
    deviceId, umid, aliId, uet: "9", token,
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
    Origin: "https://m.某站.com", Referer: "https://m.某站.com/", // 站点已脱敏
    ...extraHeaders,
  };
  const res = await fetch(BASE + cpath, { method, headers });
  const body = await res.text();
  let parsed = null, decoded = null;
  try { decoded = dec(body); parsed = JSON.parse(decoded); } catch { try { parsed = JSON.parse(body); } catch {} }
  return { status: res.status, parsed, raw: body, decoded };
}

签名这关过了,服务端就肯返回数据了------但你会发现返回的是一坨 base64,看不懂。这就是第②关。

第②关:响应解密(AES-128-ECB)

响应体是一串 base64,解 base64 之后也不是 JSON,而是二进制。在前端找到对应的解密逻辑,是一个最朴素的 AES-128-ECB :16 字节 key、无 IV、PKCS7 padding。key 同样是前端写死的常量(上面的 DEC_KEY)。

javascript 复制代码
// 响应体 = base64( AES-128-ECB(明文 JSON) ),无 IV,PKCS7
function dec(b64) {
  const d = crypto.createDecipheriv("aes-128-ecb", Buffer.from(DEC_KEY, "utf8"), null);
  return Buffer.concat([d.update(Buffer.from(b64, "base64")), d.final()]).toString("utf8");
}

解出来就是干净的 JSON 了。两层"锁"------请求侧 HMAC 签名 + 响应侧 AES 解密------构成了这套接口的基础防护,逻辑上就是下面这张图:

到这里,核心接口 drama/page(剧详情 + 剧集列表 + 当前集播放信息)已经能读了。但真正的"宝藏"------播放地址------还在第三层加密里,而且藏的位置是个坑

第③关:播放地址解密(最大的坑:watchInfo,不是 sortedItems)

解密后的 data 里,有一个 sortedItems 数组,字段名一看就像清晰度列表:qualityCode / canPlay / canShowLogin...... 第一反应当然是"播放地址肯定在这里面"。

结果在这卡了很久 :sortedItems 里压根没有可解密成直链的密文,它只是清晰度元数据(告诉你 SD/HD/4K 哪个能播、要不要登录)。真正的播放密文在另一个字段:

text 复制代码
data.watchInfo.m3u8.url   ← 真身在这里

这是逆向里非常典型的陷阱:字段名最像的那个,往往是幌子 。靠"猜字段名"会反复扑空,正确做法是把每个看起来像 URL/密文的字段都拖去试解密 ,谁能解出 http... 谁才是真的。

解这层密文用的是 AES-128-CBC ,但它的 key 设计得很"贼":不是写死的常量,而是从同一份响应的 newSign 字段里截取的 ------取 newSign 的第 4 到第 20 个字符(共 16 字节)当 key,IV 则是另一个固定常量。

javascript 复制代码
const PLAY_IV = "b1da7878016e4e2b"; // 固定 IV(16 字节 utf-8)

// 播放地址解密:AES-128-CBC,key 藏在响应的 newSign 字段里(第 4~20 字符)
export function decPlayUrl(cipherB64, newSign, iv = PLAY_IV) {
  const key = newSign.substring(4, 20);
  const d = crypto.createDecipheriv("aes-128-cbc", Buffer.from(key, "utf8"), Buffer.from(iv, "utf8"));
  return Buffer.concat([d.update(Buffer.from(cipherB64, "base64")), d.final()]).toString("utf8");
}

解出来是一个带签名参数的真实直链 (标清是 *-ld.mp4),URL 里挂着 key/time/rk/uid/seasonId 等参数,其中 time 约等于"签发时刻 + 4 小时",也就是这条直链的有效期只有 4 小时

把第①②③关串起来,"拿到某一集真实地址"就是这么一个小函数:

javascript 复制代码
async function resolveEpUrl(episodeSid) {
  const d = await pageCall(episodeSid ? { episodeSid: String(episodeSid) } : {});
  const cipher = d.watchInfo?.m3u8?.url;   // 真身在 watchInfo,不在 sortedItems
  if (!cipher) return null;
  return decPlayUrl(cipher, d.newSign);    // key 就在同一份响应里
}

第④关之前的两个小坑:按集定位 & 受限判定

坑 A:drama/page 必须带 isAgeLimit,而"指定第几集"只认一个参数

drama/page 不带 isAgeLimit 会拿不到正常数据;默认只返回第 1 集watchInfo。想要第 2、3、N 集,就得告诉接口"我要哪一集"。

问题是:episodeList[i] 里同时有 id / sid / episodeNo / episodeId / number 一堆候选字段,到底传哪个、用哪个参数名能切集? 与其瞎猜,不如用控制变量法 写个小探针:固定其他条件,逐个参数名去试,然后看解出的直链里那个文件 ID 有没有变------变了就说明这个参数真的切到了别的集。

javascript 复制代码
// 探针:逐个参数名试"指定第 2 集",看解出的 fileId 是否变化
const epSid = "377403"; // episodeList[1] 的 sid
for (const key of ["episodeSid", "sid", "episodeId", "episodeNo", "number"]) {
  const v = (key === "episodeNo" || key === "number") ? "2" : epSid;
  const r = await page({ [key]: v });
  const diff = r.fileId && r.fileId !== base.fileId ? "★变了(命中指定集!)"
             : (r.fileId ? "同默认集" : "无cipher");
  console.log(`page +${key}=${v}: fileId=${r.fileId} ${diff}`);
  await sleep(3000);
}

跑下来结论很干脆:只有 episodeSid=<episodeList[i].sid> 真正生效 ,其它参数名(sid/episodeId/episodeNo/number)统统被忽略、照样返回默认集。这种"只有一个参数名管用"的细节,猜十次不如探一次。

顺带一提:还有个 drama/play 接口看起来更"直给",但它免登录直接 403 (需要 token)。能用免登录的 drama/page 拿到 watchInfo 就不碰它------逆向要走阻力最小的路。

坑 B:playRestricted ------ 空数据不是 bug,是"未登录的合法返回"

有些剧解出来 episodeList / watchInfo 全是空,一开始会以为是解析写错了。其实看 data.authorityInfo.playRestricted 就明白了:

  • playRestricted = 1:免费、免登录可播。
  • playRestricted = 3:受限(VIP/需登录)。此时接口对未登录用户合法地返回空 episodeListwatchInfo 全 null。

再细一点,sortedItems[0]canPlay/canShowLogin/canShowVip 标记了每档清晰度的门槛:SD 通常 canPlay:true && canShowLogin:false(免登录可播),HD/4K 则 canShowLogin/canShowVip:true(要登录或会员)。判清楚"是受限还是真没有",能省掉大量无谓的 debug。

第④关(最硬):CDN 防盗链 403 ------ token 按 IP 绑定

直链解出来了,curl 一下却 403 "Invalid Request"。这关最费劲,也最有意思。

先摸清 CDN 的两层结构:

  • 直链的 host 是某 CDN 的调度器 :它对任何 key(哪怕你把 key 改坏、改过期、删掉)都老老实实回 302,根本不做鉴权------所以"调度器能 302"会给你一种"鉴权过了"的错觉。
  • 真正鉴权发生在被 302 指过去的边缘节点。403 就是边缘节点拒的。

那到底为什么 403?把"同一条直链在不同网络环境下的成败"对比一下,根因浮出水面:token 是按客户端出口 IP 绑定的------签发这条直链时,服务端记下了"请求 API 的那个出口 IP";下载时边缘节点会校验"来下载的出口 IP 是不是同一个",不一致就 403。

偏偏本机挂了按域名分流的代理(TUN) :签发 API 和下载 CDN 走的可能是不同的代理出口(实测签发时服务端看到的 IP,和真实下载出网的 IP 不是一个)。于是同一条直链,签发出口 ≠ 下载出口 → 必然 403。

想明白根因,解法就朴素到有点"反高潮":失败就重新签发一条新直链再下,直到下载连接的出口恰好和签发出口对上 。因为单条 TCP 连接 = 单一出口 ,只要某次 fetch 一开始返回 200/206,这条连接的出口就锁定了,后面整段都能从同一个出口下完。实测往往重签 1~2 次就能对齐成功。

javascript 复制代码
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";

// 下载一集:每次失败就重新签发新直链(对抗 IP 绑定 + 代理出口轮询)
async function downloadEp(ep, outFile, maxTry = 12) {
  for (let i = 1; i <= maxTry; i++) {
    const url = await resolveEpUrl(ep.sid);     // 每次都重新签发一条新直链
    if (!url) { await sleep(2500); continue; }
    const res = await fetch(url, { headers: { "User-Agent": UA, Referer: "https://m.某站.com/" } });
    if (res.status >= 400) {                     // 403 = 下载出口与签发出口没对齐
      console.log(`    #${i} CDN ${res.status}(IP未对齐),重签重试`);
      await sleep(2500); continue;
    }
    // 200/206:这条连接的出口已锁定,从头下到尾
    const total = Number(res.headers.get("content-length") || 0);
    const fd = fs.createWriteStream(outFile);
    let got = 0;
    for await (const chunk of res.body) {
      fd.write(chunk); got += chunk.length;
      if (total) process.stdout.write(`\r    下载 ${(got/1048576).toFixed(1)}/${(total/1048576).toFixed(1)}MB`);
    }
    fd.end(); await new Promise(r => fd.on("close", r));
    return got;
  }
  return 0;
}

补一句:如果你是在用户真实网络/真实浏览器里(签发 API 和下载 CDN 天然同一个出口 IP),这道 403 根本不会出现。它纯粹是"按域名分流代理"带来的副作用------但把它当成一道反爬来啃,反而帮我们彻底搞清了这套防盗链的鉴权点在哪。

串起来:一个能跑的下载器

把上面四关拼到一起,主流程其实很短:drama/page 取剧名 + 剧集列表 → 逐集 episodeSid 定位 → 解直链 → 重签重试下载

javascript 复制代码
(async () => {
  const d = await pageCall();                          // 不带集参数:拿剧信息 + episodeList
  const title = d.dramaInfo?.title || dramaId;
  const eps = d.episodeList || [];
  console.log(`《${title}》feeMode=${d.dramaInfo?.feeMode} playRestricted=${d.authorityInfo?.playRestricted} 共 ${eps.length} 集`);
  if (!eps.length) { console.log("无可下载剧集(可能受限/需登录)"); return; } // 呼应坑 B

  const outDir = path.resolve("downloads", `${dramaId}-${title}`.replace(/[\/\\:*?"<>|]/g, "_"));
  fs.mkdirSync(outDir, { recursive: true });
  for (let n = epStart; n <= Math.min(epEnd, eps.length); n++) {
    const ep = eps[n - 1];
    const outFile = path.join(outDir, `E${String(ep.episodeNo ?? n).padStart(2, "0")}.mp4`);
    const bytes = await downloadEp(ep, outFile);       // 内部自动重签重试
    console.log(bytes ? `✅ ${(bytes/1048576).toFixed(1)}MB` : "❌ 失败");
    await sleep(3000);
  }
})();

pageCall 里再叠一层 429 退避重试,整条链路就稳了:

javascript 复制代码
async function pageCall(extra = {}) {
  let r, tries = 0;
  while (true) {
    const umid = fakeId();                              // 每次换设备指纹,压低 429
    r = await api("/m-station/drama/page",
      { dramaId, isAgeLimit: "0", hsdrOpen: "0", ...extra }, // isAgeLimit 必带!
      { umid, aliId: umid });
    if (r.status === 429 && tries < 6) { tries++; await sleep(tries * 4000); continue; }
    break;
  }
  return r.parsed?.data || {};
}

一条容易被忽略、但很重要的工程纪律:请求先落盘

逆向过程里抓到的每个请求/响应都是易失资源 ------脚本一退、终端一关就没了,下次又得重新抓、重新触发风控。所以我给每个脚本都加了一条铁律:拿到响应第一件事是落盘(JSONL 追加),然后才分析,而不是只在内存/终端里看一眼就往下走。

javascript 复制代码
const REQLOG = path.resolve("data/requests/download.jsonl");
fs.mkdirSync(path.dirname(REQLOG), { recursive: true });
const logReq = (rec) => { rec.ts = new Date().toISOString(); fs.appendFileSync(REQLOG, JSON.stringify(rec) + "\n"); };

别小看这几行:逆向时反复触发同一个接口很容易把指纹/IP 打进风控,把每次请求的 path/params/status/code/msg 落盘 + 去重,既能事后复盘"到底哪次参数组合生效了",也能避免无谓地重复打接口。

实测

  • 输入一个免费剧的 dramaId,脚本自动列出剧名、feeModeplayRestricted 和总集数。
  • 选定某集后,自动 episodeSid 定位 → 解出直链 → 遇 403 自动重签,实测 1~2 次即对齐成功。
  • 完整下载某免费剧 E1:174MB / 约 41 分钟 / H.264 960×540 + AAC ,ffprobe 校验通过、可正常播放。
  • 全程免浏览器、免登录 (标清),纯 node + 内置 crypto

复盘:这套逆向里最值钱的几条经验

把这次踩的坑抽象成可复用的方法论,大概是这么几条:

关卡 卡点 通用经验
请求签名 签名串顺序/字段必须和请求头一致 签名串里出现的每个值都要在头里一一对应,参数先规范化排序再签名和发送
响应解密 body 是 base64+AES 看不懂的 base64 先试定长对称解密(ECB/CBC),key 多半是前端硬编码常量
播放地址 密文在 watchInfo 不在 sortedItems 字段名最像的常是幌子 ;把所有疑似密文字段都拖去试解,谁解出 http 谁是真的
二次密钥 key 藏在响应的 newSign key 不一定是常量,可能从同一响应的另一个字段动态截取
切集参数 只有 episodeSid 生效 多候选参数别猜,用控制变量法探针,看输出 ID 是否变化
空数据 playRestricted=3 合法返回空 先区分"受限/未登录"还是"真 bug",省掉大量假性调试
CDN 403 token 按 IP 绑定 调度器不鉴权、边缘才鉴权;失败重签重试到出口对齐,单连接=单出口

适用与不适用

  • 适合:学习一套"签名 + 响应加密 + 二次加密 + 防盗链"组合拳的完整拆解思路;想看"控制变量法找参数""字段陷阱怎么破""CDN IP 绑定怎么绕"的真实案例。
  • 不适合 :指望它当通用下载器------它只覆盖免登录标清 ;HD/4K 需要登录态/会员 token,不在本文范围。直链还有 4 小时有效期,过期得重新签发。
  • 前提:整套逻辑依赖前端硬编码的密钥与接口结构,站点一旦改版/换 key,签名与解密都要重新逆。

写在最后

这条链路真正的难点,不在任何一个加密算法本身------HmacSHA256、AES-ECB、AES-CBC 都是教科书级别的。难的是把"算法、字段、密钥来源、防盗链鉴权点"这些散落的线索一条条对上 ,以及在每个"看起来像 bug"的地方,先停下来判断它到底是 bug 还是设计(watchInfo vs sortedItemsplayRestricted=3 的空数据、调度器不鉴权而边缘鉴权)。把这些判断力沉淀下来,比记住某一个 key 有用得多。

⚠️ 免责声明 :本文及代码仅供安全研究、技术学习与个人合法备份使用,所有站点名称、域名、链接均已脱敏。请遵守相关法律法规与目标站点的服务条款,切勿用于任何侵犯版权或商业用途;由使用本文内容产生的一切后果由使用者自行承担。