[Bugfix][Security] Bound embedding densification before to_dense() (#54632)
Signed-off-by: lzhan011 <35493221+lzhan011@users.noreply.github.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
lzhan011 committed
Sep 1, 2026 at 03:45 UTC
ce7391712b59f55a8d1254468605c5a3592d2d50
8 files changed
+226
-18
docs/usage/security.md
+1
@@ -98,6 +98,7 @@ denial of service:
98
| `VLLM_MAX_AUDIO_CLIP_FILESIZE_MB` | `25` | Maximum compressed filesize in MB for a single audio file. Enforced on all audio inputs (multimodal chat URLs, speech-to-text uploads, data: URLs, and local file paths) before decoding begins. |
99
| `VLLM_MAX_AUDIO_DECODE_DURATION_S` | `600` | Maximum decoded audio duration in seconds. Prevents compressed audio from expanding into gigabytes of float32 PCM. |
100
| `VLLM_MAX_AUDIO_DECODE_BYTES` | `268435456` (256 MiB) | Maximum float32 PCM bytes that audio decoding may allocate. Guards against sample-rate forgery where an inflated header sample rate bypasses the duration guard while the actual frame count causes a multi-GiB allocation. |
101
+| `VLLM_MAX_EMBED_DECODE_BYTES` | `2147483648` (2 GiB) | Maximum bytes a client-supplied embedding payload (`prompt_embeds`, `image_embeds`, `audio_embeds`, `video_embeds`) may allocate once densified. A sparse tensor carries its own declared shape, so a payload of a few hundred bytes can expand into hundreds of GiB. Checked before `to_dense()`, so the memory is never allocated. Set to `0` to disable. |
102
103
Setting any of these to `0` disables the corresponding limit. This is **not
104
recommended** for deployments exposed to untrusted users, as it removes the
tests/renderers/test_sparse_tensor_validation.py
+143
-1
@@ -2,7 +2,13 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""
4
Tests verify that malicious sparse tensors are rejected before they can trigger
5
-out-of-bounds memory writes during to_dense() operations.
5
+out-of-bounds memory writes, or an unbounded allocation, during to_dense()
6
+operations.
7
+
8
+A sparse tensor carries its own declared shape, so the two failure modes are
9
+independent: invalid *indices* are what the invariant checks catch, while a
10
+valid tensor with an enormous declared *shape* passes those checks and is
11
+bounded by `VLLM_MAX_EMBED_DECODE_BYTES` instead.
12
"""
13
14
import io
@@ -12,9 +18,12 @@ import pybase64 as base64
18
import pytest
19
import torch
20
21
+import vllm.envs as envs
22
from vllm.exceptions import VLLMValidationError
23
from vllm.multimodal.media import AudioEmbeddingMediaIO, ImageEmbeddingMediaIO
24
+from vllm.multimodal.media.video import VideoEmbeddingMediaIO
25
from vllm.renderers.embed_utils import safe_load_prompt_embeds
26
+from vllm.utils.sparse_utils import safe_to_dense
27
28
29
@pytest.fixture
@@ -397,3 +406,136 @@ class TestSparseTensorValidationIntegration:
406
407
with pytest.raises((RuntimeError, ValueError)):
408
io_handler.load_base64("", attack_payload.decode("utf-8"))
409
+
410
+
411
+# The real attack payload: 4 TiB of float32 once densified, from a single
412
+# stored element. Only ever measured, never decoded.
413
+BOMB_SHAPE = (2**20, 2**20)
414
+
415
+# What the size-limit tests below actually build. Each of them also lowers
416
+# `VLLM_MAX_EMBED_DECODE_BYTES` to `TEST_LIMIT_BYTES`, so the guard still has
417
+# something to reject while nothing here can allocate more than 4 MiB even if
418
+# the guard regressed. Keeping the 4 TiB shape in these tests would make the
419
+# outcome depend on the runner: with `vm.overcommit_memory=0` the allocator
420
+# refuses it outright, but with `vm.overcommit_memory=1` the malloc succeeds
421
+# and the zero-fill is what fails, which would OOM the test process.
422
+OVERSIZED_SHAPE = (1024, 1024) # 4 MiB dense
423
+TEST_LIMIT_BYTES = 4096
424
+
425
+EMBEDDING_MEDIA_IO = [
426
+ (ImageEmbeddingMediaIO, "image_embeds"),
427
+ (AudioEmbeddingMediaIO, "audio_embeds"),
428
+ (VideoEmbeddingMediaIO, "video_embeds"),
429
+]
430
+
431
+
432
+def _create_oversized_sparse_tensor(shape: tuple[int, int]) -> torch.Tensor:
433
+ """A *valid* sparse tensor with one stored element and a declared `shape`.
434
+
435
+ Indices are in bounds, so `check_sparse_tensor_invariants()` accepts it.
436
+ That is the point: the invariant checks are not what stops this one.
437
+ """
438
+ indices = torch.tensor([[0], [0]])
439
+ values = torch.tensor([1.0])
440
+ return torch.sparse_coo_tensor(indices, values, shape, dtype=torch.float32)
441
+
442
+
443
+@pytest.fixture
444
+def small_decode_limit(monkeypatch: pytest.MonkeyPatch) -> None:
445
+ monkeypatch.setenv("VLLM_MAX_EMBED_DECODE_BYTES", str(TEST_LIMIT_BYTES))
446
+
447
+
448
+class TestEmbeddingDecodeSizeLimit:
449
+ """Bound the dense allocation a payload can declare.
450
+
451
+ `weights_only=True` stops code execution and the invariant checks stop
452
+ out-of-bounds indices, but neither bounds the declared shape.
453
+ """
454
+
455
+ def test_oversized_sparse_tensor_rejected(self, small_decode_limit):
456
+ with pytest.raises(VLLMValidationError) as exc_info:
457
+ safe_to_dense(
458
+ _create_oversized_sparse_tensor(OVERSIZED_SHAPE),
459
+ parameter="prompt_embeds",
460
+ )
461
+
462
+ assert "VLLM_MAX_EMBED_DECODE_BYTES" in str(exc_info.value)
463
+ assert "prompt_embeds" in str(exc_info.value)
464
+
465
+ def test_default_limit_would_reject_the_real_payload(self):
466
+ """The shipped default has to bound the actual attack, not just the
467
+ shrunken shape used above. Checked arithmetically so no test ever asks
468
+ the allocator for 4 TiB."""
469
+ dense_bytes = BOMB_SHAPE[0] * BOMB_SHAPE[1] * 4
470
+ assert dense_bytes > envs.VLLM_MAX_EMBED_DECODE_BYTES > 0
471
+
472
+ def test_attack_payload_is_tiny(self):
473
+ """The payload really is small - this is amplification, not an upload."""
474
+ assert len(_encode_tensor(_create_oversized_sparse_tensor(BOMB_SHAPE))) < 4096
475
+
476
+ def test_valid_tensors_still_densify(self):
477
+ assert safe_to_dense(
478
+ _create_valid_dense_tensor(), parameter="prompt_embeds"
479
+ ).shape == (10, 768)
480
+
481
+ dense = safe_to_dense(_create_valid_sparse_tensor(), parameter="image_embeds")
482
+ assert dense.shape == (3, 3)
483
+ assert not dense.is_sparse
484
+
485
+ def test_limit_is_configurable(self, monkeypatch: pytest.MonkeyPatch):
486
+ # 32 x 32 float32 == 4096 bytes, exactly at the limit.
487
+ monkeypatch.setenv("VLLM_MAX_EMBED_DECODE_BYTES", str(TEST_LIMIT_BYTES))
488
+
489
+ at_limit = _create_oversized_sparse_tensor((32, 32))
490
+ assert safe_to_dense(at_limit, parameter="image_embeds").shape == (32, 32)
491
+
492
+ over_limit = _create_oversized_sparse_tensor((33, 32))
493
+ with pytest.raises(VLLMValidationError):
494
+ safe_to_dense(over_limit, parameter="image_embeds")
495
+
496
+ def test_limit_can_be_disabled(self, monkeypatch: pytest.MonkeyPatch):
497
+ monkeypatch.setenv("VLLM_MAX_EMBED_DECODE_BYTES", "0")
498
+ tensor = _create_oversized_sparse_tensor(OVERSIZED_SHAPE)
499
+
500
+ assert safe_to_dense(tensor, parameter="image_embeds").shape == OVERSIZED_SHAPE
501
+
502
+ @pytest.mark.parametrize("io_cls,parameter", EMBEDDING_MEDIA_IO)
503
+ def test_media_io_rejects_oversized_base64(
504
+ self, io_cls, parameter, small_decode_limit
505
+ ):
506
+ encoded = _encode_tensor(_create_oversized_sparse_tensor(OVERSIZED_SHAPE))
507
+
508
+ with pytest.raises(VLLMValidationError) as exc_info:
509
+ io_cls().load_base64("", encoded.decode("utf-8"))
510
+
511
+ assert parameter in str(exc_info.value)
512
+
513
+ @pytest.mark.parametrize("io_cls,parameter", EMBEDDING_MEDIA_IO)
514
+ def test_media_io_rejects_oversized_file(
515
+ self, io_cls, parameter, tmp_path, small_decode_limit
516
+ ):
517
+ path = tmp_path / "embeds.pt"
518
+ torch.save(_create_oversized_sparse_tensor(OVERSIZED_SHAPE), path)
519
+
520
+ with pytest.raises(VLLMValidationError):
521
+ io_cls().load_file(path)
522
+
523
+ @pytest.mark.parametrize("io_cls,parameter", EMBEDDING_MEDIA_IO)
524
+ def test_media_io_rejects_non_tensor_payload(self, io_cls, parameter):
525
+ """These three had no `isinstance` check, so a non-tensor payload
526
+ surfaced as an `AttributeError` (500) rather than a 4xx."""
527
+ buffer = io.BytesIO()
528
+ torch.save({"not": "a tensor"}, buffer)
529
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
530
+
531
+ with pytest.raises(VLLMValidationError) as exc_info:
532
+ io_cls().load_base64("", encoded)
533
+
534
+ assert "torch.Tensor" in str(exc_info.value)
535
+
536
+ @pytest.mark.parametrize("io_cls,parameter", EMBEDDING_MEDIA_IO)
537
+ def test_media_io_still_accepts_valid_embeddings(self, io_cls, parameter):
538
+ tensor = torch.randn(4, 16, dtype=torch.float32)
539
+ encoded = _encode_tensor(tensor)
540
+
541
+ assert torch.equal(io_cls().load_base64("", encoded.decode("utf-8")), tensor)
vllm/envs.py
+12
@@ -82,6 +82,7 @@ if TYPE_CHECKING:
82
VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600
83
VLLM_MAX_AUDIO_DECODE_BYTES: int = 268_435_456
84
VLLM_MAX_AUDIO_PREPROCESS_WORKERS: int = max(1, min(os.cpu_count() or 1, 2))
85
+ VLLM_MAX_EMBED_DECODE_BYTES: int = 2_147_483_648
86
VLLM_MAX_IMAGE_PIXELS: int = 178_956_970
87
VLLM_VIDEO_LOADER_BACKEND: str = "opencv"
88
VLLM_MEDIA_CONNECTOR: str = "http"
@@ -1011,6 +1012,16 @@ environment_variables: dict[str, Callable[[], Any]] = {
1012
"VLLM_MAX_AUDIO_DECODE_BYTES": lambda: int(
1013
os.getenv("VLLM_MAX_AUDIO_DECODE_BYTES", "268435456")
1014
),
1015
+ # Maximum bytes a client-supplied embedding payload (`prompt_embeds`,
1016
+ # `image_embeds`, `audio_embeds`, `video_embeds`) may allocate once it is
1017
+ # densified. A sparse tensor carries its own declared shape, so a payload
1018
+ # of a few hundred bytes can expand into hundreds of GiB. The limit is
1019
+ # checked before `to_dense()` so the memory is never allocated. Set to 0
1020
+ # to disable. Default is 2 GiB, which covers a 128K-token float32
1021
+ # embedding at hidden_size 4096.
1022
+ "VLLM_MAX_EMBED_DECODE_BYTES": lambda: int(
1023
+ os.getenv("VLLM_MAX_EMBED_DECODE_BYTES", "2147483648")
1024
+ ),
1025
# Maximum number of worker threads used for STT preprocessing. The default
1026
# intentionally caps at 2 because that performed best in profiling.
1027
# https://github.com/vllm-project/vllm/pull/44612#issuecomment-4662757781
@@ -2325,6 +2336,7 @@ def compile_factors() -> dict[str, object]:
2336
"VLLM_MAX_AUDIO_DECODE_DURATION_S",
2337
"VLLM_MAX_AUDIO_DECODE_BYTES",
2338
"VLLM_MAX_AUDIO_PREPROCESS_WORKERS",
2339
+ "VLLM_MAX_EMBED_DECODE_BYTES",
2340
"VLLM_MAX_IMAGE_PIXELS",
2341
"VLLM_VIDEO_LOADER_BACKEND",
2342
"VLLM_MEDIA_CONNECTOR",
vllm/multimodal/media/audio.py
+6
-3
@@ -16,7 +16,10 @@ from vllm.multimodal.audio import resample_audio_pyav
16
from vllm.utils.import_utils import PlaceholderModule
17
from vllm.utils.mem_constants import MiB_bytes
18
from vllm.utils.serial_utils import tensor2base64
19
-from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
19
+from vllm.utils.sparse_utils import (
20
+ check_sparse_tensor_invariants_threadsafe,
21
+ safe_to_dense,
22
+)
23
24
from .base import MediaIO
25
@@ -348,7 +351,7 @@ class AudioEmbeddingMediaIO(MediaIO[torch.Tensor]):
351
buffer = BytesIO(data)
352
with check_sparse_tensor_invariants_threadsafe():
353
tensor = torch.load(buffer, weights_only=True)
351
- return tensor.to_dense()
354
+ return safe_to_dense(tensor, parameter="audio_embeds")
355
356
def load_base64(self, media_type: str, data: str) -> torch.Tensor:
357
return self.load_bytes(pybase64.b64decode(data, validate=True))
@@ -356,7 +359,7 @@ class AudioEmbeddingMediaIO(MediaIO[torch.Tensor]):
359
def load_file(self, filepath: Path) -> torch.Tensor:
360
with check_sparse_tensor_invariants_threadsafe():
361
tensor = torch.load(filepath, weights_only=True)
359
- return tensor.to_dense()
362
+ return safe_to_dense(tensor, parameter="audio_embeds")
363
364
def encode_base64(self, media: torch.Tensor) -> str:
365
return tensor2base64(media)
vllm/multimodal/media/image.py
+6
-3
@@ -11,7 +11,10 @@ from PIL import Image
11
12
import vllm.envs as envs
13
from vllm.utils.serial_utils import tensor2base64
14
-from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
14
+from vllm.utils.sparse_utils import (
15
+ check_sparse_tensor_invariants_threadsafe,
16
+ safe_to_dense,
17
+)
18
19
from ..image import convert_image_mode, normalize_image, rgba_to_rgb
20
from .base import MediaIO, MediaWithBytes
@@ -135,7 +138,7 @@ class ImageEmbeddingMediaIO(MediaIO[torch.Tensor]):
138
buffer = BytesIO(data)
139
with check_sparse_tensor_invariants_threadsafe():
140
tensor = torch.load(buffer, weights_only=True)
138
- return tensor.to_dense()
141
+ return safe_to_dense(tensor, parameter="image_embeds")
142
143
def _load_numpy(self, data: bytes) -> torch.Tensor:
144
with BytesIO(data) as buffer:
@@ -156,7 +159,7 @@ class ImageEmbeddingMediaIO(MediaIO[torch.Tensor]):
159
160
with check_sparse_tensor_invariants_threadsafe():
161
tensor = torch.load(filepath, weights_only=True)
159
- return tensor.to_dense()
162
+ return safe_to_dense(tensor, parameter="image_embeds")
163
164
def encode_base64(self, media: torch.Tensor) -> str:
165
return tensor2base64(media)
vllm/multimodal/media/video.py
+6
-3
@@ -14,7 +14,10 @@ from PIL import Image
14
from vllm import envs
15
from vllm.logger import init_logger
16
from vllm.utils.serial_utils import tensor2base64
17
-from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
17
+from vllm.utils.sparse_utils import (
18
+ check_sparse_tensor_invariants_threadsafe,
19
+ safe_to_dense,
20
+)
21
22
from ..video import VIDEO_LOADER_REGISTRY
23
from .base import MediaIO, MediaWithBytes
@@ -215,7 +218,7 @@ class VideoEmbeddingMediaIO(MediaIO[torch.Tensor]):
218
buffer = BytesIO(data)
219
with check_sparse_tensor_invariants_threadsafe():
220
tensor = torch.load(buffer, weights_only=True)
218
- return tensor.to_dense()
221
+ return safe_to_dense(tensor, parameter="video_embeds")
222
223
def _load_numpy(self, data: bytes) -> torch.Tensor:
224
with BytesIO(data) as buffer:
@@ -236,7 +239,7 @@ class VideoEmbeddingMediaIO(MediaIO[torch.Tensor]):
239
240
with check_sparse_tensor_invariants_threadsafe():
241
tensor = torch.load(filepath, weights_only=True)
239
- return tensor.to_dense()
242
+ return safe_to_dense(tensor, parameter="video_embeds")
243
244
def encode_base64(self, media: torch.Tensor) -> str:
245
return tensor2base64(media)
vllm/renderers/embed_utils.py
+5
-7
@@ -8,7 +8,10 @@ import torch
8
9
from vllm.exceptions import VLLMValidationError
10
from vllm.utils.async_utils import make_async
11
-from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
11
+from vllm.utils.sparse_utils import (
12
+ check_sparse_tensor_invariants_threadsafe,
13
+ safe_to_dense,
14
+)
15
16
if TYPE_CHECKING:
17
from vllm.config import ModelConfig
@@ -30,12 +33,7 @@ def safe_load_prompt_embeds(
33
weights_only=True,
34
map_location=torch.device("cpu"),
35
)
33
- if not isinstance(tensor, torch.Tensor):
34
- raise VLLMValidationError(
35
- "`prompt_embeds` payload did not deserialize to a torch.Tensor.",
36
- parameter="prompt_embeds",
37
- )
38
- tensor = tensor.to_dense()
36
+ tensor = safe_to_dense(tensor, parameter="prompt_embeds")
37
38
if tensor.dim() > 2:
39
tensor = tensor.squeeze(0)
vllm/utils/sparse_utils.py
+47
-1
@@ -1,7 +1,11 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""
4
-Thread-safe wrapper for sparse tensor invariant validation.
4
+Safe deserialization helpers for client-supplied embedding tensors.
5
+
6
+`check_sparse_tensor_invariants_threadsafe()` is a thread-safe wrapper for
7
+sparse tensor invariant validation. `safe_to_dense()` bounds the memory a
8
+deserialized tensor may allocate when densified.
9
10
PyTorch's `torch.sparse.check_sparse_tensor_invariants()` context manager
11
manipulates a **process-global** flag (save/enable/restore). When multiple
@@ -18,6 +22,10 @@ import threading
22
23
import torch
24
25
+import vllm.envs as envs
26
+from vllm.exceptions import VLLMValidationError
27
+from vllm.utils.mem_constants import MiB_bytes
28
+
29
_SPARSE_LOAD_LOCK = threading.Lock()
30
31
@@ -25,3 +33,41 @@ _SPARSE_LOAD_LOCK = threading.Lock()
33
def check_sparse_tensor_invariants_threadsafe():
34
with _SPARSE_LOAD_LOCK, torch.sparse.check_sparse_tensor_invariants():
35
yield
36
+
37
+
38
+def safe_to_dense(tensor: object, *, parameter: str) -> torch.Tensor:
39
+ """Densify a client-supplied embedding tensor within a memory budget.
40
+
41
+ `torch.load(..., weights_only=True)` prevents arbitrary code execution but
42
+ not memory amplification: a sparse COO tensor carries its own declared
43
+ shape, so the dense allocation is bounded by that shape rather than by the
44
+ request. A 2.6 KB payload declaring `(30000, 30000)` materializes 3.4 GiB,
45
+ and the shape can be picked to fit whatever host is being targeted. The
46
+ invariant checks only reject *invalid* tensors (indices outside the
47
+ declared shape); a single non-zero element inside a `(2**20, 2**20)` shape
48
+ is perfectly valid and passes them.
49
+
50
+ So reject oversized payloads based on the dense size they declare, before
51
+ `to_dense()` allocates anything.
52
+ """
53
+ if not isinstance(tensor, torch.Tensor):
54
+ raise VLLMValidationError(
55
+ f"`{parameter}` payload did not deserialize to a torch.Tensor.",
56
+ parameter=parameter,
57
+ )
58
+
59
+ max_bytes = envs.VLLM_MAX_EMBED_DECODE_BYTES
60
+ if max_bytes > 0:
61
+ dense_bytes = tensor.numel() * tensor.element_size()
62
+ if dense_bytes > max_bytes:
63
+ raise VLLMValidationError(
64
+ f"`{parameter}` declares shape {tuple(tensor.shape)}, which "
65
+ f"would allocate {dense_bytes} bytes "
66
+ f"({dense_bytes / MiB_bytes:.0f} MiB) once densified, "
67
+ f"exceeding the {max_bytes} byte limit. Set "
68
+ f"VLLM_MAX_EMBED_DECODE_BYTES to increase this limit.",
69
+ parameter=parameter,
70
+ value=dense_bytes,
71
+ )
72
+
73
+ return tensor.to_dense()