基于抓包模拟云服务器实现网络摄像头本地控制
背景
家里几年前买的一个飞利浦品牌的网络摄像头,摄像头本身带双轴云台、语音对讲、云端视频等功能,正常情况下只能通过“云看看”APP使用。
这类摄像头有一个比较明显的问题:只要厂商停止维护APP、关闭服务器,或者设备所在网络无法访问厂商服务器,就无法继续使用,另外数据上传到厂商服务器也存在数据隐私问题。因此我希望在不依赖厂商APP和云服务器的情况下,实现摄像头的本地视频播放和云台控制。
设备的基本信息如下:
| 项目 | 信息 |
|---|---|
| 名称 | 皇家飞利浦WiFi版 |
| 芯片 | FH8626V100 |
| 固件 | V30023.1.145.20240716 |
| 原APP | 云看看 |
| 云台 | 水平、垂直双轴 |
对局域网端口扫描后,设备只有8554端口开放,使用VLC可以直接播放:
1 | rtsp://admin:admin123456@摄像头IP:8554/profile0 |
但是设备没有开放HTTP管理页面,也没有发现可用的ONVIF服务,常见的ONVIF设备管理工具同样无法发现它。也就是说,视频已经可以本地读取,但云台控制仍然只能走厂商私有协议。
目标
最开始考虑过修改固件,但这条路存在几个问题:
- 没有找到对应版本的官方固件包;
- 无法确定固件是否有签名、校验和分区保护;
- 即使成功解包,也需要继续分析云台驱动和进程间通信;
- 一旦刷写失败,摄像头很容易变砖。
相比之下,APP既然能通过网络控制云台,那么只要还原摄像头与服务器之间的协议,就可以在不修改固件的情况下模拟厂商服务器。因此最终目标调整为:
- 通过RTSP读取视频;
- 抓取摄像头从开机到云台控制的完整流量;
- 还原服务器发现、会合、可靠传输和云台控制协议;
- 在Linux中创建摄像头热点并劫持其云端请求;
- 用Python模拟云服务器,提供本地HTTP控制页面。
本文只分析和控制自己持有的设备,不涉及接入或控制其他人的摄像头。
抓包
抓包范围
如果只抓手机APP流量,看到的主要是手机与厂商服务器之间的通信,并不能直接得到摄像头最终收到的云台控制包。
因此我让摄像头连接到自己创建的无线热点,在热点网关上抓取摄像头的全部流量,并从摄像头断电启动开始记录。待摄像头上线后,在APP中依次执行“上、下、左、右”,这样抓包中同时包含:
- DHCP和DNS;
- 摄像头向引导服务器注册;
- 引导服务器下发最终会话地址;
- 摄像头上传视频;
- APP触发后的云台方向和停止命令。
Wireshark中可以先按摄像头MAC或IP过滤,再重点观察UDP:
1 | eth.addr == 摄像头MAC |
1 | ip.addr == 摄像头IP && udp |
摄像头重启后本地IP和UDP源端口都会变化,所以实现时不能依赖固定IP或固定端口,应通过MAC识别设备,并以实际收到报文的源地址作为当前会话地址。
动态服务器
抓包中摄像头会查询以下域名:
1 | p2p2.cloudbirds.cn |
随后向多个公网节点的UDP 32100、32101、32102端口发送注册请求。不同时间抓包时,公网节点和最终视频服务器地址均发生了变化,这说明摄像头不是写死连接某一台视频服务器,而是先连接引导节点,再由引导节点动态分配最终会话节点。
这也解释了为什么直接寻找一个固定的“云台UDP端口”没有意义:摄像头本地端口、最终服务器地址和最终服务器端口都可能在重启后变化。
协议分析
引导协议
摄像头的引导协议以F1开头,核心报文如下:
| 报文 | 方向 | 作用 |
|---|---|---|
| F100 | 摄像头 -> 引导服务器 | NAT地址探测 |
| F101 | 引导服务器 -> 摄像头 | 返回观察到的IP和端口 |
| F112 | 摄像头 -> 引导服务器 | 设备注册 |
| F113 | 引导服务器 -> 摄像头 | 注册确认 |
| F182 | 引导服务器 -> 摄像头 | 下发最终会话IP、端口和令牌 |
| F183 | 摄像头 -> 会话服务器 | 携带设备ID和令牌报到 |
其中F182总长度为24字节,端口和IPv4均采用小端或字节倒序:
1 | offset size 含义 |
模拟服务器收到F112后先返回F113,再发送一条指向本机的F182。摄像头接受后,会主动向本地会话端口发送F183:
1 | F1 83 | 00 1C | token[4] | device_id[20] | flag_le32 |
会话服务器继续返回F103、F184和一条反向F183,然后双方才开始传输控制和视频数据。
可靠传输层
最终会话中的数据报文为F1D0,确认报文为F1D1:
1 | F1 D0 | length | channel | sequence | payload |
抓包中出现了三个逻辑通道:
| 通道 | 内容 |
|---|---|
| D100 | 设备信息、配置和云台控制 |
| D102 | H.264视频数据 |
| D103 | H.264视频数据 |
服务器必须对摄像头发送的控制和视频序号返回F1D1确认,否则摄像头会不断重传。两个视频通道中都可以找到H.264的Annex-B起始码和SPS,因此不能简单地把其中一个通道当作音频。
云台协议
在APP中依次点击上、下、左、右后,对比服务器发送的D100控制报文,可以确认云台命令号为0x1001,方向映射如下:
| 方向 | 数值 |
|---|---|
| 停止 | 00 |
| 上 | 01 |
| 下 | 02 |
| 左 | 03 |
| 右 | 06 |
完整报文模板为:
1 | F1 D0 00 24 D1 00 SS SS |
其中SS SS是大端序列号,DD是方向。APP按下按钮时只发送一次方向命令,松开时使用新的序列号发送一次停止命令。
最初实现HTTP页面时,我每隔700ms重复发送一次方向命令,结果云台移动表现为一顿一顿。后来改为按下只发一次方向、松开只发一次停止;按住期间浏览器只发送HTTP心跳,服务端超过1.2秒收不到心跳才自动停止,云台移动就恢复平滑了。
Windows热点方案的问题
第一版服务器运行在Windows移动热点上。摄像头连接热点后,可以正常看到它发往公网引导节点的UDP请求,Python也可以伪造F113和F182。
抓包已经明确看到摄像头根据伪造的F182,向Windows热点地址的32110端口发送了F183,但是普通UDP socket始终收不到这个包。
也就是说,此时协议已经基本正确,问题出在Windows移动热点的ICS、WFP和本机NAT投递路径:链路层能看到报文,不代表它一定能进入本机应用监听的socket。继续在Windows上绕过这一层的成本已经高于切换Linux,因此后续改在Linux实现。
Linux模拟服务器
创建热点
最开始直接使用hostapd切换无线网卡模式,出现了如下错误:
1 | nl80211: Could not configure driver mode |
原因是同一张网卡原先由NetworkManager和wpa_supplicant作为客户端连接Wi-Fi,直接启动hostapd时接口没有正确释放。
最终改为让NetworkManager自己创建热点。程序先记录原Wi-Fi连接并断开,然后执行等价于以下命令的操作:
1 | nmcli device wifi hotspot \ |
NetworkManager会自动提供网关、DHCP和DNS。实际环境中热点网关为10.42.0.1/24,摄像头每次通过DHCP获得动态地址。
只有一张无线网卡时,电脑创建热点后会暂时失去Wi-Fi互联网连接。因此程序会记录原连接,退出时删除临时热点并恢复原Wi-Fi。如果需要电脑保持上网,可以增加一个USB无线网卡:USB网卡连接互联网,内置网卡专门创建摄像头热点。
劫持引导请求
Linux下使用nftables,把来自摄像头热点网段、目标为公网UDP 32100至32102的报文重定向到本地引导服务器。这样不需要预测摄像头解析到哪一台公网节点,也不需要把公网服务器地址写死在代码中。
1 | 摄像头 -> 公网引导节点:32100 |
本地Python服务器返回报文后,连接跟踪会把响应源地址还原成摄像头原本请求的公网节点。抓包中可以看到本机生成的F182在线路上表现为:
1 | 公网引导节点:32100 -> 摄像头动态IP:动态端口 |
而F182负载中下发的最终会话地址则是本机热点地址和32110端口。
Linux INPUT防火墙
Linux方案中也遇到过一个很容易误判的问题:链路层已经看到摄像头向10.42.0.1:32110发送F183,Python UDP socket却依然收不到。
这次已经能够排除NAT和协议字段,因为抓包中的F183令牌、设备ID、目标IP和端口都正确,最终定位为主机INPUT防火墙在socket之前丢弃了新会话报文。
为了不全局关闭系统防火墙,程序使用AF_PACKET只读监听摄像头热点接口,仅把“摄像头子网发往本机32110”的UDP负载交给协议处理器;所有响应仍然通过正常UDP socket发送。这样既能绕过本机INPUT丢包,又不会永久修改系统防火墙配置。
最终状态机如下:
1 | 创建热点和DHCP |
HTTP控制页面和视频
通过Python实现完整协议后,启动Python服务器启动一个HTTP服务。提供以下功能:
- 显示摄像头当前动态IP;
- 显示云端模拟会话是否建立;
- 鼠标、触摸和键盘控制上下左右;
- 按住移动、松开停止和失联自动停止;
- 播放摄像头实时画面。
浏览器通常不能直接播放RTSP,因此页面中的视频没有使用私有云视频通道,而是由ffmpeg从摄像头的8554/profile0读取RTSP,再转码为浏览器可以直接显示的MJPEG:
1 | 摄像头RTSP -> ffmpeg -> MJPEG -> HTTP页面 |
程序会在RTSP TCP和UDP之间自动切换。正常启动方式如下:
1 | python3 camera_linux_server.py --force-interface |
当使用第二张无线网卡上网、热点网卡不再承载默认路由时,则不需要--force-interface:
1 | python3 camera_linux_server.py --interface wlp0s20f3 |
效果
最终实现了以下效果:
- 摄像头连接本地Linux热点,不需要手机APP参与控制;
- Python模拟引导服务器和最终会话服务器,摄像头主动连接本机;
- 本地服务器完成可靠层ACK、设备问候和配置交换;
- HTTP页面可以平滑控制云台上下左右移动;
- 页面可以通过RTSP实时播放摄像头画面;
- 程序退出后自动删除热点配置并恢复原Wi-Fi连接。
整个过程没有修改摄像头固件。对这类没有ONVIF、但保留RTSP和私有云协议的设备来说,模拟服务器比刷写未知固件的风险小很多,也便于通过抓包逐步验证每一个协议状态。
未完成部分
当前抓包没有执行语音对讲,因此还无法确定音频上行的编码、逻辑通道和控制命令。D102、D103均已确认包含H.264数据,不能凭通道编号推断其中一个是音频。
另外,目前的服务器是针对FH8626V100和该固件版本的实验实现,问候包和配置包部分字段仍然是按抓包原样回放。不同固件可能使用不同的设备认证或配置字段,不能直接认为适用于所有“云看看”摄像头。
如果后续继续分析,最有价值的抓包是:摄像头完整断电启动后,单独执行一次短语音对讲,并同时保留手机和摄像头两侧的完整流量。这样才能定位音频协商、音频负载以及跨重启变化的会话字段。
总结
从动态引导服务器、Windows本机NAT,到Linux INPUT防火墙,网络报文从网卡到应用socket之间还会经过多层处理。通过逐层确认“摄像头有没有发送、网卡有没有收到、socket有没有收到”,最终才把问题从私有协议收敛到操作系统网络栈,并完成本地云服务器模拟。
对于自己持有的网络设备,只要抓包中能观察到完整的请求、响应和状态变化,就可以先把协议当作状态机还原,再逐步替换动态字段和外部依赖。相比直接修改固件,这通常是一条更可控、也更容易验证的本地化路径。