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: