fix(security): prevent image decompression bomb OOM denial of service (#47010)
Signed-off-by: jperezde <jperezde@redhat.com>
Juan Pérez de Algaba committed
Jun 30, 2026 at 11:39 UTC
364ee36af1ed72a7ae3700b14db8099bc1b94e6c
7 files changed
+115
-12
docs/usage/security.md
+15
@@ -85,6 +85,21 @@ significantly reduce the attack surface for these types of abuse.
85
Also, consider setting `VLLM_MEDIA_URL_ALLOW_REDIRECTS=0` to prevent HTTP
86
redirects from being followed to bypass domain restrictions.
87
88
+### 5. **Restrict Media Decode Sizes:**
89
+
90
+Compressed media files can expand into gigabytes of memory during decoding. vLLM
91
+enforces decode-size limits to prevent out-of-memory denial of service:
92
+
93
+| Environment Variable | Default | Description |
94
+| --- | --- | --- |
95
+| `VLLM_MAX_IMAGE_PIXELS` | `178956970` (~179M pixels) | Maximum decoded image size in pixels. Images exceeding this are rejected before raster memory is allocated. Default matches PIL's built-in 2x decompression-bomb threshold (~680 MB for RGB). |
96
+| `VLLM_MAX_AUDIO_CLIP_FILESIZE_MB` | `25` | Maximum filesize in MB for a single audio file. |
97
+| `VLLM_MAX_AUDIO_DECODE_DURATION_S` | `600` | Maximum decoded audio duration in seconds. Prevents compressed audio from expanding into gigabytes of float32 PCM. |
98
+
99
+Setting any of these to `0` disables the corresponding limit. This is **not
100
+recommended** for deployments exposed to untrusted users, as it removes the
101
+protection against resource-exhaustion attacks.
102
+
103
## Security and Firewalls: Protecting Exposed vLLM Systems
104
105
While vLLM is designed to allow unsafe network services to be isolated to
tests/multimodal/media/test_image.py
+54
@@ -205,3 +205,57 @@ def test_image_media_io_load_file(tmp_path):
205
206
with pytest.raises(ValueError, match="Failed to load image"):
207
image_io.load_file(truncated_real_file)
208
+
209
+
210
+def test_image_pixel_limit_respected():
211
+ """A small image within the pixel limit loads successfully."""
212
+ import vllm.envs as envs
213
+
214
+ image = Image.new("RGB", (100, 100), (255, 0, 0))
215
+ from io import BytesIO
216
+
217
+ buf = BytesIO()
218
+ image.save(buf, format="PNG")
219
+ data = buf.getvalue()
220
+
221
+ assert envs.VLLM_MAX_IMAGE_PIXELS >= 100 * 100
222
+
223
+ image_io = ImageMediaIO()
224
+ result = image_io.load_bytes(data)
225
+ assert result.media.size == (100, 100)
226
+
227
+
228
+def test_image_pixel_limit_rejected(monkeypatch):
229
+ """An image exceeding the pixel limit is rejected before raster decode."""
230
+ import vllm.envs as envs
231
+
232
+ monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100)
233
+
234
+ image = Image.new("RGB", (20, 20), (0, 255, 0))
235
+ from io import BytesIO
236
+
237
+ buf = BytesIO()
238
+ image.save(buf, format="PNG")
239
+ data = buf.getvalue()
240
+
241
+ image_io = ImageMediaIO()
242
+ with pytest.raises(ValueError, match="exceed"):
243
+ image_io.load_bytes(data)
244
+
245
+
246
+def test_image_pixel_limit_disabled(monkeypatch):
247
+ """Setting VLLM_MAX_IMAGE_PIXELS=0 disables the pixel limit."""
248
+ import vllm.envs as envs
249
+
250
+ monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 0)
251
+
252
+ image = Image.new("RGB", (1000, 1000), (0, 0, 255))
253
+ from io import BytesIO
254
+
255
+ buf = BytesIO()
256
+ image.save(buf, format="PNG")
257
+ data = buf.getvalue()
258
+
259
+ image_io = ImageMediaIO()
260
+ result = image_io.load_bytes(data)
261
+ assert result.media.size == (1000, 1000)
vllm/envs.py
+9
@@ -79,6 +79,7 @@ if TYPE_CHECKING:
79
VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25
80
VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600
81
VLLM_MAX_AUDIO_PREPROCESS_WORKERS: int = max(1, min(os.cpu_count() or 1, 2))
82
+ VLLM_MAX_IMAGE_PIXELS: int = 178_956_970
83
VLLM_VIDEO_LOADER_BACKEND: str = "opencv"
84
VLLM_MEDIA_CONNECTOR: str = "http"
85
VLLM_MM_HASHER_ALGORITHM: str = "blake3"
@@ -954,6 +955,13 @@ environment_variables: dict[str, Callable[[], Any]] = {
955
str(max(1, min(os.cpu_count() or 1, 2))),
956
)
957
),
958
+ # Maximum decoded image size in pixels. Small compressed images can
959
+ # expand into gigabytes of raster memory. This limit is enforced before
960
+ # decoding so the memory is never allocated. Default matches PIL's
961
+ # built-in 2x decompression-bomb threshold (~179M pixels, ~680 MB RGB).
962
+ "VLLM_MAX_IMAGE_PIXELS": lambda: int(
963
+ os.getenv("VLLM_MAX_IMAGE_PIXELS", "178956970")
964
+ ),
965
# Backend for Video IO — selects the frame-sampling algorithm.
966
# - "opencv": uniform sampling.
967
# - "opencv_dynamic": duration-aware dynamic sampling.
@@ -2083,6 +2091,7 @@ def compile_factors() -> dict[str, object]:
2091
"VLLM_MAX_AUDIO_CLIP_FILESIZE_MB",
2092
"VLLM_MAX_AUDIO_DECODE_DURATION_S",
2093
"VLLM_MAX_AUDIO_PREPROCESS_WORKERS",
2094
+ "VLLM_MAX_IMAGE_PIXELS",
2095
"VLLM_VIDEO_LOADER_BACKEND",
2096
"VLLM_MEDIA_CONNECTOR",
2097
"VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME",
vllm/multimodal/media/image.py
+9
@@ -9,6 +9,7 @@ import pybase64
9
import torch
10
from PIL import Image
11
12
+import vllm.envs as envs
13
from vllm.utils.serial_utils import tensor2base64
14
15
from ..image import convert_image_mode, normalize_image, rgba_to_rgb
@@ -72,6 +73,14 @@ class ImageMediaIO(MediaIO[Image.Image]):
73
def load_bytes(self, data: bytes) -> MediaWithBytes[Image.Image]:
74
try:
75
image = Image.open(BytesIO(data))
76
+ w, h = image.size
77
+ max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
78
+ if max_pixels > 0 and w * h > max_pixels:
79
+ raise ValueError(
80
+ f"Image dimensions {w}x{h} ({w * h} pixels) exceed "
81
+ f"the maximum of {max_pixels} pixels. Set "
82
+ f"VLLM_MAX_IMAGE_PIXELS to increase this limit."
83
+ )
84
image = normalize_image(image)
85
image.load()
86
image = self._convert_image_mode(image)
vllm/multimodal/video.py
+28
@@ -13,6 +13,7 @@ import numpy as np
13
import numpy.typing as npt
14
import torch
15
16
+from vllm import envs
17
from vllm.logger import init_logger
18
from vllm.utils.import_utils import PlaceholderModule
19
from vllm.utils.mem_constants import MiB_bytes
@@ -88,6 +89,18 @@ def get_video_loader_backend_for_processor(
89
return VIDEO_LOADER_REGISTRY.get_backend_for_video_processor(video_processor)
90
91
92
+def _check_frame_pixel_limit(width: int, height: int) -> None:
93
+ """Reject video frames exceeding VLLM_MAX_IMAGE_PIXELS before decoding."""
94
+ max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
95
+ if max_pixels > 0 and width * height > max_pixels:
96
+ raise ValueError(
97
+ f"Video frame dimensions {width}x{height} "
98
+ f"({width * height} pixels) exceed the maximum of "
99
+ f"{max_pixels} pixels. Set VLLM_MAX_IMAGE_PIXELS to "
100
+ f"increase this limit."
101
+ )
102
+
103
+
104
def resize_video(frames: npt.NDArray, size: tuple[int, int]) -> npt.NDArray:
105
num_frames, _, _, channels = frames.shape
106
new_height, new_width = size
@@ -733,6 +746,7 @@ class PyNvVideoCodecVideoBackendMixin:
746
temp_file.write(data)
747
748
gpu_source = cls._read_source_metadata(temp_path, nvc)
749
+ _check_frame_pixel_limit(gpu_source.width, gpu_source.height)
750
source = cls._prepare_source(gpu_source.source)
751
frame_idx = cls.compute_frames_index_to_sample(
752
source=source, target=target, **kwargs
@@ -835,6 +849,10 @@ class VideoBackend(
849
850
if backend == "opencv":
851
cap = cls.open_video_capture(data)
852
+ _check_frame_pixel_limit(
853
+ int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
854
+ int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
855
+ )
856
source = cls._prepare_source(cls.get_video_metadata(cap))
857
frame_idx = cls.compute_frames_index_to_sample(
858
source=source, target=target, **kwargs
@@ -850,6 +868,8 @@ class VideoBackend(
868
"frame_recovery is only available for `opencv` backend"
869
)
870
with av.open(BytesIO(data)) as container:
871
+ stream = container.streams.video[0]
872
+ _check_frame_pixel_limit(stream.width, stream.height)
873
source = cls._prepare_source(cls.get_metadata(container))
874
frame_idx = cls.compute_frames_index_to_sample(
875
source=source, target=target, **kwargs
@@ -1609,6 +1629,10 @@ class Molmo2VideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1629
**kwargs,
1630
) -> tuple[npt.NDArray, dict[str, Any]]:
1631
cap = cls.open_video_capture(data)
1632
+ _check_frame_pixel_limit(
1633
+ int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
1634
+ int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
1635
+ )
1636
1637
source = OpenCVVideoBackendMixin.get_video_metadata(cap)
1638
target = VideoTargetMetadata(
@@ -1758,6 +1782,10 @@ class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1782
Tuple of (frames_array, metadata_dict)
1783
"""
1784
cap = cls.open_video_capture(data)
1785
+ _check_frame_pixel_limit(
1786
+ int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
1787
+ int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
1788
+ )
1789
1790
source = OpenCVVideoBackendMixin.get_video_metadata(cap)
1791
vllm/transformers_utils/processors/nano_nemotron_vl.py
-6
@@ -44,12 +44,6 @@ AUDIO_CONTEXT = "<so_embedding>"
44
# MAX_FRAMES = 16
45
DEFAULT_NUM_TILES = 12
46
47
-# Configure PIL to handle large images without warnings
48
-# This prevents DecompressionBombWarning for legitimate large images
49
-Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely
50
-# Alternative: Set a specific higher limit
51
-# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels
52
-
47
48
def calculate_timestamps(
49
indices: list[int] | torch.Tensor,
vllm/transformers_utils/processors/nemotron_vl.py
-6
@@ -10,12 +10,6 @@ from vllm.tokenizers.hf import HfTokenizer
10
11
from .internvl import InternVLImageProcessor, InternVLProcessor
12
13
-# Configure PIL to handle large images without warnings
14
-# This prevents DecompressionBombWarning for legitimate large images
15
-Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely
16
-# Alternative: Set a specific higher limit
17
-# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels
18
-
13
14
def build_transform(input_size: int):
15
return T.Compose(