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(