这道题我前后折腾了差不多两个通宵,从最开始拿到 pcap 包一脸懵,到后来能把三种协议的视频流完整抠出来,中间踩的坑比想象中多得多。先说结论:这类流量分析题的核心套路其实很固定——先认协议、再找流媒体会话、然后想办法把媒体数据从包里剥出来重组成文件,最后用播放器验证。这篇文章我就以自己实际做题的过程为主线,把 rtmp、rtsp、mpeg-dash 三条线分别怎么拆、怎么提取、怎么避坑,一次性讲清楚。
1. 题目拆解:这三条协议分别在考什么
很多人拿到题目第一反应是“三种协议都要会解析”,但实际做下来你会发现,真正的考点不是让你徒手去读二进制流,而是考察你对协议交互过程的理解——尤其是握手阶段、控制信令和媒体数据的分发方式。如果对协议底层机制不熟,就算工具摆在面前也不知道该抓哪个包、该过滤哪条流。
先说 RTMP。这是个老牌协议,基于 TCP,默认端口 1935,但流量分析题里经常故意改成 80 或者 443 来迷惑你。RTMP 的特点是长连接 + 多路复用,一条 TCP 连接上既跑控制消息(比如 connect、createStream、play)也跑媒体分块(audio/video 数据)。所以提取的关键在于:先识别出 AMF0 编码的命令消息,沿着命令流找到 play 操作后面的媒体流通道,再按 RTMP chunk 格式重组数据。
再说 RTSP。RTSP 本身不传媒体数据,它只负责会话控制(DESCRIBE、SETUP、PLAY 这些命令),真正的音视频数据走的是 RTP,而且是基于 UDP 或 TCP 封装。这种协议的提取难点在 RTP 报文里,负载头里的 payload type、timestamp、sequence number 这些字段你得认清楚,因为它们直接决定了你怎么剥离 12 字节的 RTP 头、怎么处理排序和去重。
最后是 MPEG-DASH。它不是一种“线上协议”,而是一种基于 HTTP 的分段媒体传输方案,客户端通过 manifest 文件(MPD)拿到分片 URL,再逐个下载。所以提取思路完全不同——你要做的是从 HTTP 响应里还原出 MPD 和媒体分片(通常是 fMP4 格式),然后把分片按顺序拼起来。这反而是三种里最容易自动化的,一条 http 过滤规则就能解决大半问题。
我在开始做题前先给自己列了一张表,把三种协议的关键特征、默认端口、媒体封装格式和推荐的提取工具写在一张纸上,事实证明这个动作非常有用,后面所有排查都是围绕这张表展开的。
| 协议 | 传输层 | 控制信令 | 媒体数据 | 提取工具 |
|---|---|---|---|---|
| RTMP | TCP | AMF0 命令 | FLV 标签 | tcpdump + 自写解析脚本 |
| RTSP | TCP/UDP | RTSP 文本命令 | RTP 包 | Wireshark 的 RTP 分析器 |
| MPEG-DASH | HTTP | 无(请求 URL 即控制) | fMP4 分片 | Wireshark 导出 + 手动拼接 |
这张表列完,其实后面一半的工作量已经确定了——你只是在验证你的判断而已。
2. 环境准备:工具链和基本过滤规则
工欲善其事必先利其器。我用的环境是 Kali 虚拟机,Wireshark 3.6 版本,配合 tshark 做命令行批量处理,另外还装了一个专门处理媒体流的开源小工具,用来从原始字节流里抠 FLV 和 MP4。如果你机器上有 Python,强烈建议装上scapy和construct这两个库,后面写协议解析脚本会省非常多事。
打开 pcap 之后不要急着翻包,先看 Protocol Hierarchy 统计。这是我最常用的一个入口,它能在几秒钟之内告诉你包里有哪些协议、流量占比是多少。我那次统计出来的结果大概是:TCP 占了大头,HTTP 次之,然后能看到少量 RTSP 和 RTP,RTMP 由于跑在 TCP 之上需要专门去识别。这个结果直接帮我确认了三条线的存在,也提示我 RTMP 流量藏得比较深。
关于过滤规则,我建议按协议分别保存成独立的过滤配置:
- RTMP:
tcp.port == 1935 || tcp.port == 80,然后通过tcp.payload里的关键字去进一步确认。因为 RTMP 握手的前三个字节是版本号(一般是 0x03),你可以用tcp.payload[0:3] == 03:00:00来做粗筛。 - RTSP/RTP:
rtsp || rtsp2 || rtp,这是最直接的入口。RTP 的 SSRC 字段如果稳定不变,说明是一条持续的媒体流;如果 SSRC 频繁变化,就要小心是不是有多路流交叉。 - MPEG-DASH:
http && http.response.code == 200,然后配合http.content_type看一下 response 里是不是video/mp4或application/dash+xml。MPD 文件的 Content-Type 一般就是application/dash+xml,特征非常明显。
我在实际做题时发现,RTSP 那一路的包很容易被忽略,因为它的控制信令(DESCRIBE、SETUP、PLAY)都是纯文本,长得跟普通 HTTP 很像,混在一堆 80 端口流量里特别容易被覆盖掉。我的经验是先按rtsp过滤一遍,把控制信令的交互时序理清,再切到 RTP 去看媒体流,这样逻辑上就不容易乱。
环境准备阶段花不了太多时间,但一定要做完整。我把 Wireshark 的列显示调成了Time, Source, Destination, Protocol, Length, Info,然后把 TCP 的 Relative sequence number 打开,RTP 的 SSRC 和 timestamp 也显示出来。这些显示偏好会影响你后续的判断效率,所以别嫌麻烦。
3. RTMP 提取:从 TCP 流里抠出 FLV 文件
RTMP 这题我走了不少弯路,一开始直接用了 Wireshark 的“Follow TCP Stream”导出原始数据,然后想当然地拿导出文件当 FLV 去播放,结果当然是失败的。后来查资料才知道,RTMP 的数据在 TCP 层之上还有一层Chunk 封装——数据被切分成一个个 chunk,每个 chunk 有自己的 basic header 和 message header,直接导出的原始流里夹杂着这些协议头,必须先把它们剥掉重建出完整的 FLV 结构。
重建 FLV 的思路是:RTMP 媒体消息(type id 为 8 代表音频,9 代表视频,18 代表 metadata)的 message body 就是一个 FLV tag 的 Data 部分。你只需要从每个消息体里取出数据,在前面补上 11 字节的 FLV tag header,再补上 4 字节的 PreviousTagSize,就能拼出完整的 FLV 文件。
我写的提取脚本核心逻辑是这几步:
- 用 scapy 读取 pcap,按 TCP 流重组数据。注意按四元组(src ip、src port、dst ip、dst port)区分不同的 RTMP 连接,别把所有流混在一起。
- 忽略 RTMP 握手阶段的 1537 字节(如果 C/S 都发了 1537 字节的握手包,你要把这部分跳过)。
- 遍历 chunk 流,解析 basic header 里的 chunk stream id 和 message header 里的 message type id、message stream id、timestamp。
- 遇到 type id 为 8/9/18 的消息,记录下 message body。
- 将所有消息体按 timestamp 排序后,依次组装成 FLV tag 写入文件。
import scapy.all as sc def get_tcp_payloads(pcap, src_ip, src_port, dst_ip, dst_port): """按四元组提取某个 TCP 流的所有载荷""" payloads = [] for pkt in sc.PcapReader(pcap): if not pkt.haslayer(sc.TCP): continue ip = pkt[sc.IP] tcp = pkt[sc.TCP] if (ip.src == src_ip and ip.sport == src_port and ip.dst == dst_ip and ip.dport == dst_port): if tcp.payload: payloads.append(bytes(tcp.payload)) return b''.join(payloads)拿到 TCP 载荷后,关键一步是跳过握手。RTMP 握手分三步:客户端发 C0(1 字节版本)+ C1(1536 字节随机数),服务器回 S0+S1+S2(总共 3073 字节),两边校验完握手就结束了。做题的时候最简单的判断方式是:找到第一个非握手命令消息(通常是 connect),从这个 offset 开始解析 chunk 流。我写了个小的扫描函数,在一段字节流里搜索“\x03\x00\x00\x00\x00\x00\x03”这种 connect 命令的 AMF 特征,一旦找到就从这里作为起点。
这里有个小坑:RTMP 的 chunk size 默认是 128 字节,但服务端可以通过 Set Chunk Size 消息(type id 为 1)动态调整,最大能到 65536。如果你的脚本只按 128 硬切,在高码率视频流下一定会错位,解析出来的文件会花屏甚至打不开。我的做法是先处理这个控制消息,动态更新 chunk size。
当脚本把 FLV 文件拼出来之后,我直接在本地用开源播放器试播,第一版出来画面是花的,排查了半天发现是 PreviousTagSize 算错了。这个值必须是本 tag 的 11 字节头 + 前面一个 tag 的 Data 长度的总和,不能简单用消息体长度。修正之后文件就能正常播放了,当时那个爽快感确实很强。
4. RTSP 提取:RTP 排序、去重与时间戳校正
RTSP 这道题比 RTMP 要“温柔”一些,因为传输的媒体内容是裸的 RTP 包,不需要担心 chunk 重组,但也带来另一个麻烦:RTP 是面向包的,包的排列顺序不一定跟播放顺序一致,你还要处理乱序、丢包重传和时间戳抖动。
我的提取路径分成四步:
第一步,用rtsp过滤找控制信令。重点看 SETUP 响应里的Transport: RTP/AVP/UDP;unicast;client_port=xxxxx-xxxxx;server_port=yyyyy-yyyyy,这里会给出一对 UDP 端口,分别是 RTP 和 RTCP 的端口,音频和视频各有一对。别小看这一步,端口没搞清楚,后面过滤 RTP 就是大海捞针。
第二步,用 Wireshark 的rtp过滤器把所有 RTP 包列出来,看 SSRC 和 Payload Type(PT)。通常视频是 96/97/98 这种动态 PT,音频是 8(PCMA)或 0(PCMU)。如果不存在动态 PT,就用 RTP 的 marker bit 和 timestamp 的变化趋势来判断:视频帧的 timestamp 在同一帧内保持不变,帧与帧之间有明显跳变;音频的 timestamp 则是连续递增。
第三步,排序和去重。RTP 的 sequence number 是 16 位循环递增的,排序时要注意回绕处理,不能直接用sort -n完事。我的做法是把 sequence number 差值在 -32767 以下的视为回绕,加上 65536 再参与排序。同时,重复的包要按 sequence number 去重,我见过同一个包出现三份,不处理的话会音频视频不同步。
第四步,提取 payload 并组装。这里必须先解析 RTP header,payload 从第 12 字节开始。如果走了 TCP 封装的 RTP(interleaved 模式,带 4 字节的帧标记),那是在$字符后面跟 2 字节 payload length,处理方式完全不同。我那次题目里用的是 UDP 封装,所以避开了这个大坑。
组装音视频时,我用了一招比较省事的办法:直接解析 RTP payload 里的编码信息。
- 如果是 H.264,RTP payload 通常以 NAL unit 开头,每个 NAL 前面需要补
00 00 00 01起始码,再把 AUD(Access Unit Delimiter)处理好。 - 如果是 AAC,RTP payload 里通常是 ADTS 头(有些封装会带 AU 头,要去掉),把 ADTS 头连着 payload 直接存成
.aac文件就能播放。
我实际做的 H.264 提取片段是这样的:
def extract_h264_from_rtp(rtp_payloads): out = b'' prev_delta = None for ts, seq, marker, payload in rtp_payloads: # 留给 H.264 的 RTP payload 可能是单个 NAL 单元, # 也可能是分片(FU-A/FU-B),分片时需要重组 nal_type = payload[0] & 0x1F if nal_type == 28: # FU-A fu_header = payload[1] start_bit = (fu_header >> 7) & 0x01 end_bit = (fu_header >> 6) & 0x01 nri = (payload[0] >> 5) & 0x03 nal = bytes([(nri << 5) | (fu_header & 0x1F)]) + payload[2:] if start_bit: out += b'\x00\x00\x00\x01' + nal elif end_bit: out += nal else: out += b'\x00\x00\x00\x01' + payload return out这版脚本排序前先把 marker bit 和 timestamp 建了个索引,方便按帧组织。调试时我用tshark -r capture.pcap -T fields -e rtp.timestamp -e rtp.seq -e rtp.p_type -E separator=/t打印关键字段,跟脚本的输出对比验证了两三组数据,确认没有问题才继续。
提取完成之后,H.264 的裸流可以直接用 ffmpeg 转成 MP4 验证:ffmpeg -f h264 -i video.h264 -c:v copy video.mp4。音频如果是 AAC,直接ffmpeg -f adts -i audio.aac -c:a copy audio.m4a。这里提醒一句,H.264 裸流如果起始码缺失或者 SPS/PPS 不在流前面,ffmpeg 会报错,需要手动把 SPS/PPS 拼到第一个关键帧前,这类题目的坑我后来也整理成了一张自查表。
5. MPEG-DASH 提取:HTTP 响应重组与 MPD 解析
MPEG-DASH 这条线路在三种协议里是最“现代”的,也最贴近现在 Web 播放的实际场景。它本质上就是一连串 HTTP GET 请求,客户端先用一个 MPD 文件拿到视频分片的信息,再根据带宽和分辨率选择需要下载的片段。
做题的第一步很简单:用http.response.code == 200 && http.content_type contains "dash"过滤出 MPD 的响应。这个文件是 XML 格式,里面有Period、AdaptationSet、Representation和SegmentTemplate这些关键元素。我那次拿到的 MPD 里,视频分片的 URL 是按$Number$模板生成的,比如segment_$Number$.m4s,这种模板意味着分片的命名是连续编号的,可以直接按顺序拉取。
第二步是把所有分片的 HTTP 响应从 pcap 里导出。方法有两种:一种是 Wireshark 图形界面里选中一条 HTTP 响应,右键Follow HTTP Stream,然后选择导出原始数据;另一种是写脚本遍历所有 response,按http.content_length字段把 body 抠出来,并以 URL 末尾的编号命名。第二种适合分片多的情况,我那次有几十个分片,手动导出会疯掉。
导出分片之后,每个.m4s文件其实是 fMP4 的一个 segment,它自带stypbox(segment type)和moofbox(movie fragment),可以独立播放但不能单独作为完整 MP4。拼接的思路就是:把第一个分片里的ftypbox 保留,后面的分片去掉重复的ftyp和无关 box,再把所有moof + mdat按顺序接起来,最后用一个合适的工具修复 moov 索引。
我用 ffmpeg 做了拼接,最简单的方式是先把每个分片转成 ts,再全部 concat。但这样做会重新编码,可能引入画质损失。更稳妥的做法是写脚本直接拼接,或者用支持 DASH 的工具链把 segment 合成为 mp4。我实际用的是脚本拼接,几十个 fragment 合并之后生成的 MP4 播放完全正常。
MPD 解析的注意点集中在SegmentTemplate和SegmentTimeline的区别上。前者是简单的编号模板,后者在直播场景里会携带时间偏移(t/d属性),如果你按编号下载而它实际是按时间漂移的,分片序列就会错位,拼接出来的视频缺帧。我那次虽然是按编号的,但依然踩了另一个坑:MPD 里写了availabilityStartTime,导致分片 URL 的前缀带了时间参数,直接用模板拼出来的 URL 跟包里实际请求对不上。解决办法是回到 HTTP 请求列表,确认实际请求的完整 URI,再决定如何命名分片文件。
下面是我写的一个简单分片提取脚本(示意):
def extract_dash_segments(pcap, filter_str='http'): segments = {} for pkt in sc.PcapReader(pcap): if pkt.haslayer(sc.TCP) and pkt[sc.TCP].payload: # 这里简化处理,实际需要按 TCP 流重组完整 HTTP 响应 pass # 把 segments 按编号排序,然后合并 with open('merged.mp4', 'wb') as f: for seg in sorted(segments.items()): f.write(seg[1])实际做的时候,我还是优先用 Wireshark 的File → Export Objects → HTTP功能,它可以直接列出所有 HTTP 对象,按 content type 排序后把媒体分片批量导出,非常方便。只是注意有些分片的 Content-Type 是application/octet-stream,需要结合 URL 后缀来判断哪些是媒体分片。
把三条线都做通之后,我把三份输出文件放在同一个播放器里对比试播,RTMP 提取的 FLV 画质和时长最“原始”,RTSP 的 H.264 + AAC 最标准,DASH 的 MP4 则带了完整的 metadata 信息(因为 fMP4 里本来就封装了时长、分辨率)。这让你真切感受到三种协议各自在设计哲学上的差异。
6. 问题排查与经验汇总:那些让我半夜抓狂的坑
做题过程中我专门维护了一个错误日志,把每次失败的现象、原因和解决方式都记了下来。这一节把我遇到频率最高的几个坑展开讲讲,保你少走弯路。
坑一:TCP 流重组不完整导致媒体数据残缺。这是所有协议共通的问题,Wireshark 的 Follow TCP Stream 默认只显示该流的数据,但如果你用了负载均衡或端口复用,可能在一条“逻辑流”里塞了多路 RTMP/RTSP 会话。解决方法是严格按四元组 + 会话建立时间区分,或者直接把流量按 key 拆开分别导出。用 tshark 导出时我习惯加上-2 -R "tcp.stream eq 12"这种按索引筛选的方式。
坑二:RTP 时间戳排序后音频视频对不上。这个问题在 RTSP 题里特别常见。原因是音频和视频的 RTP timestamp 时钟频率不同:视频通常是 90000 Hz,音频是 8000 Hz 或 48000 Hz。排序时你只能用同一路流的 timestamp 做顺序判断,不能把两路的 timestamp 放在一起比大小。我当时就犯了这个错误,结果音频视频交错混乱,花了半小时才反应过来。
坑三:RTMP 消息体里有 AMF 数据掺杂。类型 18 的 metadata 消息(onMetaData)里藏着分辨率、码率、帧率等信息,它本身不是音视频数据,但很多脚本在拼 FLV 时忘了跳过它,结果把 metadata 当成了 video tag 头,导致整个 FLV 结构错位。正确的做法是把 metadata 按 AMF0 格式解析,提取需要的字段,但不要把它写入 FLV 文件(或者你按 FLV 规范写成 ScriptData tag 也可以,但我建议初学时直接跳过)。
坑四:DASH 分片里的sidxbox 被忽略导致时长错误。部分分片带sidx(Segment Index Box),它记录了分片内部的子分段信息。如果你只按moof + mdat拼接而没处理sidx,播放器虽然能播,但在 seek 的时候会不准。我个人建议拼接时把sidx也保留(按顺序放回原位置),这样最终文件更完整。
坑五:抓包时没有抓到服务端到客户端的响应,导致只有请求没有响应体。这种题在真实环境里是因为抓包位置不对称造成的,但在题目给的 pcap 里如果也是只有半条链路的数据,往往意味着媒体数据是单向广播的(比如组播 RTP),你需要检查目标地址是不是组播地址,并调整过滤规则。
我把排查思路总结成一个简单的决策树:
- 提取出来的文件打不开?先看是不是文件头不对:FLV 必须以
46 4C 56开头,MP4 必须以ftypbox 开头。 - 能打开但花屏/乱音?大概率是时间戳排序错了,或者 H.264 缺少起始码,或者 AAC 的 ADTS 头被多剥了一层。
- 时长只有几秒,远小于预期?多半是流重组时只拿到了握手之后的一小段,或者丢包太多,RTP 排序时没有做丢包补偿。
这个决策树是我做完题之后才总结出来的,但如果你动手之前先看一遍,会少走很多弯路。
7. 实操心得:比技术更重要的是方法论
我个人的体会是,这类流量分析题真正考查的并不是“会不会用 Wireshark”,而是你能不能把理论协议栈和实际抓包逐层对应起来。很多人在学校学了 RTMP 是 TCP 上的协议、RTSP 是文本协议、DASH 是 HTTP 分片,但打开 pcap 的一瞬间就懵了,因为看到的是一堆十六进制字节流和杂乱无章的时序。原因就在于你没有建立一种“假设—验证—修正”的循环。
我在做题时给自己定了几条纪律,效果很好:
第一,永远先从统计视图入手。不要上来就点开第一个包看十六进制,先用 Protocol Hierarchy 和 Conversations 把全局图景搞清楚。这一步能帮你确认存在哪些协议、哪些 IP 是服务器、哪些是大流量会话,比漫无目的地翻包高效一个数量级。
第二,每一步输出都要有验证手段。我从 TCP 流里拿到 FLV 后,先用ffprobe看封装信息,确认有视频轨和音频轨再继续调播放。每修改一次脚本,都做一次 A/B 对比,看看输出文件差异在哪一段。这种“随时验证”的习惯帮我快速定位了多个隐藏 bug。
第三,保留中间产物,不要一路改到底。我习惯把所有中间文件(原始导出流、解析后的裸流、拼好的 FLV/MP4)分目录存放,并写一个说明文件记录每一步命令和参数。这样一旦最后结果不对,可以回溯是哪一步出的问题,而不需要从头再来一遍。
第四,如果完全卡住了,就回到协议规范本身。我某次 RTMP 提取失败,问题出在忽略了一个 AMF0 扩展字段,导致后续的对象边界错位。当时我是在网上找到一份 RTMP 规范的中文整理版,把 AMF0 的 type marker 一个一个对照着解析,才定位到问题。协议规范永远是最可靠的老师。
最后再说一个很多人忽视的点:时间戳和序号的处理逻辑必须写在脚本里,不要靠肉眼判断。我去重和排序用的都是在数据预处理阶段完成的逻辑,而不是在生成文件之后靠播放器去猜。这样即使分片再多、丢包再多,只要你保持了准确的时间基准,拼接出来的文件就能用。
这是我整个做题过程里最有价值的一段经验,也希望对正在跟这道题较劲的人有点启发。如果你也在做类似的流量分析,建议先把思路理清,再动工具,拿不到结果的时候回头检查协议细节,而不是急着换工具或者换方案。