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:
co-authored by
Cursor
parent
37fbf52084
commit
640a09086d
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user