comradeFreeman/luxdvr_reveng

GitHub: comradeFreeman/luxdvr_reveng

一个轻量级 Python 代理,通过逆向解析 LuxDVR 专有协议并借助 FFmpeg 将其零转码转换为标准 RTSP 流,使旧款 CCTV DVR 能接入现代监控系统。

Stars: 0 | Forks: 0

# LuxDVR Pro 04-fx2 到 RTSP 的桥接 一个轻量级、零转码的 Python 代理,专为旧版中国 CCTV DVR(特别是 **LuxDVR Pro 04-fx2**)设计。 许多早期的 DVR 使用现代 NVR 无法读取的专有且未公开的传输协议。该脚本充当一个精准的桥接器:它连接到 DVR,剥离专有标头(`1111` / `PACK`),并将纯净的原始 `H.264 Annex B` NAL 单元直接通过管道输入到 `FFmpeg`。 通过将此纯净流传输到像 MediaMTX 这样的 RTSP 服务器,您可以轻松地将旧 DVR 摄像头集成到现代智能家居系统(Shinobi、Frigate、Home Assistant)中,且 CPU 负载仅约为 ~0.1%。 ![运行示例](demo.png "配合 LuxDVR Pro 04-fx2 运行的 RSTP 代理") ### 主要功能: * 🚀 **零转码:** 使用 FFmpeg 的 `-c:v copy` 实现零延迟、零 CPU 消耗的流传输。 * 🛡️ **自动恢复:** 内置的 watchdog 可以优雅地处理网络断开和管道破裂。 * 🎭 **客户端伪装:** 生成随机 MAC 地址和客户端 ID,以防止在同时运行多个摄像头流时出现流冲突。 * 🐧 **Systemd 就绪:** 旨在作为 Linux 中的模板守护进程 24/7 全天候运行(参见 [luxdvr@.service](luxdvr@.service))。 ### 文件说明 1. `protocol.py` — 核心协议解析器。处理部分逆向工程的专有通信(DVR 到 IE+WebClient),剥离自定义传输包装器并提取纯净的 H.264 Annex B 帧。有关逆向工程的详细信息在下方提供。 2. `main.py` — 主入口点和网络客户端。管理 CLI 参数,维护 TCP socket,运行带有自动重连功能的 keep-alive watchdog,并将原始二进制视频流通过管道输出到 `stdout`。 3. `ref.py` — 在初始逆向工程阶段使用的实验性参考脚本。 4. `credentials.py` — 默认配置文件(主机 IP、登录名、密码、MAC)。这些值作为后备默认值,可以通过 CLI 参数覆盖。 5. `stream_copy.sh` / `stream_h264.sh` — 演示 FFmpeg pipeline 的示例 shell 脚本。展示了如何以零 CPU 消耗(`copy`)或完全软件转码(`h264`)的方式将原始流重新打包为 RTSP。 6. `luxdvr@.service` — 一个 `systemd` 模板单元文件,用于将桥接部署为 Linux 上具有弹性的 24/7 后台 daemon。它允许独立管理多个摄像头,处理自动崩溃恢复,并确保与本地 RTSP 服务器(例如 MediaMTX)的正确启动顺序。 # LuxDVR Pro 04-Fx2 逆向工程 ### 本部分提供了协议逆向工程的里程碑记录 ## 1. 前置条件 + Oracle VirtualBox + Windows XP 虚拟机(为节省时间,您可以使用预装操作系统的 VDI,例如 [这个](https://sysprobs.com/windows-xp-virtualbox-pre-installed-image) 镜像) **注意!建议将虚拟网络接口设置为“桥接”模式,以简化操作流程!** + Wireshark + Python ## 2. 准备工作 1. 将您的 PC、虚拟机和 DVR 连接到同一路由器 2. 找出 DVR 的 IP 地址 3. 在虚拟机上运行 `Internet Explorer` 并访问 `http://`,然后点击 `Internet Explorer`。 此时应开始下载 `WebClient.exe`。 4. 安装上述文件,重启浏览器 5. 在您用于连接网络的接口上运行 Wireshark,使用过滤器 `ip.addr == `,然后按 `Enter` 6. 导航至 `http:///webcamera.html` - 您应该会看到一个登录页面。默认情况下,登录名是 `admin`,密码是 `123456`。 ![带有 DVR Pro 04-fx2 旧版 Web 界面的 Windows XP 虚拟机](xp.png "带有 DVR Pro 04-fx2 旧版 Web 界面的 Windows XP 虚拟机") ## 3. 协议 *(完整的参考实验脚本位于 `ref.py` 中)* 登录后,您将能够直接在浏览器中观看摄像头视频。同时,如果您查看 Wireshark 窗口,您会看到: 在建立连接期间主要是 HTTP 流量,随后是来自 6036 端口视频流传输的纯 TCP 流量。 我猜测这至少包含两个会话:UI 会话(端口 80)和控制会话(端口 6036,同时负责视频流传输)。 让我们深入分析最后一种流量 ### 1) 问候(DVR -> 客户端) 在建立到控制流的连接后(在我的例子中是 #367),DVR 立即发送长度为 64 字节的 `hello` 数据包。 其主要特征是在 Wireshark 中查看 `ascii` 转储时包含 `head` 这个词(且只有这个词): | 偏移量 | 十六进制 | ASCII 转储 | |:---------|:--------------------------------------------------|:-------------------------------------------------:| | 00000000 | 68 65 61 64 00 00 00 00  b9 00 00 00 04 00 00 00 | head.... ........ | | 00000010 | 03 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000020 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000030 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | **注意:您可以在 Wireshark 中从该数据包开始追踪 TCP 流** ### 2) 身份验证(客户端 -> DVR) #### 注意:从这里开始,每个数据包都以所谓的 4 字节长的“magic header”开头 - `b'1111'`(十六进制的 `31 31 31 31`)。接下来是 4 字节长的 payload 长度(小端序) 客户端(*这里及下文我均指 IE + WebClient.exe*)发送包含身份验证凭据的特定格式结构体。 此外,它还会发送 PC 的全名和 MAC 地址。也许 DVR 需要此信息来区分不同的客户端 | 偏移量 | 十六进制 | ASCII 转储 | |:---------|:-----------------------------------------------------|:---------------------------------------------------------| | 00000000 | 31 31 31 31 88 00 00 00  01 01 00 00 78 01 fb 03 | 1111.... ....x... | | 00000010 | 00 00 00 00 78 00 00 00  03 00 00 00 00 00 00 00 | ....x... ........ | | 00000020 | 61 64 6d 69 6e 00 00 00  00 00 00 00 00 00 00 00 | admin... ........ | | 00000030 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000040 | 00 00 00 00 31 32 33 34  35 36 00 00 00 00 00 00 | ....1234 56...... | | 00000050 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000060 | 00 00 00 00 00 00 00 00  73 79 73 70 72 6f 62 73 | ........ sysprobs | | 00000070 | 2d 64 61 31 61 36 31 00  00 00 00 00 00 00 00 00 | -da1a61. ........ | | 00000080 | 00 00 00 00 08 00 27 63  97 34 00 00 04 00 00 00 | ......'c .4...... | 说明: 1. 从 `0x20` 偏移量开始、长度为 36 字节的字节 - `login`(登录名) 2. 从 `0x44` 偏移量开始、长度为 36 字节 - `password`(密码) 3. 偏移量 `0x84-0x89` 表示客户端的 MAC 地址,在我的例子中,它是 VirtualBox 桥接网络接口的 MAC 地址:`08:00:27:63:97:34`。 4. 从 `0x68` 偏移量开始、长度为 28 字节的部分表示完整的计算机名称。 ### 3) DVR 信息(DVR -> 客户端) 如果身份验证成功,DVR 会发送一些关于自身的软件和硬件数据: | 偏移量 | 十六进制 | ASCII 转储 | |:---------|:-----------------------------------------------------|:----------------------------------------------------------| | 00000040 | 31 31 31 31 6c 01 00 00  01 00 01 00 50 be ec 00 | 1111l... ....P... | | 00000050 | 04 00 00 00 5c 01 00 00  ff ff ff ff 0f 00 00 00 | ....\\... ........ | | 00000060 | 00 00 00 00 0f 00 00 00  00 00 00 00 0f 00 00 00 | ........ ........ | | 00000070 | 00 00 00 00 0f 00 00 00  00 00 00 00 0f 00 00 00 | ........ ........ | | 00000080 | 00 00 00 00 0f 00 00 00  00 00 00 00 04 04 04 01 | ........ ........ | | 00000090 | c8 00 00 00 04 04 00 00  80 08 10 04 40 12 00 b9 | ........ ....@... | | 000000A0 | 01 01 01 01 84 78 94 82  04 00 00 00 01 00 00 00 | .....x.. ........ | | 000000B0 | fc ff c9 03 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 000000C0 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 000000D0 | 00 00 00 00 00 18 ae 39  83 9c 00 00 0c 07 dd 07 | .......9 ........ | | 000000E0 | 28 31 0b 00 45 44 56 52  00 00 00 00 00 00 00 00 | (1..EDVR ........ | | 000000F0 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000100 | 00 00 00 00 00 00 00 00  33 2e 33 2e 30 2e 50 2d | ........ 3.3.0.P- | | 00000110 | 33 35 32 30 41 2d 30 30  00 00 00 00 00 00 00 00 | 3520A-00 ........ | | 00000120 | 00 00 00 00 00 00 00 00  00 00 00 00 43 39 4b 37 | ........ ....C9K7 | | 00000130 | 2d 44 33 42 33 2d 44 37  42 34 00 00 00 00 00 00 | -D3B3-D7 B4...... | | 00000140 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000150 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000160 | 00 00 00 00 00 00 00 00  00 00 00 00 31 38 35 2e | ........ ....185. | | 00000170 | 30 2e 31 36 2e 51 39 2d  44 4b 43 42 41 2d 74 64 | 0.16.Q9- DKCBA-td | | 00000180 | 32 30 61 00 00 00 00 00  00 00 00 00 00 00 00 00 | 20a..... ........ | | 00000190 | 2d 2d 2d 00 00 00 00 00  00 00 00 00 00 00 00 00 | ---..... ........ | | 000001A0 | 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 000001B0 | 00 00 00 00    | .... | 在这里我们可以找到 DVR 的主机名、软件、硬件版本以及大量未知的数据 :) ### 4) 摄像头?(DVR -> 客户端) 前一个数据包发送完毕后,DVR 立即发送另一个数据包。该数据包的含义有点“模糊”。 这可能是对可用摄像头的枚举,并包含每个摄像头的附加信息。 有趣的是,该数据包似乎包含多个“微型”数据包,因为“magic header” (以及相应的 payload 长度)出现了 5 次:开头 1 次,然后每个摄像头各 1 次。 ### 5) 摄像头首选项(设置?请求?)(客户端 -> DVR) | 偏移量 | 十六进制 | ASCII 转储 | |:---------|:-----------------------------------------------------|:---------------------------------------------------------| | 00000090 | 31 31 31 31 50 00 00 00  03 04 00 00 ff ff ff ff | 1111P... ........ | | 000000A0 | ff ff ff ff 40 00 00 00  00 f8 59 05 04 00 00 00 | ....@... ..Y..... | | 000000B0 | 01 f8 00 00 00 00 00 00  02 f8 00 00 00 00 00 00 | ........ ........ | | 000000C0 | 03 f8 00 00 00 00 00 00  40 f8 00 00 00 00 00 00 | ........ @....... | | 000000D0 | 41 f8 00 00 00 00 00 00  42 f8 00 00 00 00 00 00 | A....... B....... | | 000000E0 | 43 f8 00 00 00 00 00 00 | C....... | 这个数据包比前一个更加“模糊”。非常非常粗略地,我可以假设这是一种关于 每个摄像头设置的“请求”(也许不仅仅是摄像头的设置)- 您可以看到从 `0xb0` 偏移量开始的 8 个请求。 在此之前可能还有另一个请求(看一下 `03 04` - 让人联想到 `命令 + 子命令` 的结构) #### 关于这个数据包我唯一确切知道的事情是 - 我们需要发送这个请求,并且**必须**读取完整的回复(见下一点。) ### 6) 摄像头预设(DVR -> 客户端) 先前的请求之后,DVR 会回复几个庞大的数据包(**20580** 字节!),除了 `ascii` 转储中重复出现的 `preset001`-`preset128` 外,我们对这些数据包一无所知。 但是我们**必须**读取所有这些内容,否则 DVR 会关闭连接。 ### 7) 流媒体视频(客户端 -> DVR) 只有现在我们才能请求 DVR 开始传输摄像头的视频流。像往常一样,我们需要传递 `magic header`、payload 长度(52 字节)、 可能的 `命令 + 子命令` 组合以及位于 `0x10c` 偏移量处的摄像头编号,其余所有部分都填零: | 偏移量 | 十六进制 | ASCII 转储 | |:---------|:-----------------------------------------------------|:---------------------------------------------------------| | 000000E8 | 31 31 31 31 34 00 00 00  01 02 00 00 00 00 00 00 | 11114... ........ | | 000000F8 | 00 00 00 00 24 00 00 00  00 00 00 00 00 00 00 00 | ....$... ........ | | 00000108 | 00 00 00 00 01 00 00 00  00 00 00 00 00 00 00 00 | ........ ........ | | 00000118 | 00 00 00 00 00 00 00 00  00 00 00 00 | ........ .... | ### 8) Keep-alive(客户端 -> DVR) 在实验过程中我注意到,当原始文件大小即将达到 3 Mb 时,DVR 会关闭流。 这种行为是由于客户端缺乏对 DVR 的“提醒”引起的,意思是“我还在这里,请继续传输”。 我在 Wireshark 中发现了这个数据包 - 它的有效载荷只有 8 字节,每 7-8 秒发送一次。 因此,我们至少需要每 5-10 秒发送一次“keep-alive”数据包: | 偏移量 | 十六进制 | ASCII 转储 | |:---------|:------------------------|:--------------------------------------------------| | 0000015C | 31 31 31 31  00 00 00 00 | 1111 .... | 仅仅只有 `magick header` 和零长度的 payload,但这就足够了! ## 4. 处理来自 DVR 的视频流 最后,在满足上述所有条件后,DVR 将开始回复原始的 H264 视频流。 但由于它被封装在 TCP 中,而 TCP 本身并不关心其内容,只关心可靠交付, 因此开发人员继续使用他们的协议。以下是 Gemini 提供的解释(它还为我制作了 `cleaner.py`, 可以将 TCP 视频流转换为纯净的 H264),因为我并没有完全理解它施展的魔法 🥲... 直到第 11 次尝试 😅 为了让另一端的程序(我们的 Python 脚本)能够理解一个数据块从哪里开始、另一个在哪里结束,DVR 工程师们发明了他们自己的传输层(包装器)。 让我们使用转储中的示例,逐层拆解这个“俄罗斯套娃”。 ### 第 1 层:传输信封(DVR 级别) DVR “吐”到 TCP socket 中的每一条信息都被严格打包成一个标准的信封。它始终具有完全相同的结构: `Magic header` + `Payload 长度` + `Payload 本身` 在您的数据中,它看起来像这样: 1. Magic header:`31 31 31 31`(ASCII 中的 `b'1111'`)。这是我们脚本的信标。它的意思是:“注意,一个新数据包即将开始!”。 2. Payload 长度:接下来的 4 个字节指示后面有多少字节。 *日志示例(续传数据包):b'1111\x1c(\x00\x00PACK...'* 1. 标头:`b'1111'` 2. 长度:`\x1c(\x00\x00`。在小端序架构中,这是数字 `0x0000281c`。转换为十进制,即 10,268 字节。 我们在 Python 中的 while 循环会搜索标头,读取 10268 字节,并精确地按该长度进行“截取”(切片)。这就是我们提取内部套娃的方式。 ### 第 2 层:元数据和分片(信封里面是什么?) DVR 从摄像头提取一帧画面。如果它是一个中间帧(P 帧,仅包含如手或阴影等运动信息),它的大小会很小 —— 比如说 500 字节。DVR 可以轻松地将其填充到一个 1111 信封中。 但是每 2 秒钟,摄像头就会输出一个 I 帧(关键帧)。这是一张完整、类似 JPEG 的整帧图像。它的大小,例如,达到了 33100 字节。 在早期中国 DVR 的内存中,为网络传输分配了 10 KB 的缓冲区。DVR 在物理上根本无法一次性发送 33 KB! 那它做了什么呢?它拿出一把链锯,将 I帧 切碎(将其分片)。 为了让客户端(也就是我们)能够理解这是一个整体的不同片段,DVR 发明了另一个包装器 —— PACK 标头。 它精确占用 28 字节并包含分片编号: + `PACK... \x01\x00\x00\x00` — 分片 #1 + `PACK... \x02\x00\x00\x00 — 分片 #2 + `PACK... \x03\x00\x00\x00` — 分片 #3 *日志示例(I帧的第一个分块)*: DVR 提取视频帧的前 10240 字节,将 28 字节的 `PACK` 标头粘附在上面(总计 10268 字节),将其打包进 `b'1111'` 信封,然后发送进 TCP socket。 ### 第 3 层:视频编解码器(H.264 Annex B) 终于,我们接触到了视频本身。在包装器内部存放着原始像素。但 H.264 编解码器也有它自己的标记(NAL units)。 H.264 中的每一个元素,无一例外地都以该序列开头:`00 00 00 01`(Start Code)。 紧跟在这个标记后面的是 1 个字节,它准确地告诉 FFmpeg 接下来要传输的是什么: + `\x00\x00\x00\x01 \x67`(在 ASCII 中是 `g`)— SPS 数据包。这是视频的“护照”,包含分辨率(352x288)以及那个疯狂的帧率(1,200,000 fps)。 + `\x00\x00\x00\x01 \x68`(在 ASCII 中是 `h`)— PPS 数据包。 + `\x00\x00\x00\x01 \x65`(在 ASCII 中是 `e`)— IDR 帧。关键帧实际像素的起点。 + `\x00\x00\x00\x01 \x41`(在 ASCII 中是 `A`)— P 帧。运动帧。 您日志中的示例(视频的最开始部分!):`b'1111\x1c(\x00\x00 PACK... \x00\x00\x00\x01 g B...'` 我们看到了编解码器标记、字母 g(SPS),FFmpeg 由此明白:“啊哈,画面尺寸是 352x288!”。 ### 它是如何出错的以及我们是如何修复的(总结) 想象一下 DVR 正在发送一个巨大的帧。网络中飞舞着一串链条: + 信封 1:`b'1111'` `长度` `PACK 分片 1` `00 00 00 01 (编解码器!)` `视频数据...` + 信封 2:`b'1111'` `长度` `PACK 分片 2` `视频数据续传...` + 信封 3:`b'1111'` `长度` `PACK 分片 3` `视频数据结尾...` ### 我们最终的脚本做了什么? 它以极高的精准度运作: 1. 它在 `信封 1` 中看到 `00 00 00 01` 了吗?它会切掉它之前的所有内容并抓取纯净的视频。 2. 它处理 `信封 2`。它没有在那里看到编解码器标记,但看到了分片标记 `PACK`。 脚本明白:“啊哈!这是被切分的帧的尾部!”。它仔细地切掉 `PACK` 标头的 28 个字节, 并将原始像素无缝地拼接到 `信封 1` 上。 3. 它抓取到一个 P 帧。它切掉中文标头(76 字节)并提取 `00 00 00 01 \x41`。 4. 脚本剥去层层套娃,扔掉传输层的塑料外壳(`b'1111'` 和 `PACK`), 并保存最纯净、完整的 H.264,而 FFmpeg 也乐意将其“吞下”。
标签:FFmpeg, Python, RTSP, 并发处理, 无后门, 物联网, 视频流媒体, 逆向工具