做视频站逆向,真正难的从来不是"看懂一段加密函数",而是把散落在十几个混淆 chunk、几十个请求字段、还有一层 CDN 防盗链里的线索,一条条对上,最后串成一条能稳定跑通的链路。任何一环猜错,你都会卡在一个"看起来像 bug、其实是设计"的地方动弹不得。
这篇文章记录的就是这么一条链路:不开浏览器、纯协议,拿到某追剧站(下文统一称「某站」)免费剧集的真实直链并下载下来。目标很克制——只要免登录能看的标清(SD),不碰需要会员的 HD/4K。但麻雀虽小,五脏俱全:它同时踩中了请求签名、响应加密、二次加密、字段陷阱、防盗链 IP 绑定五类典型反爬。
⚠️ 全文所有网站名称、域名、CDN 厂商、链接一律用「某站 / 某域名 / 某 CDN」脱敏,只保留可复用的逆向方法与协议结构。代码是真实可跑的实现,仅供学习与个人备份研究。
战果先放这
先把结论摆出来,免得你读完发现是"理论上可行":
- 端到端跑通:输入一个剧集 ID,自动定位到某一集 → 解出真实直链 → 下载成可播放的 mp4。
- 实测某免费剧 E1 完整下载:174MB / 时长约 41 分钟 / H.264 960×540 + AAC,
ffprobe校验通过、可正常播放。 - 整条链路不依赖浏览器、不依赖登录态(标清),纯
node脚本 + 内置crypto。
下面按"关卡"拆,每一关都给出:怎么发现的、怎么验证的、最后的代码长什么样。
全链路一图流
把整条链路画出来,后面每一节都是在啃其中一格:
┌─────────────────────────────────────────────────────────┐ │ 第①关 请求签名:每个请求头带 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):
{METHOD}\naliId:{aliId}\nct:{ct}\ncv:{cv}\nt:{t}\n{规范化路径}这里有两个容易翻车的细节,也是逆向里最值得记下来的经验:
- "规范化路径"必须和真实请求 URL 完全一致。它把 query 参数按 key 排序后拼回 path,签名用它、发请求也用它——一旦你发请求时参数顺序和签名时不一样,服务端用它自己排序后的串去验签就对不上。
- 签名串里的
aliId/ct/cv/t要和请求头里的同名字段一一对应,少一个、错一个都验不过。
签名所需的密钥是写死在前端的常量。复原成 Node 实现就是这样(域名已脱敏,密钥即源码原样):
importcryptofrom"node:crypto";constSIGN_KEY="ES513W0B1CsdUrR13Qk5EgDAKPeeKZY";// HmacSHA256 的密钥(前端硬编码)constDEC_KEY="3b744389882a4067";// 响应解密 AES-ECB 的 key(下一关用)constBASE="https://api.某站.com";// API 域名(已脱敏)// 规范化路径:参数按 key 排序后拼回 path —— 签名串与真实请求都用它functioncanon(url,params){constsp=newURLSearchParams(params);sp.sort();constr=sp.toString();leto=url.indexOf("?")>-1?`${url}&${r}`:(r?`${url}?${r}`:url);if(o.endsWith("&"))o=o.replace(/&$/,"");returno;}consthmac=(m,k)=>crypto.createHmac("sha256",k).update(m,"utf8").digest("base64");封装成一个通用api(),把签名头和那一堆"指纹头"都补齐。这里有个反 429 的小技巧:umid/aliId这类设备指纹每次换成随机值,可以明显降低被限流的概率。
constuuid=()=>crypto.randomUUID().toUpperCase();constDEVICE=uuid();exportasyncfunctionapi(path,params={},{method="GET",ct="web_pc",cv="1.0.0",token="",umid="",aliId="",deviceId=DEVICE,extraHeaders={},}={}){constt=Date.now();constcpath=canon(path,params);// 签名串:方法\naliId:..\nct:..\ncv:..\nt:..\n规范化路径constsignStr=`${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${t}\n${cpath}`;constheaders={"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,};constres=awaitfetch(BASE+cpath,{method,headers});constbody=awaitres.text();letparsed=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)。
// 响应体 = base64( AES-128-ECB(明文 JSON) ),无 IV,PKCS7functiondec(b64){constd=crypto.createDecipheriv("aes-128-ecb",Buffer.from(DEC_KEY,"utf8"),null);returnBuffer.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 哪个能播、要不要登录)。真正的播放密文在另一个字段:
data.watchInfo.m3u8.url ← 真身在这里这是逆向里非常典型的陷阱:字段名最像的那个,往往是幌子。靠"猜字段名"会反复扑空,正确做法是把每个看起来像 URL/密文的字段都拖去试解密,谁能解出http...谁才是真的。
解这层密文用的是AES-128-CBC,但它的 key 设计得很"贼":不是写死的常量,而是从同一份响应的newSign字段里截取的——取newSign的第 4 到第 20 个字符(共 16 字节)当 key,IV 则是另一个固定常量。
constPLAY_IV="b1da7878016e4e2b";// 固定 IV(16 字节 utf-8)// 播放地址解密:AES-128-CBC,key 藏在响应的 newSign 字段里(第 4~20 字符)exportfunctiondecPlayUrl(cipherB64,newSign,iv=PLAY_IV){constkey=newSign.substring(4,20);constd=crypto.createDecipheriv("aes-128-cbc",Buffer.from(key,"utf8"),Buffer.from(iv,"utf8"));returnBuffer.concat([d.update(Buffer.from(cipherB64,"base64")),d.final()]).toString("utf8");}解出来是一个带签名参数的真实直链(标清是*-ld.mp4),URL 里挂着key/time/rk/uid/seasonId等参数,其中time约等于"签发时刻 + 4 小时",也就是这条直链的有效期只有 4 小时。
把第①②③关串起来,"拿到某一集真实地址"就是这么一个小函数:
asyncfunctionresolveEpUrl(episodeSid){constd=awaitpageCall(episodeSid?{episodeSid:String(episodeSid)}:{});constcipher=d.watchInfo?.m3u8?.url;// 真身在 watchInfo,不在 sortedItemsif(!cipher)returnnull;returndecPlayUrl(cipher,d.newSign);// key 就在同一份响应里}第④关之前的两个小坑:按集定位 & 受限判定
坑 A:drama/page必须带isAgeLimit,而"指定第几集"只认一个参数
drama/page不带isAgeLimit会拿不到正常数据;默认只返回第 1 集的watchInfo。想要第 2、3、N 集,就得告诉接口"我要哪一集"。
问题是:episodeList[i]里同时有id / sid / episodeNo / episodeId / number一堆候选字段,到底传哪个、用哪个参数名能切集?与其瞎猜,不如用控制变量法写个小探针:固定其他条件,逐个参数名去试,然后看解出的直链里那个文件 ID 有没有变——变了就说明这个参数真的切到了别的集。
// 探针:逐个参数名试"指定第 2 集",看解出的 fileId 是否变化constepSid="377403";// episodeList[1] 的 sidfor(constkeyof["episodeSid","sid","episodeId","episodeNo","number"]){constv=(key==="episodeNo"||key==="number")?"2":epSid;constr=awaitpage({[key]:v});constdiff=r.fileId&&r.fileId!==base.fileId?"★变了(命中指定集!)":(r.fileId?"同默认集":"无cipher");console.log(`page +${key}=${v}: fileId=${r.fileId}${diff}`);awaitsleep(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 次就能对齐成功。
constUA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";// 下载一集:每次失败就重新签发新直链(对抗 IP 绑定 + 代理出口轮询)asyncfunctiondownloadEp(ep,outFile,maxTry=12){for(leti=1;i<=maxTry;i++){consturl=awaitresolveEpUrl(ep.sid);// 每次都重新签发一条新直链if(!url){awaitsleep(2500);continue;}constres=awaitfetch(url,{headers:{"User-Agent":UA,Referer:"https://m.某站.com/"}});if(res.status>=400){// 403 = 下载出口与签发出口没对齐console.log(`#${i}CDN${res.status}(IP未对齐),重签重试`);awaitsleep(2500);continue;}// 200/206:这条连接的出口已锁定,从头下到尾consttotal=Number(res.headers.get("content-length")||0);constfd=fs.createWriteStream(outFile);letgot=0;forawait(constchunkofres.body){fd.write(chunk);got+=chunk.length;if(total)process.stdout.write(`\r 下载${(got/1048576).toFixed(1)}/${(total/1048576).toFixed(1)}MB`);}fd.end();awaitnewPromise(r=>fd.on("close",r));returngot;}return0;}补一句:如果你是在用户真实网络/真实浏览器里(签发 API 和下载 CDN 天然同一个出口 IP),这道 403 根本不会出现。它纯粹是"按域名分流代理"带来的副作用——但把它当成一道反爬来啃,反而帮我们彻底搞清了这套防盗链的鉴权点在哪。
串起来:一个能跑的下载器
把上面四关拼到一起,主流程其实很短:drama/page取剧名 + 剧集列表 → 逐集episodeSid定位 → 解直链 → 重签重试下载。
(async()=>{constd=awaitpageCall();// 不带集参数:拿剧信息 + episodeListconsttitle=d.dramaInfo?.title||dramaId;consteps=d.episodeList||[];console.log(`《${title}》feeMode=${d.dramaInfo?.feeMode}playRestricted=${d.authorityInfo?.playRestricted}共${eps.length}集`);if(!eps.length){console.log("无可下载剧集(可能受限/需登录)");return;}// 呼应坑 BconstoutDir=path.resolve("downloads",`${dramaId}-${title}`.replace(/[\/\\:*?"<>|]/g,"_"));fs.mkdirSync(outDir,{recursive:true});for(letn=epStart;n<=Math.min(epEnd,eps.length);n++){constep=eps[n-1];constoutFile=path.join(outDir,`E${String(ep.episodeNo??n).padStart(2,"0")}.mp4`);constbytes=awaitdownloadEp(ep,outFile);// 内部自动重签重试console.log(bytes?`✅${(bytes/1048576).toFixed(1)}MB`:"❌ 失败");awaitsleep(3000);}})();pageCall里再叠一层429 退避重试,整条链路就稳了:
asyncfunctionpageCall(extra={}){letr,tries=0;while(true){constumid=fakeId();// 每次换设备指纹,压低 429r=awaitapi("/m-station/drama/page",{dramaId,isAgeLimit:"0",hsdrOpen:"0",...extra},// isAgeLimit 必带!{umid,aliId:umid});if(r.status===429&&tries<6){tries++;awaitsleep(tries*4000);continue;}break;}returnr.parsed?.data||{};}一条容易被忽略、但很重要的工程纪律:请求先落盘
逆向过程里抓到的每个请求/响应都是易失资源——脚本一退、终端一关就没了,下次又得重新抓、重新触发风控。所以我给每个脚本都加了一条铁律:拿到响应第一件事是落盘(JSONL 追加),然后才分析,而不是只在内存/终端里看一眼就往下走。
constREQLOG=path.resolve("data/requests/download.jsonl");fs.mkdirSync(path.dirname(REQLOG),{recursive:true});constlogReq=(rec)=>{rec.ts=newDate().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 还是设计(watchInfovssortedItems、playRestricted=3的空数据、调度器不鉴权而边缘鉴权)。把这些判断力沉淀下来,比记住某一个 key 有用得多。
⚠️免责声明:本文及代码仅供安全研究、技术学习与个人合法备份使用,所有站点名称、域名、链接均已脱敏。请遵守相关法律法规与目标站点的服务条款,切勿用于任何侵犯版权或商业用途;由使用本文内容产生的一切后果由使用者自行承担。