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>
This commit is contained in:
Juan Pérez de Algaba
2026-08-10 15:49:17 +00:00
committed by GitHub
co-authored by Cursor
parent 37fbf52084
commit 640a09086d
5 changed files with 126 additions and 7 deletions
+1
View File
@@ -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
+70 -5
View File
@@ -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,
)
@@ -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
+10
View File
@@ -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",
+43 -2
View File
@@ -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(