拿到一个几百 MB 的 pcap 文件,题目只留一句话:里面有视频,把它提出来,提出来还要能放。我第一次做这类题也被 RTMP、RTSP、MPEG-DASH 三种协议混在一起搞到头疼——不是流量看不懂,而是不知道从哪下手。这道题的难点不在于“抓到包”,而在于“从包里还原出一个能播放的媒体文件”,本质上是流量取证里的视频提取。
这篇文章我会直接把整套思路讲透:先教你从协议特征快速判断该走哪条提取路线,再分别拆解 RTMP、RTSP/RTP、MPEG-DASH 三种场景的完整做法,最后把我踩过的坑和排查套路列成清单。适合正在刷流量分析题、做网络取证,或者单纯想搞懂流媒体协议底层逻辑的人,跟着操作就能复现。
1. 题眼拆解:三种视频流协议的流量指纹
1.1 RTMP:基于 TCP 的“长流水”
RTMP 是 Adobe 时代的推拉流协议,跑在 TCP 上,默认端口 1935。它最大的特点是“有状态”:客户端要先握手,再发 connect、createStream、play 这些命令,然后服务器才把音视频数据以消息的形式持续推过来。从抓包角度看,你看到的是连续的消息流,媒体就藏在消息体里,而不是像 HTTP 那样每段文件都有清晰的 URL 边界。
Wireshark 里直接过滤rtmp,如果能看到connect、createStream、play这些 AMF 命令,基本可以断定这段流量是 RTMP 推流或拉流。RTMP 消息头里类型 ID 的 8 表示音频、9 表示视频、18 表示 AMF 数据,这对定位媒体载荷非常关键。提取时最实用的思路是:RTMP 的流封装和 FLV 格式非常接近,只要找到 FLV 文件头,后面就是一条一条的 FLV Tag,拼接后基本等于拿回一个 FLV 文件。
1.2 RTSP/RTP:控制流与媒体流分离
RTSP 本身是文本信令协议,默认端口 554,长得有点像 HTTP,但目的是控制媒体会话,指令包括 DESCRIBE、SETUP、PLAY、TEARDOWN。真正承载视频的是 RTP 包,传输层通常走 UDP,也可能封装在 TCP 里。RTP 有独立的 sequence number、timestamp、SSRC 字段,负载部分是 H.264 等编码的裸流数据。
Wireshark 里过滤rtsp会看到会话协商过程;过滤rtp会看到大量媒体包。注意,这里媒体数据是被拆散在多个 RTP 报文里的,甚至一个视频 NAL 单元会被切成多个分片分别发送,所以提取时必须理解 RTP 的分包规则,不能简单地把所有 payload 串起来就完事。这种协议的特征就是“控制归控制,媒体归媒体”,提取视频时要同时考虑信令里的参数和 RTP 序号关系。
1.3 MPEG-DASH:HTTP 文件清单加分段
MPEG-DASH 和前两种完全不是一个思路。DASH 没有独立端口,它就是跑在 HTTP/HTTPS 上的自适应流,核心是一个 MPD(Media Presentation Description)清单文件,里面描述了视频分段的 URL、时长、码率、初始化文件等信息。真正播放时,播放器先拉 MPD,再按清单逐个拉取 init segment 和 media segment,这些分段通常是 fMP4 格式,扩展名一般是 .m4s 或 .mp4。
在流量图里,DASH 的指纹特别直观:一连串GET /xxx.mpd、GET /xxx/init.mp4、GET /xxx/seg1.m4s这样的 HTTP 请求。由于每个分段都是独立的 HTTP 响应,提取时就比别人省事很多——不需要自己去拼 RTP 分片,直接把 HTTP 对象导出并按清单顺序拼接即可。
1.4 先用统计视图定方向:协议地图比大海捞针靠谱
拿到 pcap 后我习惯先做一件事:看协议统计,而不是立刻跟踪流。在 Wireshark 里打开Statistics -> Protocol Hierarchy,可以看到每种协议占了多少数据量。如果 RTMP 占了绝大部分,显然主战场是 RTMP 提取;如果看到 RTP 和 RTSP 都有,就得做好解析 RTP 分片的准备;如果看到大量HTTP且 URI 里带.mpd或.m4s,那就是 DASH 没跑了。这步能用几秒钟给整道题定方向,省掉一堆无谓的翻包。
| 协议 | 传输层 | 默认端口 | 典型抓包特征 | 提取产物 |
|---|---|---|---|---|
| RTMP | TCP | 1935 | 握手 + AMF 命令 + 类型 8/9 消息 | FLV 文件 |
| RTSP/RTP | TCP/UDP | 554 / 动态端口 | SETUP/PLAY 信令 + RTP 序号和时间戳 | H.264 裸流或 MP4 |
| MPEG-DASH | HTTP/HTTPS | 80/443 | MPD 请求 + 连续 m4s 分段请求 | fMP4 合并后的 MP4 |
2. 提取原理与工具选型
2.1 为什么抓到包不等于拿到视频
新手最容易卡住的地方就是:明明看到视频数据了,拖进播放器却打不开。原因是网络传输的媒体数据和播放器认识的媒体文件根本不是一回事。RTMP 在 TCP 流里是一条条消息,有 RTMP 消息头;RTP 在 UDP 里是最大传输单元限制下的分片,每个包只有一段负载;DASH 的 fMP4 分段则是被 URL 切开的多个独立文件。这些都需要重新封装配齐元数据,才能变成播放器认识的 FLV、MP4 或裸流 H.264。
再深入一点,封装时的关键参数是时间戳。RTMP 时间戳单位是毫秒,直接写进 FLV Tag 头;RTP 时间戳是 90kHz 计数器,计算时长要用差值除以 90000;DASH 分段时间信息在 MPD 和 fragment 的 moof 里。提取时如果完全不关心这些参数,导致时间戳错乱,就算文件头拼出来了,画面也会抽风或者干脆无法解码。
2.2 工具链准备:这几样足够
- Wireshark / tshark:负责解析 pcap、过滤协议、导出流和 HTTP 对象。图形界面适合交互式分析,命令行适合批量提取。
- FFmpeg:最后的转封装和验证神器,几乎所有提取产物都要交给它转成 MP4,或者用来修复索引、补时间戳。
- Python3 加 dpkt/scapy:处理 RTP 分片、FLV Tag 重组时不写脚本太难受,依赖库负责解析 RTP 头,脚本核心只做字节拼接。
- hexdump 或十六进制编辑器:定位 FLV 头、检查 H.264 起始码、观察 DASH 分段结构时必备。
这些工具在流量分析题里已经算全家桶了,平时做网络取证也一样用,不需要额外装稀奇古怪的“视频提取专用工具”。
2.3 两种核心思维:清单优先、边界优先
提取不同协议时,脑子里要始终有两件事:边界在哪,清单是什么。RTMP 的边界是 FLV Tag Header 和 RTMP Message Header,你得知道哪里是一个完整 tag,哪里有 PreviousTagSize;RTP 的边界是 RTP Header,你得靠 sequence number 判断包的顺序,靠 NAL Header 判断分片起止;DASH 的边界是 HTTP 响应体,Content-Length 决定了每个分段从哪到哪。清单则是 MPD 文件、RTP 的 SSRC、RTMP 的流标识,这些都决定了哪些包属于同一路视频,哪些是另一路。
把这个思路套到任何陌生协议上也成立。先找元数据,再按边界切分,最后按序号重组,三步走完,基本上没有提不出来的视频。
3. 实操全流程:三种协议逐个击破
3.1 全局侦察:先看全貌,再伸手
打开 pcap 之后,别急着点开某条流。先用Statistics -> Protocol Hierarchy看协议分布,然后用几个快速过滤命令确认细节:
# 查看 RTSP 信令 tshark -r capture.pcap -Y "rtsp" -T fields -e rtsp.method -e rtsp.uri # 查看 DASH 相关 HTTP 请求 tshark -r capture.pcap -Y "http.request.uri contains "mpd" or http.request.uri contains "m4s"" -T fields -e http.request.uri # 查看 RTP 流的 SSRC 和负载类型 tshark -r capture.pcap -Y "rtp" -T fields -e rtp.ssrc -e rtp.payload_type | sort | uniq -c做这一步的意义是避免在无关流量里浪费半天。题目如果混了三种协议,先明确每段视频大概在哪个时间范围、哪几个端点之间,再逐个击破。我实际做这种题目时的经验是:把过滤结果按时间排序,对照会话时序图基本能画出整个播放过程,后面提取就是按图索骥。
3.2 RTMP 流提取:定位 FLV 头,切出可播放文件
RTMP 的提取路径算是三种里最直观的。在 Wireshark 里定位到携带视频的 TCP 流,右键Follow TCP Stream,选择Raw并导出原始数据。注意这个原始数据里包含 RTMP 握手和所有消息头,还不是标准 FLV,所以需要进一步处理。
大多数题目的流量里,RTMP 的实际承载结构会保留 FLV Tag 形态:每条视频消息的 payload 就是一个 FLV Tag 的 Data。这种情况下,最简单的方法是在导出的 TCP 流字节里搜索46 4c 56 01,也就是FLV\x01的十六进制形式,找到之后把文件裁掉前面的握手和信令,再用脚本按 FLV Tag 结构往下切。如果找不到 FLV 头,说明流量里只有 RTMP 消息头,那就得换一套逻辑:把 RTMP 消息头剥掉,只保留类型 8/9 的 payload,再手动封装 FLV Tag。
下面这段 Python 能处理“能找到 FLV 头”的情况,逻辑简单到适合改写成命令行工具:
import sys data = open("rtmp_stream.bin", "rb").read() idx = data.find(b"FLV\x01") if idx == -1: print("no flv header found") sys.exit(1) out = open("extracted.flv", "wb") out.write(data[idx:]) out.close()裁完头之后,先试着用ffprobe看一眼,如果识别出 FLV 且能看到 H.264 流,说明提取成功;如果报错,常见原因是时间戳或 Tag 边界还是乱的,可以用 FFmpeg 重新转封装一次:
ffmpeg -i extracted.flv -c copy output.mp4-c copy不做重编码,只重新封装,速度快且不损失质量。如果这一步能成功,说明结构基本是完整的,剩下的就是验证画面是否正常。
3.3 RTSP/RTP 流提取:解析 RTP 分片,还原 H.264 裸流
RTSP 提取是三种里最容易翻车的,因为 RTP 分片规则是绕不过去的坎。第一步建议先把整个 RTSP 会话看完整:从 DESCRIBE 请求的响应体拿到 SDP 信息,里面可能有 rtpmap、sprop-parameter-sets 等关键参数。SDP 会告诉你好几件事:负载类型对应哪个编码、RTP timestamp 的单位是否 90kHz、H.264 的 SPS/PPS 是否通过信令下发。
拿到参数后,定位视频 RTP 流。用过滤器找到目标 SSRC 的所有包,例如某个视频流的 SSRC 是0x12345678,提取 payload:
tshark -r capture.pcap -Y "rtp.ssrc==0x12345678" -T fields -e rtp.payload -E separator=/ > rtp_payload_hex.txt接下来需要把十六进制 payload 转成二进制,并按 H.264 的 NAL 类型处理。需要理解三种情况:普通 NAL(类型 1 到 23),直接加00 00 00 01起始码写入文件;STAP-A(类型 24)内含多个 NAL 子单元,需要按子单元长度拆开再分别加起始码;FU-A(类型 28)是分片,一个 NAL 被拆在多个 RTP 包里,需要等齐首包和尾包才能拼完整。下面是核心简化脚本:
import sys lines = [x.strip() for x in open("rtp_payload_hex.txt") if x.strip()] out = open("video.h264", "wb") fua_buffer = bytearray() for line in lines: raw = bytes.fromhex(line) if len(raw) < 2: continue nal_type = raw[1] & 0x1f if nal_type == 24: # STAP-A pos = 2 while pos < len(raw): n_len = int.from_bytes(raw[pos:pos+2], "big") out.write(b"\x00\x00\x00\x01" + raw[pos+2:pos+2+n_len]) pos += 2 + n_len elif nal_type == 28: # FU-A fu_indicator = raw[0] fu_header = raw[1] start = fu_header & 0x80 end = fu_header & 0x40 payload = raw[2:] if start: fua_buffer = bytearray() fua_buffer += bytes([(fu_indicator & 0xe0) | (fu_header & 0x1f)]) fua_buffer += payload elif end and len(fua_buffer) > 0: fua_buffer += payload out.write(b"\x00\x00\x00\x01" + fua_buffer) fua_buffer = bytearray() elif len(fua_buffer) > 0: fua_buffer += payload else: out.write(b"\x00\x00\x00\x01" + raw) out.close()脚本没有处理 NRI 优先级和部分边界情况,但应付常规题目已经够了。更严谨的做法是引用专门的 H.264 depacketizer,不过自己写一遍才能真正理解 RTP 分片到底在做什么。得到video.h264后,用 FFmpeg 转成 MP4:
ffmpeg -f h264 -framerate 25 -i video.h264 -c copy video.mp4如果解码时报non-existing PPS 0 referenced,多半是 SPS/PPS 没写入裸流文件。这时从 SDP 里找到sprop-parameter-sets=Z0IAK5YFAFu...,把逗号分隔的两段 base64 解码后按字节写入裸流开头,再重新转封装。实际做题时,这一步能救回大半“提取失败”的情况。
3.4 MPEG-DASH 提取:导出 HTTP 对象,按 MPD 顺序拼接
DASH 提取的体验要舒服得多。验证目标是 MPD 文件本身:用http.request.uri contains "mpd"找到请求响应,Follow HTTP Stream 保存 MPD 内容。MPD 里会看到<Representation>、<SegmentTemplate>、<Initialization>这些标签,还有$Number$代表的分段编号规则。有了这个清单,提取顺序就明确了。
在 Wireshark 的File -> Export Objects -> HTTP列表里,筛选出响应 Content-Type 为视频、或者扩展名是.m4s.mp4的对象,全部导出到本地目录。导出的文件有些会按 URL 自动命名,有些需要手动重命名,重点是根据 MPD 中的分段顺序给它们编号,而不是看文件大小。乱序拼装的后果是画面跳跃、时长错误,运气好也只是播放器不认。
如果初始化段和媒体段分离,通常文件名里会有init字样,先写 init,再按顺序追加所有 media segment。拼接时用 FFmpeg 的 concat demuxer 最稳,写一个清单文件:
file 'init.mp4' file 'segment_1.m4s' file 'segment_2.m4s'然后执行:
ffmpeg -f concat -i list.txt -c copy merged.mp4 ffmpeg -i merged.mp4 -c copy final.mp4第一次转封装是为了合并分段,第二次是把可能存在的不规范 moov 位置、sidx 索引重新整理成普通播放器能认的 MP4。如果 DASH 流量里视频和音频是两条独立 Representation,那就先把两路的各自分段分别拼接成video.mp4和audio.mp4,最后再合并音视频轨道:
ffmpeg -i video.mp4 -i audio.mp4 -c copy result.mp4这种双轨场景在真实流媒体服务里非常常见,题目里也经常以“只提取视频但声音缺失”的形式出现,所以搞清楚 MPD 里有哪些 Representation 比闷头导文件重要得多。
4. 常见问题与排查技巧实录
4.1 RTP 乱序、丢包与时间戳跳跃
RTP 基于 UDP,没有 TCP 那种保证顺序的机制,所以 pcap 里的捕获顺序并不代表实际发送顺序。如果直接按捕获顺序拼接 payload,得到的裸流轻则花屏,重则完全无法解码。正确做法是先按 sequence number 排序,再按排序后的结果处理分片。Wireshark 的Telephony -> RTP -> RTP Stream Analysis可以直接显示丢包率和乱序统计,用它先评估该路流的健康状况,再决定是否值得手动提。
如果丢包严重,不要指望能拼出一整段无损视频。实际经验是查找 RTP sequence number 的连续性,把连续一段没有缺口的区间单独提取出来,这样至少能得到一个能播放几秒的片段。缺的 RTP 包对应的 NAL 单元会解码失败,但 H.264 本身有容错能力,只要不是关键帧附近大量丢失,后面 GOP 仍可能正常显示。
4.2 SPS/PPS 缺失与封装元数据丢失
提取 H.264 裸流时,最典型的报错是non-existing PPS 0 referenced。原因通常是 RTP 分包时把 SPS/PPS 放在带外传输,也就是通过 SDP 的sprop-parameter-sets下发,而提取时只关心了媒体包,没把这两个关键参数写到文件最前面。解码器需要 SPS 知道分辨率、帧率约定,需要 PPS 知道熵编码方式,缺任何一个都没法解。
解决办法有两步:先看 SDP,把sprop-parameter-sets里 base64 串按逗号拆成两段,分别解码成 SPS 和 PPS;然后在裸流文件最前面按 Annex B 格式写入这些字节,也就是每个 NAL 前加00 00 00 01。如果嫌手写麻烦,也可以用 FFmpeg 的h264_mp4toannexbbitstream filter 处理从 MP4 里导出的 H.264,但裸流提取场景还是手写起始码更直接。
4.3 FLV 时间戳乱序导致的播放卡顿
RTMP 提取出的 FLV 文件,用 FFmpeg 转 MP4 时如果出现“时间戳非单调递增”的警告,多半是 RTMP 消息的时间戳本身携带了乱序值,或者提取脚本在处理扩展时间戳时出了错。RTMP 头的 timestamp 是 24 位,超过 0xFFFFFF 时会使用 extended timestamp,脚本如果没处理扩展字段,就会把高位丢掉,导致时间戳回滚。修复思路是重新遍历一遍 Tag,按时间戳排序或在封装时强制用-vsync调整,不过更干净的做法是直接让 FFmpeg 在-i后加-fflags +genpts重新生成 PTS,避免脏时间戳带来的连锁问题。
另外,导出的 TCP 流如果从握手部分开始存,文件开头会有大量非 FLV 数据。如果 FFmpeg 直接报“Invalid FLV file”,优先检查 FLV 头是否在文件开头,如果不在,先裁掉前面的 RTMP 信令部分,再重新识别。
4.4 一次典型的提取失败排查实例
有一次做题,RTMP 和 RTSP 各有一段视频。RTMP 部分按 FLV 头裁切后转码非常顺利,但 RTSP 部分提取出的 H.264 始终只有前两秒能放,后面一帧都解不出来。我检查了 RTP 统计,没有明显丢包,sequence 连续,最开始怀疑是 SPS/PPS 的问题,但补上之后还是不行。后来逐个 RTP 包看 NAL type,发现有大量的 type 28,但我的脚本只在start位置开了新缓冲区,到了end位置却因为前面的中间包被跳过导致缓冲区长度不够,最后把 FU-A 的中间片段全丢了。问题出在代码用elif连接的分支逻辑上,中间包判断条件在end包出现前先走到了elif end分支,缓冲区被错误闭合。修好分支顺序之后,整段视频立即能放了。
这个例子说明,RTP 提取的主要问题往往不是工具不行,而是对分片状态机的理解不够严谨。做题时如果某个协议怎么都提不出来,不妨把 RTP 前几百个包的 NAL type 和 FU-A 起止位打印出来,人工看几条,就知道脚本哪里出现了偏差。
4.5 快速排查速查表
| 现象 | 可能原因 | 优先验证方法 | 兜底操作 |
|---|---|---|---|
| FLV 转 MP4 报 Invalid FLV | 文件开头混有 RTMP 信令 | 十六进制检查文件头是否FLV\x01 | 裁掉前导数据再转封装 |
| H.264 报 non-existing PPS | SPS/PPS 在 SDP 带外下发 | 比对 sprop-parameter-sets 是否正确写入裸流 | 手动把 base64 解码写入文件开头 |
| RTP 画面花屏 | sequence 未排序或丢包 | 查看 RTP Stream Analysis 的丢包率 | 提取连续无缺口区间,只保留可用 GOP |
| DASH 合并后播放卡顿 | 分段顺序错误 | 对照 MPD 的$Number$与实际文件名 | 按 MPD 编号重新排序,用 concat 重合并 |
| 视频正常但没声音 | 音视频在不同 Representation | 检查 MPD 里是否存在多条媒体流 | 分别拼接音视频流后合并双轨 |
这道题做到最后,我最大的感受是协议五花八门,但核心套路就那么几件事:先把会话关系和元数据搞清楚,再按协议边界把媒体切出来,最后按序号和时间戳重新封装。RTMP 像一条长流水,找到 FLV 头就能往下走;RTSP/RTP 像被撕碎的纸条,得靠 sequence number 和分片规则一张张拼回去;DASH 则像贴好标签的乐高,照着 MPD 的说明书拼就行。掌握这套思路之后,以后再遇到陌生协议,先找清单,再找边界,按序重组,起码不会抓瞎。