@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / cf1c906724
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Security] Fix image EXIF orientation and tRNS transparency handling (#44974)

Signed-off-by: jperezde <jperezde@redhat.com>

Juan Pérez de Algaba committed Jun 9, 2026 at 18:34 UTC cf1c90672404548aa3bc51f92c4745576a65ee26
3 files changed +121 -9
tests/multimodal/test_image.py
+86 -1
@@ -1,12 +1,18 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import struct
4 +from io import BytesIO
5 from pathlib import Path
6
7 import numpy as np
8 import pytest
9 from PIL import Image, ImageChops
10
9 -from vllm.multimodal.image import convert_image_mode
11 +from vllm.multimodal.image import (
12 + _has_transparency,
13 + convert_image_mode,
14 + normalize_image,
15 +)
16
17 pytestmark = pytest.mark.cpu_test
18
@@ -38,3 +44,82 @@ def test_rgba_to_rgb():
44 assert converted_image_numpy[i][j][0] == 255
45 assert converted_image_numpy[i][j][1] == 255
46 assert converted_image_numpy[i][j][2] == 255
47 +
48 +
49 +def test_palette_with_trns_to_rgb():
50 + """P-mode PNG with tRNS: transparent index should become white."""
51 + # Built synthetically because the existing assets are RGBA PNGs;
52 + # this vulnerability only affects P/L/RGB images carrying a tRNS
53 + # chunk, which uses a different transparency mechanism.
54 + img = Image.new("P", (4, 4))
55 + palette = [0] * 768
56 + palette[0:3] = [255, 0, 0]
57 + palette[3:6] = [0, 0, 255]
58 + img.putpalette(palette)
59 + img.putpixel((0, 0), 0)
60 + img.putpixel((1, 0), 1)
61 + img.info["transparency"] = 1
62 +
63 + assert _has_transparency(img)
64 + converted = convert_image_mode(img, "RGB")
65 + assert converted.mode == "RGB"
66 + r, g, b = converted.getpixel((1, 0))
67 + assert (r, g, b) == (255, 255, 255)
68 + r, g, b = converted.getpixel((0, 0))
69 + assert (r, g, b) == (255, 0, 0)
70 +
71 +
72 +def test_l_mode_no_trns_to_rgb():
73 + """L-mode without transparency should convert directly."""
74 + img = Image.new("L", (4, 4), 128)
75 + assert not _has_transparency(img)
76 + converted = convert_image_mode(img, "RGB")
77 + assert converted.mode == "RGB"
78 + assert converted.getpixel((0, 0)) == (128, 128, 128)
79 +
80 +
81 +def test_exif_transpose_normalizes_orientation():
82 + """Image with EXIF orientation 3 (180-degree rotation) should be
83 + normalized so pixel data matches visual display."""
84 + # Built synthetically because the existing assets are PNGs which
85 + # don't carry EXIF orientation metadata; we need a JPEG with an
86 + # injected EXIF orientation=3 tag.
87 + img = Image.new("RGB", (2, 1))
88 + img.putpixel((0, 0), (255, 0, 0))
89 + img.putpixel((1, 0), (0, 0, 255))
90 +
91 + buf = BytesIO()
92 + img.save(buf, format="JPEG")
93 + jpeg_bytes = buf.getvalue()
94 +
95 + exif_orientation_3 = (
96 + b"\xff\xe1"
97 + + struct.pack(">H", 26)
98 + + b"Exif\x00\x00"
99 + + b"MM"
100 + + b"\x00\x2a"
101 + + b"\x00\x00\x00\x08"
102 + + b"\x00\x01"
103 + + b"\x01\x12"
104 + + b"\x00\x03"
105 + + b"\x00\x00\x00\x01"
106 + + b"\x00\x03"
107 + + b"\x00\x00"
108 + + b"\x00\x00\x00\x00"
109 + )
110 +
111 + soi = jpeg_bytes[:2]
112 + rest = jpeg_bytes[2:]
113 + patched = soi + exif_orientation_3 + rest
114 +
115 + rotated = Image.open(BytesIO(patched))
116 + normalized = normalize_image(rotated)
117 + assert normalized.size == (2, 1)
118 +
119 +
120 +def test_normalize_image_no_exif():
121 + """Images without EXIF should pass through unchanged."""
122 + img = Image.new("RGB", (4, 4), (100, 100, 100))
123 + result = normalize_image(img)
124 + assert result.size == img.size
125 + assert result.mode == img.mode
vllm/multimodal/image.py
+30 -6
@@ -1,7 +1,9 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -from PIL import Image
4 +import contextlib
5 +
6 +from PIL import Image, ImageOps
7
8
9 def rescale_image_size(
@@ -16,6 +18,13 @@ def rescale_image_size(
18 return image
19
20
21 +def normalize_image(image: Image.Image) -> Image.Image:
22 + """Normalize EXIF orientation so the pixel data matches visual display."""
23 + with contextlib.suppress(Exception):
24 + image = ImageOps.exif_transpose(image)
25 + return image
26 +
27 +
28 def rgba_to_rgb(
29 image: Image.Image,
30 background_color: tuple[int, int, int] | list[int] = (255, 255, 255),
@@ -27,10 +36,25 @@ def rgba_to_rgb(
36 return converted
37
38
30 -def convert_image_mode(image: Image.Image, to_mode: str):
39 +def _has_transparency(image: Image.Image) -> bool:
40 + """Detect whether an image carries transparency data (RGBA, LA, PA,
41 + or tRNS chunk in P/L/RGB PNGs)."""
42 + if image.mode in ("RGBA", "LA", "PA"):
43 + return True
44 + return "transparency" in getattr(image, "info", {})
45 +
46 +
47 +def convert_image_mode(
48 + image: Image.Image,
49 + to_mode: str,
50 + background_color: tuple[int, int, int] | list[int] = (255, 255, 255),
51 +) -> Image.Image:
52 if image.mode == to_mode:
53 return image
33 - elif image.mode == "RGBA" and to_mode == "RGB":
34 - return rgba_to_rgb(image)
35 - else:
36 - return image.convert(to_mode)
54 +
55 + if to_mode == "RGB" and _has_transparency(image):
56 + if image.mode != "RGBA":
57 + image = image.convert("RGBA")
58 + return rgba_to_rgb(image, background_color)
59 +
60 + return image.convert(to_mode)
vllm/multimodal/media/image.py
+5 -2
@@ -11,7 +11,7 @@ from PIL import Image
11
12 from vllm.utils.serial_utils import tensor2base64
13
14 -from ..image import convert_image_mode, rgba_to_rgb
14 +from ..image import convert_image_mode, normalize_image, rgba_to_rgb
15 from .base import MediaIO, MediaWithBytes
16
17 MAGIC_NUMPY_PREFIX = b"\x93NUMPY" # https://numpy.org/devdocs/reference/generated/numpy.lib.format.html#format-version-1-0
@@ -65,11 +65,14 @@ class ImageMediaIO(MediaIO[Image.Image]):
65 elif image.mode == "RGBA" and self.image_mode == "RGB":
66 return rgba_to_rgb(image, self.rgba_background_color)
67 else:
68 - return convert_image_mode(image, self.image_mode)
68 + return convert_image_mode(
69 + image, self.image_mode, self.rgba_background_color
70 + )
71
72 def load_bytes(self, data: bytes) -> MediaWithBytes[Image.Image]:
73 try:
74 image = Image.open(BytesIO(data))
75 + image = normalize_image(image)
76 image.load()
77 image = self._convert_image_mode(image)
78 except (OSError, Image.UnidentifiedImageError) as e: