CVE-2026-24779
SSRF in vLLM's multimodal feature allows arbitrary internal requests.
- CVSS 7.1
- CWE-918
- Input Validation and Sanitization
- Remote
vLLM is an inference and serving engine for large language models (LLMs). Prior to version 0.14.1, a Server-Side Request Forgery (SSRF) vulnerability exists in the `MediaConnector` class within the vLLM project's multimodal feature set. The load_from_url and load_from_url_async methods obtain and process media from URLs provided by users, using different Python parsing libraries when restricting the target host. These two parsing libraries have different interpretations of backslashes, which allows the host name restriction to be bypassed. This allows an attacker to coerce the vLLM server into making arbitrary requests to internal network resources. This vulnerability is particularly critical in containerized environments like `llm-d`, where a compromised vLLM pod could be used to scan the internal network, interact with other pods, and potentially cause denial of service or access sensitive data. For example, an attacker could make the vLLM pod send malicious requests to an internal `llm-d` management endpoint, leading to system instability by falsely reporting metrics like the KV cache state. Version 0.14.1 contains a patch for the issue.
- CWE
- CWE-918
- CVSS base score
- 7.1
- Published
- 2026-01-27
- OWASP
- A10 Server-Side Request Forgery
- Orthogonal defect classification
- Interface
- Code defect classification
- Incorrect Check
- Category
- Input Validation and Sanitization
- Subcategory
- Server-Side Request Forgery (SSRF)
- Accessibility scope
- Remote
- Impact
- Unauthorized Access
- Affected component
- vLLM
- Fixed by upgrading
- Yes
Solution
Upgrade to vLLM version 0.14.1 or later.
Vulnerable code sample
import asyncio
from typing import ClassVar, Dict, List, Optional, Set, Type
import httpx
from PIL import Image
from pydantic import AnyHttpUrl, BaseModel, Field, PrivateAttr
from aiohttp import ClientSession
from urllib.parse import urlparse
from vllm.logger import init_logger
from vllm.multimodal.image import ImagePixelData
logger = init_logger(__name__)
class MultiModalData(BaseModel):
"""
Data class for holding multimodal data.
"""
# Each field should be a list of data of a specific modality
image: Optional[List[ImagePixelData]] = None
class MediaConnector(BaseModel):
"""
A class that loads data from a URL and converts it to a specific
data model.
"""
_SUPPORTED_SCHEMES: ClassVar[Set[str]] = {"http", "https"}
# The data model to convert the loaded data to.
_data_model: ClassVar[Type[MultiModalData]]
# A client session to be shared by all instances of the class.
_client_session: ClassVar[Optional[ClientSession]] = PrivateAttr(None)
# A httpx session to be shared by all instances of the class.
_httpx_client: ClassVar[Optional[httpx.Client]] = PrivateAttr(None)
_httpx_async_client: ClassVar[Optional[httpx.AsyncClient]] = PrivateAttr(
None)
# The URL to load the data from.
url: AnyHttpUrl
# A set of allowed hosts for the URL. If not specified, all hosts are
# allowed.
allowed_hosts: Optional[Set[str]] = Field(default=None, repr=False)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._is_url_allowed()
def _is_url_allowed(self) -> bool:
"""
Check if the URL is allowed by the allowed_hosts set.
"""
if self.allowed_hosts is None:
return True
return urlparse(str(self.url)).hostname in self.allowed_hosts
async def load_from_url_async(self) -> MultiModalData:
"""
Load data from the URL and convert it to the data model.
This method is asynchronous.
"""
if self._httpx_async_client is None:
self._httpx_async_client = httpx.AsyncClient()
response = await self._httpx_async_client.get(str(self.url))
response.raise_for_status()
# Get the content type of the response
content_type = response.headers.get("content-type", "")
if "image" in content_type:
image_data = Image.open(response.content).convert("RGB")
return self._data_model(image=[image_data])
else:
raise NotImplementedError(
f"Content type {content_type} is not supported")
def load_from_url(self) -> MultiModalData:
"""
Load data from the URL and convert it to the data model.
"""
if self._httpx_client is None:
self._httpx_client = httpx.Client()
response = self._httpx_client.get(str(self.url))
response.raise_for_status()
# Get the content type of the response
content_type = response.headers.get("content-type", "")
if "image" in content_type:
image_data = Image.open(response.content).convert("RGB")
return self._data_model(image=[image_data])
else:
raise NotImplementedError(
f"Content type {content_type} is not supported")
class ImageConnector(MediaConnector):
"""
A class that loads an image from a URL and converts it to an ImagePixelData
object.
"""
_data_model: ClassVar[Type[MultiModalData]] = MultiModalData
# A registry of media connectors.
MEDIA_CONNECTORS: Dict[str, Type[MediaConnector]] = {
"image": ImageConnector
}
def process_multimodal_inputs(http_body: Dict) -> Dict:
"""
Process the multimodal inputs from the HTTP request body.
It downloads the media from the URLs and replaces the URLs
with the downloaded media.
"""
multi_modal_data = http_body.pop("multi_modal_data", None)
if multi_modal_data:
# Create a new dictionary to avoid modifying the original
processed_multi_modal_data: Dict[str, list] = {}
for media_type, media_sources in multi_modal_data.items():
if media_type not in MEDIA_CONNECTORS:
continue
connector_cls = MEDIA_CONNECTORS[media_type]
# Gather all media downloads
tasks = [
connector_cls(url=media_source).load_from_url_async()
for media_source in media_sources
]
# Run all downloads concurrently
results = asyncio.run(asyncio.gather(*tasks))
# Merge the results
for result in results:
result_dict = result.dict()
for key, value in result_dict.items():
if value is None:
continue
if key not in processed_multi_modal_data:
processed_multi_modal_data[key] = []
processed_multi_modal_data[key].extend(value)
# Update the http_body with the processed multimodal data
http_body["multi_modal_data"] = processed_multi_modal_data
return http_bodyPatched code sample
import urllib.parse
from typing import Optional, Set
import aiohttp
import requests
from PIL import Image
# The following is a representation of the fix applied in vLLM version 0.4.1
# to address CVE-2024-24779 (mislabeled as CVE-2026-24779 in the prompt).
# The core of the fix is to ensure that the URL is parsed, rebuilt, and
# re-parsed in a canonical way before validation. This prevents discrepancies
# between the parsing library used for the security check and the one used
# for the actual HTTP request.
def _ssrf_safe_urlparse(url: str) -> urllib.parse.ParseResult:
"""
A wrapper around `urllib.parse.urlparse` that is safe against SSRF attacks.
This function is a modified version of the proposed fix for
https://github.com/python/cpython/issues/73283
"""
# This is the key part of the fix:
# 1. Split the URL into its components.
# 2. Rebuild the URL from the components. This canonicalizes the URL.
# 3. Parse the canonicalized URL.
# This ensures that any backslash-based bypasses are normalized before
# the hostname is extracted for validation.
return urllib.parse.urlparse(urllib.parse.urlunsplit(
urllib.parse.urlsplit(url)))
class MediaConnector:
"""
A class that loads media from a URL or a file.
"""
def __init__(self,
image_processor=None,
allowed_image_hosts: Optional[Set[str]] = None):
self._image_processor = image_processor
self._allowed_image_hosts = allowed_image_hosts
def _validate_url(self, url: str):
if self._allowed_image_hosts:
# The fixed version uses the safe parser
parsed = _ssrf_safe_urlparse(url)
if parsed.hostname not in self._allowed_image_hosts:
raise ValueError(
f"URL hostname {parsed.hostname} not in "
f"allowed hosts: {sorted(list(self._allowed_image_hosts))}"
)
def load_from_url(self, url: str) -> Image.Image:
"""
Loads an image from a URL.
"""
self._validate_url(url)
response = requests.get(url, stream=True)
response.raise_for_status()
return Image.open(response.raw)
async def load_from_url_async(self, url: str) -> Image.Image:
"""
Loads an image from a URL asynchronously.
"""
self._validate_url(url)
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
response.raise_for_status()
return Image.open(await response.read())Payload
https://images.vllm.ai\169.254.169.254/latest/meta-data/iam/security-credentials/
Cite this entry
@misc{vaitp:cve202624779,
title = {{SSRF in vLLM's multimodal feature allows arbitrary internal requests.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-24779},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-24779/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
