From 640a09086d1b3c129c4e21f85acbf551eb17b4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20P=C3=A9rez=20de=20Algaba?= <124347725+jperezdealgaba@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:49:17 +0200 Subject: [PATCH] Fix DoS via sample-rate forgery bypassing audio decode duration guard (#49948) Signed-off-by: jperezde Co-authored-by: Cursor --- docs/usage/security.md | 1 + tests/multimodal/media/test_audio.py | 75 +++++++++++++++++-- .../speech_to_text/base/serving.py | 2 + vllm/envs.py | 10 +++ vllm/multimodal/media/audio.py | 45 ++++++++++- 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/docs/usage/security.md b/docs/usage/security.md index 7eb57c9aa2..68c05a6842 100644 --- a/docs/usage/security.md +++ b/docs/usage/security.md @@ -95,6 +95,7 @@ enforces decode-size limits to prevent out-of-memory denial of service: | `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). | | `VLLM_MAX_AUDIO_CLIP_FILESIZE_MB` | `25` | Maximum filesize in MB for a single audio file. | | `VLLM_MAX_AUDIO_DECODE_DURATION_S` | `600` | Maximum decoded audio duration in seconds. Prevents compressed audio from expanding into gigabytes of float32 PCM. | +| `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. | Setting any of these to `0` disables the corresponding limit. This is **not recommended** for deployments exposed to untrusted users, as it removes the diff --git a/tests/multimodal/media/test_audio.py b/tests/multimodal/media/test_audio.py index e894483505..ae355cc1c9 100644 --- a/tests/multimodal/media/test_audio.py +++ b/tests/multimodal/media/test_audio.py @@ -1,14 +1,16 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from io import BytesIO from pathlib import Path from unittest.mock import patch import numpy as np import pybase64 as base64 import pytest +import soundfile as sf from vllm.multimodal.media import AudioMediaIO -from vllm.multimodal.media.audio import load_audio +from vllm.multimodal.media.audio import load_audio, load_audio_soundfile from ...conftest import AudioTestAssets @@ -70,8 +72,6 @@ def test_audio_media_io_encode_base64(dummy_audio): def test_load_audio_max_duration_respected(dummy_audio_bytes): """Valid audio within the duration limit should load successfully.""" - from io import BytesIO - y, sr = load_audio(BytesIO(dummy_audio_bytes), sr=None, max_duration_s=3600) assert isinstance(y, np.ndarray) assert len(y) > 0 @@ -79,8 +79,6 @@ def test_load_audio_max_duration_respected(dummy_audio_bytes): def test_load_audio_max_duration_rejected(dummy_audio_bytes): """Audio exceeding the duration limit must be rejected during decode.""" - from io import BytesIO - with pytest.raises(ValueError, match="exceeds maximum allowed duration"): load_audio(BytesIO(dummy_audio_bytes), sr=None, max_duration_s=0.0001) @@ -93,3 +91,70 @@ def test_audio_media_io_from_video(video_assets): audio_ref, sr_ref = load_audio(video_path, sr=None) assert sr == sr_ref np.testing.assert_allclose(audio_ref, audio, atol=1e-4) + + +def _make_flac_bytes(frames: int, channels: int, samplerate: int) -> bytes: + """Create a minimal FLAC file in memory for testing.""" + data = np.zeros((frames, channels), dtype=np.int16) + buf = BytesIO() + sf.write(buf, data, samplerate, format="FLAC") + return buf.getvalue() + + +def test_small_file_passes_memory_guard(): + """A small valid file should pass both duration and memory guards.""" + payload = _make_flac_bytes(frames=16000, channels=1, samplerate=16000) + y, sr = load_audio_soundfile( + BytesIO(payload), + sr=None, + max_duration_s=600, + max_decode_bytes=256 * 1024 * 1024, + ) + assert isinstance(y, np.ndarray) + assert len(y) == 16000 + + +def test_memory_guard_rejects_large_allocation(): + """A file whose frames*channels*4 exceeds the byte limit must be + rejected before allocating the buffer.""" + # 100_000 frames * 8 channels * 4 bytes = 3.2 MB + payload = _make_flac_bytes(frames=100_000, channels=8, samplerate=48000) + # Set limit to 1 MiB — should reject + with pytest.raises(ValueError, match="VLLM_MAX_AUDIO_DECODE_BYTES"): + load_audio_soundfile( + BytesIO(payload), + sr=None, + max_duration_s=600, + max_decode_bytes=1 * 1024 * 1024, + ) + + +def test_forged_samplerate_rejected_by_memory_guard(): + """The PoC scenario: high sample rate fools the duration guard but + the memory guard catches the large frame*channel allocation.""" + # Forged high sample rate: 655350 Hz, 8 channels, 1M frames + # Duration guard sees: 1_000_000 / 655_350 = 1.5s → passes + # Memory: 1_000_000 * 8 * 4 = 32 MB + payload = _make_flac_bytes(frames=1_000_000, channels=8, samplerate=655350) + # Set memory limit to 16 MiB — below the 32 MB allocation + with pytest.raises(ValueError, match="VLLM_MAX_AUDIO_DECODE_BYTES"): + load_audio_soundfile( + BytesIO(payload), + sr=None, + max_duration_s=600, + max_decode_bytes=16 * 1024 * 1024, + ) + + +def test_load_audio_threads_max_decode_bytes(): + """Verify load_audio passes max_decode_bytes through to backend.""" + # 50_000 frames * 4 channels * 4 bytes = 800 KB + payload = _make_flac_bytes(frames=50_000, channels=4, samplerate=44100) + # Limit of 512 KB should reject + with pytest.raises(ValueError, match="VLLM_MAX_AUDIO_DECODE_BYTES"): + load_audio( + BytesIO(payload), + sr=None, + max_duration_s=600, + max_decode_bytes=512 * 1024, + ) diff --git a/vllm/entrypoints/speech_to_text/base/serving.py b/vllm/entrypoints/speech_to_text/base/serving.py index 8065c13d43..e7b864f3b7 100644 --- a/vllm/entrypoints/speech_to_text/base/serving.py +++ b/vllm/entrypoints/speech_to_text/base/serving.py @@ -126,6 +126,7 @@ class SpeechToTextBaseServing(GenerateBaseServing): self.max_audio_filesize_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB self.max_audio_decode_duration_s: int = envs.VLLM_MAX_AUDIO_DECODE_DURATION_S + self.max_audio_decode_bytes: int = envs.VLLM_MAX_AUDIO_DECODE_BYTES if self.model_cls.supports_segment_timestamp: self.tokenizer = cast( PreTrainedTokenizerBase, @@ -180,6 +181,7 @@ class SpeechToTextBaseServing(GenerateBaseServing): sr=self.asr_config.sample_rate, mono=True, max_duration_s=self.max_audio_decode_duration_s, + max_decode_bytes=self.max_audio_decode_bytes, ) except ValueError: raise diff --git a/vllm/envs.py b/vllm/envs.py index 3f23fb6152..1460fa1a89 100755 --- a/vllm/envs.py +++ b/vllm/envs.py @@ -77,6 +77,7 @@ if TYPE_CHECKING: VLLM_MEDIA_LOADING_THREAD_COUNT: int = 8 VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25 VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600 + VLLM_MAX_AUDIO_DECODE_BYTES: int = 268_435_456 VLLM_MAX_AUDIO_PREPROCESS_WORKERS: int = max(1, min(os.cpu_count() or 1, 2)) VLLM_MAX_IMAGE_PIXELS: int = 178_956_970 VLLM_VIDEO_LOADER_BACKEND: str = "opencv" @@ -986,6 +987,14 @@ environment_variables: dict[str, Callable[[], Any]] = { "VLLM_MAX_AUDIO_DECODE_DURATION_S": lambda: int( os.getenv("VLLM_MAX_AUDIO_DECODE_DURATION_S", "600") ), + # Maximum float32 PCM bytes that audio decoding may allocate. + # Guards against sample-rate forgery where an attacker inflates + # the header sample rate to bypass the duration guard while the + # actual frame count still causes a multi-GiB allocation. + # Default is 256 MiB (sufficient for 600s mono 48 kHz float32). + "VLLM_MAX_AUDIO_DECODE_BYTES": lambda: int( + os.getenv("VLLM_MAX_AUDIO_DECODE_BYTES", "268435456") + ), # Maximum number of worker threads used for STT preprocessing. The default # intentionally caps at 2 because that performed best in profiling. # https://github.com/vllm-project/vllm/pull/44612#issuecomment-4662757781 @@ -2206,6 +2215,7 @@ def compile_factors() -> dict[str, object]: "VLLM_MEDIA_LOADING_THREAD_COUNT", "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", "VLLM_MAX_AUDIO_DECODE_DURATION_S", + "VLLM_MAX_AUDIO_DECODE_BYTES", "VLLM_MAX_AUDIO_PREPROCESS_WORKERS", "VLLM_MAX_IMAGE_PIXELS", "VLLM_VIDEO_LOADER_BACKEND", diff --git a/vllm/multimodal/media/audio.py b/vllm/multimodal/media/audio.py index 205f2d6280..42dcff8e3e 100644 --- a/vllm/multimodal/media/audio.py +++ b/vllm/multimodal/media/audio.py @@ -13,6 +13,7 @@ import vllm.envs as envs from vllm.logger import init_logger from vllm.multimodal.audio import resample_audio_pyav from vllm.utils.import_utils import PlaceholderModule +from vllm.utils.mem_constants import MiB_bytes from vllm.utils.serial_utils import tensor2base64 from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe @@ -50,6 +51,7 @@ def load_audio_pyav( sr: float | None = 22050, mono: bool = True, max_duration_s: float | None = None, + max_decode_bytes: int | None = None, ) -> tuple[npt.NDArray, float]: """Load an audio file using PyAV (FFmpeg), returning float32 mono waveform. @@ -103,6 +105,7 @@ def load_audio_pyav( int(sr * max_duration_s) if max_duration_s is not None else None ) total_samples = 0 + total_decode_bytes = 0 chunks: list[npt.NDArray] = [] needs_resampling = not math.isclose( @@ -122,10 +125,12 @@ def load_audio_pyav( for out_frame in resampler.resample(frame): arr = out_frame.to_ndarray() total_samples += arr.shape[-1] + total_decode_bytes += arr.nbytes chunks.append(arr) else: arr = frame.to_ndarray() total_samples += arr.shape[-1] + total_decode_bytes += arr.nbytes chunks.append(arr) if max_samples is not None and total_samples > max_samples: @@ -136,6 +141,18 @@ def load_audio_pyav( f"VLLM_MAX_AUDIO_DECODE_DURATION_S to " f"increase this limit." ) + if ( + max_decode_bytes is not None + and total_decode_bytes > max_decode_bytes + ): + raise ValueError( + f"Audio decode exceeded " + f"{max_decode_bytes / MiB_bytes:.0f} MiB memory " + f"limit ({total_decode_bytes / MiB_bytes:.0f} MiB " + f"decoded so far). Set " + f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this " + f"limit." + ) except (ValueError, ImportError): raise except Exception as e: @@ -160,6 +177,7 @@ def load_audio_soundfile( sr: float | None = 22050, mono: bool = True, max_duration_s: float | None = None, + max_decode_bytes: int | None = None, ) -> tuple[np.ndarray, int]: """Load audio via soundfile""" with soundfile.SoundFile(path) as f: @@ -174,6 +192,16 @@ def load_audio_soundfile( f"VLLM_MAX_AUDIO_DECODE_DURATION_S to " f"increase this limit." ) + if max_decode_bytes is not None: + estimated_bytes = f.frames * f.channels * np.dtype(np.float32).itemsize + if estimated_bytes > max_decode_bytes: + raise ValueError( + f"Audio would allocate {estimated_bytes / MiB_bytes:.0f} " + f"MiB of PCM ({f.frames} frames x {f.channels} channels" + f" x 4B), exceeding the " + f"{max_decode_bytes / MiB_bytes:.0f} MiB limit. Set " + f"VLLM_MAX_AUDIO_DECODE_BYTES to increase this limit." + ) y = f.read(dtype="float32", always_2d=False).T if mono and y.ndim > 1: @@ -191,10 +219,15 @@ def load_audio( sr: float | None = 22050, mono: bool = True, max_duration_s: float | None = None, + max_decode_bytes: int | None = None, ): try: return load_audio_soundfile( - path, sr=sr, mono=mono, max_duration_s=max_duration_s + path, + sr=sr, + mono=mono, + max_duration_s=max_duration_s, + max_decode_bytes=max_decode_bytes, ) except ImportError as exc: # soundfile (or resampy) is not installed — fall through to pyav. @@ -212,7 +245,13 @@ def load_audio( if isinstance(path, BytesIO): path.seek(0) try: - return load_audio_pyav(path, sr=sr, mono=mono, max_duration_s=max_duration_s) + return load_audio_pyav( + path, + sr=sr, + mono=mono, + max_duration_s=max_duration_s, + max_decode_bytes=max_decode_bytes, + ) except ImportError: raise # Let PlaceholderModule's message ("install vllm[audio]") propagate. except Exception as pyav_exc: @@ -241,6 +280,7 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]): BytesIO(data), sr=None, max_duration_s=envs.VLLM_MAX_AUDIO_DECODE_DURATION_S, + max_decode_bytes=envs.VLLM_MAX_AUDIO_DECODE_BYTES, ) def load_base64( @@ -255,6 +295,7 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]): filepath, sr=None, max_duration_s=envs.VLLM_MAX_AUDIO_DECODE_DURATION_S, + max_decode_bytes=envs.VLLM_MAX_AUDIO_DECODE_BYTES, ) def encode_base64(