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%。

### 主要功能:
* 🚀 **零转码:** 使用 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`。

## 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, 并发处理, 无后门, 物联网, 视频流媒体, 逆向工具