第一次碰到加密 HLS 的时候,我完全没意识到出了什么事。m3u8 下下来了,几百个 .ts 分片也都下下来了,合并完之后用播放器打开——画面花成马赛克,声音像坏掉的收音机。我第一反应是分片没下载全,回去一个个校验 MD5,全对。折腾到快凌晨才注意到 m3u8 里那行我一直当注释跳过的东西:
```
EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key/abc123",IV=0x9c7db8778570d05c3f8f2a1b4e6d9f30
```
这行不起眼的标签,就是"分片全是密文"的原因。后来我发现,很多人卡在这一步不是因为技术多难,而是因为没人告诉他们:HLS 的 AES-128 加密根本不是 DRM,它只是给每个分片上了把锁,钥匙就挂在旁边。这篇把我后来摸清的整条链路写下来——从看懂那行标签,到手动解密,再到用 ffmpeg 和 yt-dlp 一把梭,最后是那些让我栽过跟头的细节。
TL;DR:HLS 的 AES-128 是分片级对称加密,密钥以 HTTP(S) URL 形式写在 m3u8 的
#EXT-X-KEY里。完整流程 = 拿 m3u8 → 提取 key URI 下载 16 字节密钥 → 逐片 AES-128-CBC 解密 → 按序合并。ffmpeg 能全自动完成(前提是它能访问到 key URI,必要时加-referer/-headers),yt-dlp 也内建支持。真正的难点不在解密本身,而在:密钥 URL 带鉴权、IV 的处理方式、密钥轮换,以及分辨 AES-128 和 SAMPLE-AES(后者才是真 DRM,别混为一谈)。
目录
- 一、先搞清楚:HLS 加密到底是什么级别的东西
- 二、读懂 #EXT-X-KEY 这一行
- 三、手动走一遍:下载、取密钥、解密、合并
- 四、ffmpeg 一把梭:自动处理与自定义请求头
- 五、yt-dlp 的姿势
- 六、密钥 URL 带鉴权时怎么办
- 七、七个坑
- 八、怎么快速判断你面对的是不是真 DRM
- 九、合规与温馨提示
一、先搞清楚:HLS 加密到底是什么级别的东西
我得先纠正一个流传很广的误解:HLS 的 AES-128 不是 DRM。
这个区别决定了你要走哪条路:
| HLS AES-128 | 真 DRM(FairPlay / Widevine / PlayReady) | |
|---|---|---|
| 加密方式 | 分片对称加密(AES-128-CBC) | 内容密钥受许可证与硬件保护 |
| 密钥在哪 | m3u8 里的一个 URL,HTTP 就能拿到 | 需向许可证服务器申请,返回的是加密过的密钥 |
| 解密难度 | 拿到密钥后 openssl 一行 |
需要 CDM、设备级密钥,基本不可行 |
| ffmpeg 能直接处理 | 能 | 不能 |
| 典型标签 | METHOD=AES-128 |
METHOD=SAMPLE-AES、KEYFORMAT=com.apple.streamingkeydelivery |
| 密钥长度 | 正好 16 字节 | 不适用 |
所以看到 METHOD=AES-128,恭喜,这是"能下"的那一类。看到 SAMPLE-AES、com.apple.streamingkeydelivery 或 com.widevine.alpha,那就是另一回事了,本文的方法不适用,直接看第八节。
还有个很实用的土办法判断:打开 Network 面板,看有没有一个请求返回体正好 16 字节的二进制。有的话基本就是 AES-128;如果看到的是 skd:// 开头的 URI 或者请求发往 license 服务器,那就是 DRM。
二、读懂 #EXT-X-KEY 这一行
一个典型加密 m3u8 长这样:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/keys/9f2a.key",IV=0x9c7db8778570d05c3f8f2a1b4e6d9f30
#EXTINF:10.000,
segment0.ts
#EXTINF:10.000,
segment1.ts
字段含义:
| 字段 | 含义 | 注意点 |
|---|---|---|
METHOD |
加密方法 | NONE(不加密)/ AES-128 / SAMPLE-AES(DRM) |
URI |
密钥地址 | 下载下来必须正好 16 字节 |
IV |
初始化向量 | 十六进制,带 0x 前缀,可省略 |
KEYFORMAT |
密钥格式 | 默认 identity;其他值基本都是 DRM |
KEYFORMATVERSIONS |
版本 | 一般不用管 |
三个容易忽略的点,每一个我都踩过:
-
#EXT-X-KEY可以出现多次。流中途换密钥(密钥轮换)时,新的 key 标签会出现在对应分片前面,只作用于它后面的分片。只处理第一个 key 的话,后半段会解出乱码——表现为"前 5 分钟正常,后面全花",排查起来特别费时间。 -
IV 可以省略。省略时按 HLS 规范用分片的媒体序列号(
#EXT-X-MEDIA-SEQUENCE起始值 + 分片序号)作为隐式 IV,转成 16 字节大端整数。手动解密时若 m3u8 没有 IV,你必须自己算,不能传空也不能传全零(全零只在序列号为 0 时对)。 -
URI 可以是相对路径,要相对于 m3u8 的 URL 解析。只拼域名部分会 404,而且 404 响应体往往是个 HTML 错误页——拿它当密钥解密会得到一堆花屏,很难联想到是 key 下错了。
三、手动走一遍:下载、取密钥、解密、合并
理解原理最好的办法是手动来一次。假设:
M3U8_URL="https://cdn.example.com/vod/12345/index.m3u8"
3.1 拿 m3u8
curl -s "$M3U8_URL" -H "Referer: https://www.example.com/" -o index.m3u8
head -20 index.m3u8
3.2 提取 key URI 和 IV
shell 正则对转义不健壮,我更倾向用 python 解析:
import re
from urllib.parse import urljoin
M3U8 = "https://cdn.example.com/vod/12345/index.m3u8"
text = open("index.m3u8", encoding="utf-8").read()
m = re.search(r'#EXT-X-KEY:([^\n]+)', text)
attrs = dict(
kv.split('=', 1) for kv in
[x.strip() for x in m.group(1).split(',')] if '=' in kv
)
key_uri = attrs.get('URI', '').strip('"')
iv = attrs.get('IV', '')
key_url = urljoin(M3U8, key_uri) # 关键:处理相对路径
print("KEY:", key_url)
print("IV :", iv)
3.3 下载密钥并确认长度
curl -s "$KEY_URL" -o video.key
stat -c%s video.key # Linux:必须是 16
xxd -p video.key # 看十六进制,后面要用
这一步一定要确认是 16 字节。如果不是(返回了 HTML 错误页、JSON、或者 32 字节),说明 key URL 需要鉴权或者你下错了东西。直接拿去解密必然全花,而且报错信息不会告诉你原因。
3.4 下载分片
mkdir -p segs
i=0
grep -v '^#' index.m3u8 | grep -v '^$' | while read -r url; do
full=$(python3 -c "from urllib.parse import urljoin;import sys;print(urljoin('$M3U8_URL', sys.argv[1]))" "$url")
printf -v name '%05d.ts' "$i"
curl -s "$full" -o "segs/$name"
i=$((i+1))
done
3.5 逐片解密
mkdir -p dec
KEY_HEX=$(xxd -p video.key | tr -d '\n')
for f in segs/*.ts; do
base=$(basename "$f")
idx=$((10#${base%.ts})) # 去掉前导零
if [ -n "$IV" ]; then
USE_IV="${IV#0x}" # 去掉 0x 前缀
else
USE_IV=$(printf '%032x' "$idx") # 隐式 IV:序号转 16 字节大端
fi
openssl aes-128-cbc -d -in "$f" -out "dec/$base" -K "$KEY_HEX" -iv "$USE_IV"
done
注意 openssl 的 -K 和 -iv 都要十六进制字符串,不是原始字节。xxd -p 输出的正好是 hex,直接喂进去即可。
3.6 合并
ls dec/*.ts | sort | sed 's/^/file /' > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4
手动走完这一遍,你会对整个机制有非常具体的认识。之后再遇到问题,就知道该查哪一环——是 key 不对、IV 不对,还是分片顺序乱了。
四、ffmpeg 一把梭:自动处理与自定义请求头
绝大多数时候不需要手动来。ffmpeg 内建支持 HLS AES-128:
ffmpeg -i "https://cdn.example.com/vod/12345/index.m3u8" -c copy output.mp4
它会自动读 #EXT-X-KEY、下载密钥、解密、合并。前提是ffmpeg 能成功访问 key URL。如果 key URL 需要 Referer 或 Cookie,你会看到:
[https @ 0x55f9c0] HTTP error 403 Forbidden
Error opening input file ...
加请求头:
ffmpeg -referer "https://www.example.com/" \
-i "https://cdn.example.com/vod/12345/index.m3u8" \
-c copy output.mp4
需要自定义多个头时用 -headers,多个头之间用 CRLF(\r\n)分隔。bash 里这么写最稳:
HEADERS=$(printf 'Referer: %s\r\nCookie: %s\r\nUser-Agent: %s\r\n' \
"https://www.example.com/" "session=xxx" "Mozilla/5.0")
ffmpeg -headers "$HEADERS" -i "$M3U8_URL" -c copy output.mp4
两个很常用的附加参数:
# 分片用了非标准扩展名(.m4s / 无扩展名 / .jpg)时 ffmpeg 会拒绝加载
-allowed_extensions ALL
# 调试用:把每次 key 请求、分片请求都打出来
-v verbose
我排查问题时的标准起手式就是加 -v verbose,看它有没有去请求 key、请求返回了多少字节。如果日志里压根没出现 key 请求,说明 m3u8 解析有问题;如果出现了但返回 403,就是鉴权问题;如果返回了但大小不是 16 字节,就是 key URL 不对。三种情况三种修法。
五、yt-dlp 的姿势
yt-dlp 内建 HLS 支持,包括 AES-128 解密,通常一条命令就够:
yt-dlp "https://www.example.com/watch/12345" -o "%(title)s.%(ext)s"
需要额外请求头时:
yt-dlp --referer "https://www.example.com/" \
--add-header "Cookie:session=xxx" \
"https://cdn.example.com/vod/12345/index.m3u8"
几个我常用的组合:
# 先看有哪些格式,不下(排错第一步)
yt-dlp -F "URL"
# 只看 HLS 相关的原生 URL,配合 ffmpeg 手动处理
yt-dlp -F "URL" | grep -i m3u8
# 强制走 ffmpeg 而不是原生下载器(某些流更稳)
yt-dlp --downloader ffmpeg "URL"
# 保留中间分片用于排查
yt-dlp --keep-fragments "URL"
--keep-fragments 这个选项在我排查"合并后花屏"时帮了大忙:保留原始分片后,我可以单独对每个分片做解密测试,快速定位是某一片坏了还是全流程都错了。
六、密钥 URL 带鉴权时怎么办
这是实际场景里最常见也最烦的情况:分片能下,密钥下不来(403/401),于是合出来全是花屏。
6.1 先确认到底是哪种失败
别猜,用 curl 直接问一次:
curl -s -o key.bin -w 'HTTP=%{http_code} SIZE=%{size_download}\n' \
"$KEY_URL" -H "Referer: https://www.example.com/"
判读:
| 输出 | 说明 | 下一步 |
|---|---|---|
HTTP=200 SIZE=16 |
正常 | 问题不在 key,去查 IV 或分片顺序 |
HTTP=403 |
缺 Referer / Cookie / UA | 补齐请求头(方案 A/B) |
HTTP=200 SIZE=1234 |
返回了网页或 JSON | URL 不对,或需要签名参数 |
HTTP=401 |
需要登录态 | 用你自己的登录 Cookie(前提是你有权访问) |
6.2 方案 A:给 ffmpeg 补请求头
前面第四节写过,最简单。适用于 key 只缺 Referer/Cookie 的情况。
6.3 方案 B:yt-dlp 加头
yt-dlp --referer "https://www.example.com/" \
--add-header "Cookie:session=xxx;token=yyy" \
"URL"
6.4 方案 C:把 key 下到本地,改写 m3u8(我最常用的招)
当 key URL 带一次性签名、或者 ffmpeg 怎么都带不上正确的头时,这招最省事:
# 1) 用能成功的方式(浏览器复制的 curl / 带完整 Cookie)把 key 存到本地
curl -s "$KEY_URL" -H "Cookie: session=xxx" -o video.key
stat -c%s video.key # 确认 16
# 2) 把 m3u8 里的 key URI 改成本地文件
sed -E 's|URI="[^"]*"|URI="video.key"|g' index.m3u8 > local.m3u8
# 3) 让 ffmpeg 读本地 m3u8(注意 protocol_whitelist 要放开 file)
ffmpeg -allowed_extensions ALL \
-protocol_whitelist file,http,https,tcp,tls,crypto \
-i local.m3u8 -c copy output.mp4
-protocol_whitelist 这串必须带上 file(否则本地文件不让读)和 crypto(否则解密协议被拦)。我第一次用这招时就是漏了 crypto,报错 Protocol 'crypto' not on whitelist,查了半天。
如果连分片也要走本地,把分片也下下来、m3u8 里的分片名改成相对路径即可,同样走这套。
6.5 方案 D:分片全下 + 手动解密
ffmpeg 和 yt-dlp 都搞不定的时候,回到第三节的手动流程。yt-dlp 用 --keep-fragments 保留分片,或者你自己 curl 批量下,然后按 3.5 逐片解密、3.6 合并。慢,但是可控,出问题能定位到具体哪一片。
七、七个坑
按我遇到的频率排序。
1. 密钥不是 16 字节
症状:合并后全花屏,且 ffmpeg 不报错。原因几乎都是 key 请求返回了错误页(HTML/JSON/重定向)。对策:curl -w '%{size_download}' 确认长度,永远是排查 AES-128 的第一步。
2. 密钥轮换只处理了第一个
症状:前几分钟正常,后面花屏。原因:m3u8 里有多个 #EXT-X-KEY,只用了第一个。对策:解析时按出现位置分段处理,每个 key 作用于它之后的片段。ffmpeg 会自动处理,手写的脚本容易漏。
3. 隐式 IV 算错
症状:第一片能解,后面全花。原因:m3u8 没写 IV,你按分片序号从 0 开始算,但该流的 #EXT-X-MEDIA-SEQUENCE 不是 0。正确算法是 media_sequence + 分片索引,不是单纯的分片索引:
iv_int = media_sequence + seg_index
iv_bytes = iv_int.to_bytes(16, 'big') # 大端 16 字节
iv_hex = iv_bytes.hex()
这个坑在小项目里很常见,因为大部分测试流的 MEDIA-SEQUENCE 恰好是 0,代码跑通了就以为对了,上到直播流(序列号很大)就崩。
4. key URI 是相对路径没解析
症状:key 请求 404,但你复制 URL 到浏览器又能打开(因为你复制的是拼接后的)。对策:一律用 urljoin(m3u8_url, key_uri)。
5. 把 SAMPLE-AES 当 AES-128 处理
症状:key 拿到了(甚至能拿到 16 字节),但解密出来还是花屏。原因:SAMPLE-AES 是逐样本加密,不是整片 AES-128-CBC,openssl 那套不适用。对策:看 KEYFORMAT,非 identity 就是 DRM,趁早收手。
6. 分片合并顺序错
症状:画面能看但剧情跳跃、音画错位。原因:按文件名字典序排序(1.ts, 10.ts, 2.ts)而不是数值序,或者没按 m3u8 里的顺序。对策:下载时用 %05d 补零命名,合并时按 m3u8 原始顺序生成列表。
7. openssl 报 bad decrypt
症状:个别分片(常常是最后一片)解密失败。原因:padding 校验。可以先试试加 -nopad:
openssl aes-128-cbc -d -nopad -in seg.ts -out seg_dec.ts -K "$KEY" -iv "$IV"
不过更常见的原因是 key 或 IV 错了——先确认前六条再怀疑 padding。
八、怎么快速判断你面对的是不是真 DRM
给你一个我自己的检查顺序,基本 30 秒内能定:
- 看 m3u8 里的
METHOD。AES-128→ 可下;SAMPLE-AES→ DRM。 - 看
KEYFORMAT。没有或identity→ 正常;com.apple.streamingkeydelivery/com.widevine.alpha/com.microsoft.playready→ DRM。 - 看 URI 协议。
https://...→ 正常;skd://...→ 一定是 DRM。 - 看浏览器行为。DevTools Console 里搜
requestMediaKeySystemAccess,有调用就是走了 EME(DRM 的标准接口)。 - 看播放器兼容。某浏览器能播另一个不能,通常是 DRM 方案不同。
如果确认是真 DRM,我的建议是放弃下载这条路,不是技术洁癖,而是:硬件级密钥拿不到、即使拿到也涉及规避技术保护措施,法律风险远大于收益。这类内容要么走官方离线下载(很多平台自带),要么就不下。
九、合规与温馨提示
最后说几句必须说的话。
HLS 的 AES-128 加密本质上是传输层的内容保护,它不是版权标记,也不改变内容的权利归属。你能解密,不代表你有权传播。
具体建议:
- 本文的方法适用于你自己拥有版权或已获授权的内容:自己录的会议、公司内训、自己拍的素材、已购买且允许离线保存的课程。
- 如果密钥需要登录态才能获取,说明平台把它限定给了登录用户。用你自己账号下载你自己有权观看的内容,通常在服务条款允许的个人使用范围内;但把下载下来的内容二次分发、公开传播,基本都会越界。
- 不要为了拿 key 去破解签名算法、撞别人的会话、或者绕过付费墙。这类操作和"解密自己的分片"性质完全不同。
- 企业环境里跑批量下载前,确认带宽和存储的使用规定——我就见过有人把转码机跑满带宽,结果整个办公室断网被叫去谈话的。
技术本身是中性的,边界在使用方式。希望这篇帮你省下我当年花掉的那个凌晨。