做视频站逆向,真正难的从来不是"看懂一段加密函数",而是把散落在十几个混淆 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-sign、t、clientType、clientVersion、deviceId、aliId、umid、uet、token。其中 t 是毫秒时间戳,x-ca-sign 显然是签名------改一个字节服务端就 4xx。
在混淆后的前端 chunk 里顺着 x-ca-sign 往回找,能定位到签名是 HmacSHA256 + base64 ,而被签名的不是 body,而是一段按固定顺序拼出来的字符串 。复原后的签名串长这样(注意是真正的换行 \n):
text
{METHOD}\naliId:{aliId}\nct:{ct}\ncv:{cv}\nt:{t}\n{规范化路径}
这里有两个容易翻车的细节,也是逆向里最值得记下来的经验:
- "规范化路径"必须和真实请求 URL 完全一致。它把 query 参数按 key 排序后拼回 path,签名用它、发请求也用它------一旦你发请求时参数顺序和签名时不一样,服务端用它自己排序后的串去验签就对不上。
- 签名串里的
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/需登录)。此时接口对未登录用户合法地返回空episodeList、watchInfo全 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,脚本自动列出剧名、feeMode、playRestricted和总集数。 - 选定某集后,自动
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 sortedItems、playRestricted=3 的空数据、调度器不鉴权而边缘鉴权)。把这些判断力沉淀下来,比记住某一个 key 有用得多。
⚠️ 免责声明 :本文及代码仅供安全研究、技术学习与个人合法备份使用,所有站点名称、域名、链接均已脱敏。请遵守相关法律法规与目标站点的服务条款,切勿用于任何侵犯版权或商业用途;由使用本文内容产生的一切后果由使用者自行承担。