Fix DoS via sample-rate forgery bypassing audio decode duration guard (#49948)

Signed-off-by: jperezde <jperezde@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>

Juan Pérez de Algaba committed Aug 10, 2026 at 17:49 UTC 640a09086d1b3c129c4e21f85acbf551eb17b4d1
5 files changed +126 -7
docs/usage/security.md
+1
@@ -95,6 +95,7 @@ enforces decode-size limits to prevent out-of-memory denial of service:
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 +| `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. |
99
100 Setting any of these to `0` disables the corresponding limit. This is **not
101 recommended** for deployments exposed to untrusted users, as it removes the
tests/multimodal/media/test_audio.py
+70 -5
@@ -1,14 +1,16 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from io import BytesIO
4 from pathlib import Path
5 from unittest.mock import patch
6
7 import numpy as np
8 import pybase64 as base64
9 import pytest
10 +import soundfile as sf
11
12 from vllm.multimodal.media import AudioMediaIO
11 -from vllm.multimodal.media.audio import load_audio
13 +from vllm.multimodal.media.audio import load_audio, load_audio_soundfile
14
15 from ...conftest import AudioTestAssets
16
@@ -70,8 +72,6 @@ def test_audio_media_io_encode_base64(dummy_audio):
72
73 def test_load_audio_max_duration_respected(dummy_audio_bytes):
74 """Valid audio within the duration limit should load successfully."""
73 - from io import BytesIO
74 -
75 y, sr = load_audio(BytesIO(dummy_audio_bytes), sr=None, max_duration_s=3600)
76 assert isinstance(y, np.ndarray)
77 assert len(y) > 0
@@ -79,8 +79,6 @@ def test_load_audio_max_duration_respected(dummy_audio_bytes):
79
80 def test_load_audio_max_duration_rejected(dummy_audio_bytes):
81 """Audio exceeding the duration limit must be rejected during decode."""
82 - from io import BytesIO
83 -
82 with pytest.raises(ValueError, match="exceeds maximum allowed duration"):
83 load_audio(BytesIO(dummy_audio_bytes), sr=None, max_duration_s=0.0001)
84
@@ -93,3 +91,70 @@ def test_audio_media_io_from_video(video_assets):
91 audio_ref, sr_ref = load_audio(video_path, sr=None)
92 assert sr == sr_ref
93 np.testing.assert_allclose(audio_ref, audio, atol=1e-4)
94 +
95 +
96 +def _make_flac_bytes(frames: int, channels: int, samplerate: int) -> bytes:
97 + """Create a minimal FLAC file in memory for testing."""
98 + data = np.zeros((frames, channels), dtype=np.int16)
99 + buf = BytesIO()
100 + sf.write(buf, data, samplerate, format="FLAC")
101 + return buf.getvalue()
102 +
103 +
104 +def test_small_file_passes_memory_guard():
105 + """A small valid file should pass both duration and memory guards."""
106 + payload = _make_flac_bytes(frames=16000, channels=1, samplerate=16000)
107 + y, sr = load_audio_soundfile(
108 + BytesIO(payload),
109 + sr=None,
110 + max_duration_s=600,
111 + max_decode_bytes=256 * 1024 * 1024,
112 + )
113 + assert isinstance(y, np.ndarray)
114 + assert len(y) == 16000
115 +
116 +
117 +def test_memory_guard_rejects_large_allocation():
118 + """A file whose frames*channels*4 exceeds the byte limit must be
119 + rejected before allocating the buffer."""
120 + # 100_000 frames * 8 channels * 4 bytes = 3.2 MB
121 + payload = _make_flac_bytes(frames=100_000, channels=8, samplerate=48000)
122 + # Set limit to 1 MiB — should reject
123 + with pytest.raises(ValueError, match="VLLM_MAX_AUDIO_DECODE_BYTES"):
124 + load_audio_soundfile(
125 + BytesIO(payload),
126 + sr=None,
127 + max_duration_s=600,
128 + max_decode_bytes=1 * 1024 * 1024,
129 + )
130 +
131 +
132 +def test_forged_samplerate_rejected_by_memory_guard():
133 + """The PoC scenario: high sample rate fools the duration guard but
134 + the memory guard catches the large frame*channel allocation."""
135 + # Forged high sample rate: 655350 Hz, 8 channels, 1M frames
136 + # Duration guard sees: 1_000_000 / 655_350 = 1.5s → passes
137 + # Memory: 1_000_000 * 8 * 4 = 32 MB
138 + payload = _make_flac_bytes(frames=1_000_000, channels=8, samplerate=655350)
139 + # Set memory limit to 16 MiB — below the 32 MB allocation
140 + with pytest.raises(ValueError, match="VLLM_MAX_AUDIO_DECODE_BYTES"):
141 + load_audio_soundfile(
142 + BytesIO(payload),
143 + sr=None,
144 + max_duration_s=600,
145 + max_decode_bytes=16 * 1024 * 1024,
146 + )
147 +
148 +
149 +def test_load_audio_threads_max_decode_bytes():
150 + """Verify load_audio passes max_decode_bytes through to backend."""
151 + # 50_000 frames * 4 channels * 4 bytes = 800 KB
152 + payload = _make_flac_bytes(frames=50_000, channels=4, samplerate=44100)
153 + # Limit of 512 KB should reject
154 + with pytest.raises(ValueError, match="VLLM_MAX_AUDIO_DECODE_BYTES"):
155 + load_audio(
156 + BytesIO(payload),
157 + sr=None,
158 + max_duration_s=600,
159 + max_decode_bytes=512 * 1024,
160 + )
vllm/entrypoints/speech_to_text/base/serving.py
+2
@@ -126,6 +126,7 @@ class SpeechToTextBaseServing(GenerateBaseServing):
126
127 self.max_audio_filesize_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB
128 self.max_audio_decode_duration_s: int = envs.VLLM_MAX_AUDIO_DECODE_DURATION_S
129 + self.max_audio_decode_bytes: int = envs.VLLM_MAX_AUDIO_DECODE_BYTES
130 if self.model_cls.supports_segment_timestamp:
131 self.tokenizer = cast(
132 PreTrainedTokenizerBase,
@@ -180,6 +181,7 @@ class SpeechToTextBaseServing(GenerateBaseServing):
181 sr=self.asr_config.sample_rate,
182 mono=True,
183 max_duration_s=self.max_audio_decode_duration_s,
184 + max_decode_bytes=self.max_audio_decode_bytes,
185 )
186 except ValueError:
187 raise
vllm/envs.py
+10
@@ -77,6 +77,7 @@ if TYPE_CHECKING:
77 VLLM_MEDIA_LOADING_THREAD_COUNT: int = 8
78 VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25
79 VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600
80 + VLLM_MAX_AUDIO_DECODE_BYTES: int = 268_435_456
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"
@@ -986,6 +987,14 @@ environment_variables: dict[str, Callable[[], Any]] = {
987 "VLLM_MAX_AUDIO_DECODE_DURATION_S": lambda: int(
988 os.getenv("VLLM_MAX_AUDIO_DECODE_DURATION_S", "600")
989 ),
990 + # Maximum float32 PCM bytes that audio decoding may allocate.
991 + # Guards against sample-rate forgery where an attacker inflates
992 + # the header sample rate to bypass the duration guard while the
993 + # actual frame count still causes a multi-GiB allocation.
994 + # Default is 256 MiB (sufficient for 600s mono 48 kHz float32).
995 + "VLLM_MAX_AUDIO_DECODE_BYTES": lambda: int(
996 + os.getenv("VLLM_MAX_AUDIO_DECODE_BYTES", "268435456")
997 + ),
998 # Maximum number of worker threads used for STT preprocessing. The default
999 # intentionally caps at 2 because that performed best in profiling.
1000 # https://github.com/vllm-project/vllm/pull/44612#issuecomment-4662757781
@@ -2206,6 +2215,7 @@ def compile_factors() -> dict[str, object]:
2215 "VLLM_MEDIA_LOADING_THREAD_COUNT",
2216 "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB",
2217 "VLLM_MAX_AUDIO_DECODE_DURATION_S",
2218 + "VLLM_MAX_AUDIO_DECODE_BYTES",
2219 "VLLM_MAX_AUDIO_PREPROCESS_WORKERS",
2220 "VLLM_MAX_IMAGE_PIXELS",
2221 "VLLM_VIDEO_LOADER_BACKEND",
vllm/multimodal/media/audio.py
+43 -2
@@ -13,6 +13,7 @@ import vllm.envs as envs
13 from vllm.logger import init_logger
14 from vllm.multimodal.audio import resample_audio_pyav
15 from vllm.utils.import_utils import PlaceholderModule
16 +from vllm.utils.mem_constants import MiB_bytes
17 from vllm.utils.serial_utils import tensor2base64
18 from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
19
@@ -50,6 +51,7 @@ def load_audio_pyav(
51 sr: float | None = 22050,
52 mono: bool = True,
53 max_duration_s: float | None = None,
54 + max_decode_bytes: int | None = None,
55 ) -> tuple[npt.NDArray, float]:
56 """Load an audio file using PyAV (FFmpeg), returning float32 mono waveform.
57
@@ -103,6 +105,7 @@ def load_audio_pyav(
105 int(sr * max_duration_s) if max_duration_s is not None else None
106 )
107 total_samples = 0
108 + total_decode_bytes = 0
109
110 chunks: list[npt.NDArray] = []
111 needs_resampling = not math.isclose(
@@ -122,10 +125,12 @@ def load_audio_pyav(
125 for out_frame in resampler.resample(frame):
126 arr = out_frame.to_ndarray()
127 total_samples += arr.shape[-1]
128 + total_decode_bytes += arr.nbytes
129 chunks.append(arr)
130 else:
131 arr = frame.to_ndarray()
132 total_samples += arr.shape[-1]
133 + total_decode_bytes += arr.nbytes
134 chunks.append(arr)
135
136 if max_samples is not None and total_samples > max_samples:
@@ -136,6 +141,18 @@ def load_audio_pyav(
141 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
142 f"increase this limit."
143 )
144 + if (
145 + max_decode_bytes is not None
146 + and total_decode_bytes > max_decode_bytes
147 + ):
148 + raise ValueError(
149 + f"Audio decode exceeded "
150 + f"{max_decode_bytes / MiB_bytes:.0f} MiB memory "
151 + f"limit ({total_decode_bytes / MiB_bytes:.0f} MiB "
152 + f"decoded so far). Set "
153 + f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this "
154 + f"limit."
155 + )
156 except (ValueError, ImportError):
157 raise
158 except Exception as e:
@@ -160,6 +177,7 @@ def load_audio_soundfile(
177 sr: float | None = 22050,
178 mono: bool = True,
179 max_duration_s: float | None = None,
180 + max_decode_bytes: int | None = None,
181 ) -> tuple[np.ndarray, int]:
182 """Load audio via soundfile"""
183 with soundfile.SoundFile(path) as f:
@@ -174,6 +192,16 @@ def load_audio_soundfile(
192 f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
193 f"increase this limit."
194 )
195 + if max_decode_bytes is not None:
196 + estimated_bytes = f.frames * f.channels * np.dtype(np.float32).itemsize
197 + if estimated_bytes > max_decode_bytes:
198 + raise ValueError(
199 + f"Audio would allocate {estimated_bytes / MiB_bytes:.0f} "
200 + f"MiB of PCM ({f.frames} frames x {f.channels} channels"
201 + f" x 4B), exceeding the "
202 + f"{max_decode_bytes / MiB_bytes:.0f} MiB limit. Set "
203 + f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this limit."
204 + )
205 y = f.read(dtype="float32", always_2d=False).T
206
207 if mono and y.ndim > 1:
@@ -191,10 +219,15 @@ def load_audio(
219 sr: float | None = 22050,
220 mono: bool = True,
221 max_duration_s: float | None = None,
222 + max_decode_bytes: int | None = None,
223 ):
224 try:
225 return load_audio_soundfile(
197 - path, sr=sr, mono=mono, max_duration_s=max_duration_s
226 + path,
227 + sr=sr,
228 + mono=mono,
229 + max_duration_s=max_duration_s,
230 + max_decode_bytes=max_decode_bytes,
231 )
232 except ImportError as exc:
233 # soundfile (or resampy) is not installed — fall through to pyav.
@@ -212,7 +245,13 @@ def load_audio(
245 if isinstance(path, BytesIO):
246 path.seek(0)
247 try:
215 - return load_audio_pyav(path, sr=sr, mono=mono, max_duration_s=max_duration_s)
248 + return load_audio_pyav(
249 + path,
250 + sr=sr,
251 + mono=mono,
252 + max_duration_s=max_duration_s,
253 + max_decode_bytes=max_decode_bytes,
254 + )
255 except ImportError:
256 raise # Let PlaceholderModule's message ("install vllm[audio]") propagate.
257 except Exception as pyav_exc:
@@ -241,6 +280,7 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
280 BytesIO(data),
281 sr=None,
282 max_duration_s=envs.VLLM_MAX_AUDIO_DECODE_DURATION_S,
283 + max_decode_bytes=envs.VLLM_MAX_AUDIO_DECODE_BYTES,
284 )
285
286 def load_base64(
@@ -255,6 +295,7 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
295 filepath,
296 sr=None,
297 max_duration_s=envs.VLLM_MAX_AUDIO_DECODE_DURATION_S,
298 + max_decode_bytes=envs.VLLM_MAX_AUDIO_DECODE_BYTES,
299 )
300
301 def encode_base64(