Prhub

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

原始 PR 作者 jperezdealgaba 合并时间 2026-08-10 23:49 文件变更 5 提交数 3 评论 11 代码增减 +126 / -7

执行摘要

音频解码新增字节上限,封堵采样率伪造 OOM DoS

PR body 明确指出现有 max_duration_s 守卫的可利用性:"An attacker can set samplerate=655350 (FLAC max) with 8 channels to make hundreds of millions of frames appear as a short clip, bypassing the duration check while f.read() allocates up to 11.7 GiB of float32 PCM — enough to OOM-kill the API server." 修复目标是在时长守卫之外增加一个独立于采样率的字节预算兜底:"Both guards (duration + bytes) are independently useful: duration catches legitimately long files early; bytes catches sample-rate forgery regardless of claimed duration." 社区成员 TobyB1702 在 issue 评论中实测复现了漏洞(31 MiB PCM 分配被新守卫精确拦截),并指出了 security.md 缺少该环境变量的文档缺口。

值得精读。这是一个小而完整的安全修复样本:问题定位清晰(时长守卫信任容器头)、修复设计巧妙(引入与采样率解耦的独立字节守卫,两道防线互补)、测试覆盖到位(直接构造 PoC 场景验证),且 review 闭环质量高(导入整理、去掉测试类、环境变量缓存、常量复用等意见均被采纳)。建议关注 load_audio_soundfile 的预读估算与 load_audio_pyav 的增量累计这两条路径的差异,以及后续同类资源守卫(图片、视频)如何复用同一模式。

讨论亮点

评审中存在四轮有实质内容的讨论,全部已解决:

  • 测试导入整理:DarkLight1337 要求 "Please clean up the PR by moving imports to top level",作者随后将 iosoundfile 等导入提升到模块顶层。

  • 测试类结构去留:NickLucche 对 TestMaxDecodeBytes 分组提出意见:"can we drop the test class? I am not a fan of the grouping pattern claude uses and class is just overhead here",最终作者拆掉了测试类,改为顶层辅助函数加独立测试函数,代码更简洁,也与仓库现有测试风格一致。

  • 环境变量缓存:NickLucche 指出 envs.VLLM_MAX_AUDIO_DECODE_BYTES 是运行时求值的 lambda,"probably worth to just cache it once in self in init",作者在 serving.py__init__ 中缓存到 self.max_audio_decode_bytes

  • 常量复用:DarkLight1337 建议错误信息中的 MiB 换算改用 MiB_bytes 常量而非手写 1024**2,已按意见修改。

此外 TobyB1702 在 issue 评论中完整复现了漏洞与新守卫的拦截行为,并推动 security.md 补文档。

实现拆解

实现分五步完成:

  1. 注册环境变量(vllm/envs.py:在 VLLMEnvironment 数据类中新增字段 VLLM_MAX_AUDIO_DECODE_BYTES: int = 268_435_456(256 MiB),在 environment_variables 字典中注册解析 lambda 和设计注释(说明默认值可覆盖 600 秒单声道 48 kHz float32),并加入 compile_factors 的变量列表,保证多进程 worker 通过环境变量传播。

  2. 实现字节守卫核心(vllm/multimodal/media/audio.py:给 load_audio_soundfileload_audio_pyav 增加 max_decode_bytes 参数。soundfile 路径在 f.read() 之前用 f.frames * f.channels * np.dtype(np.float32).itemsize 估算 PCM 大小并提前拒绝;PyAV 路径无法预知帧数,因此在解码循环中用 total_decode_bytes += arr.nbytes 增量累计,每帧解码后检查阈值。load_audio 的两个回退分支均透传该参数,MediaIO.load_bytes / load_file 则直接从 envs.VLLM_MAX_AUDIO_DECODE_BYTES 取值。

  3. STT 服务接入(vllm/entrypoints/speech_to_text/base/serving.py:在服务 __init__ 中缓存 self.max_audio_decode_bytes = envs.VLLM_MAX_AUDIO_DECODE_BYTES(按 review 意见避免每次请求重复求值环境 lambda),并在 _decode_and_chunk_speech 中传给 load_audio

  4. 回归测试(tests/multimodal/media/test_audio.py:新增 _make_flac_bytes 内联构造最小 FLAC 文件,覆盖四个场景:小文件同时通过时长与字节守卫、frames * channels * 4 超过限制时拒绝、PoC 伪造采样率场景(655350 Hz、8 声道、100 万帧,时长守卫误判为 1.5 秒但字节守卫拒绝)、以及 load_audio 向后端透传 max_decode_bytes

  5. 文档配套(docs/usage/security.md:在安全配置表中新增 VLLM_MAX_AUDIO_DECODE_BYTES 条目,说明其防采样率伪造的作用,并保留"设为 0 禁用限制"的警告文案。

文件 模块 状态 重要度
vllm/multimodal/media/audio.py 音频解码 modified 7.15
tests/multimodal/media/test_audio.py 音频测试 modified 7.11
vllm/envs.py 环境变量 modified 5.34
vllm/entrypoints/speech_to_text/base/serving.py 语音服务 modified 4.49
docs/usage/security.md 安全文档 modified 1.18

关键符号

load_audio_soundfile load_audio_pyav load_audio AudioMediaIO.load_bytes AudioMediaIO.load_file _decode_and_chunk_speech _make_flac_bytes test_forged_samplerate_rejected_by_memory_guard

关键源码片段

vllm/multimodal/media/audio.py core-logic

修复核心所在:为 soundfile 与 PyAV 两条解码路径分别增加与采样率解耦的字节预算守卫,并让 `load_audio` 与 `MediaIO` 透传新参数。

# 关键修复单元 1:soundfile 后端预读字节守卫
# 原理:在 f.read() 真正分配内存之前,用容器头部的 frames * channels * 4
# 估算 float32 PCM 大小。该估算不依赖采样率,因此伪造高采样率无法绕过。
def load_audio_soundfile(
    path: BytesIO | Path | str,
    *,
    sr: float | None = 22050,
    mono: bool = True,
    max_duration_s: float | None = None,
    max_decode_bytes: int | None = None,
) -> tuple[np.ndarray, int]:
    """加载音频(soundfile 后端)"""
    with soundfile.SoundFile(path) as f:
        native_sr = f.samplerate
        # 时长守卫:f.frames / native_sr,信任容器头,可被伪造采样率绕过
        if max_duration_s is not None:
            file_duration_s = f.frames / native_sr
            if file_duration_s > max_duration_s:
                raise ValueError(
                    f"Audio exceeds maximum allowed duration of "
                    f"{max_duration_s}s (file contains "
                    f"{file_duration_s:.1f}s at {native_sr}Hz). Set "
                    f"VLLM_MAX_AUDIO_DECODE_DURATION_S to "
                    f"increase this limit."
                )
​
        # 字节守卫:独立于采样率,按 frames * channels * 4B 提前估算
        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:
        y = np.mean(y, axis=tuple(range(y.ndim - 1)))
​
    if sr is not None and sr != native_sr:
        y = resample_audio_pyav(y, orig_sr=native_sr, target_sr=sr)
        return y, int(sr)
    return y, native_sr
# 关键修复单元 2:PyAV 后端的解码中增量字节计数
# 原理:PyAV 无法预知帧数,因此在 decode 循环里用 arr.nbytes 累计实际
# 解码产生的字节数,每帧解码后检查是否超过 max_decode_bytes。
# 累计的是中间解码数组(含重采样结果),口径偏保守但安全侧有利。
    total_samples = 0
    total_decode_bytes = 0 # 字节预算累计器,不信任容器头部信息
​
    for frame in container.decode(stream):
        if needs_resampling:
            assert resampler is not None
            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:
            raise ValueError(
                f"Audio exceeds maximum allowed duration of "
                f"{max_duration_s}s (decoded {total_samples} "
                f"samples at {sr}Hz). Set "
                f"VLLM_MAX_AUDIO_DECODE_DURATION_S to 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 limit."
            )
tests/multimodal/media/test_audio.py test-coverage

新增 4 个回归测试并直接构造 PoC 场景(伪造采样率 655350 Hz、8 声道),验证字节守卫在时长守卫被绕过时仍能拒绝分配。

def _make_flac_bytes(frames: int, channels: int, samplerate: int) -> bytes:
    """在内存中构造一个最小 FLAC 文件,避免依赖真实音频资源。"""
    data = np.zeros((frames, channels), dtype=np.int16)
    buf = BytesIO()
    sf.write(buf, data, samplerate, format="FLAC")
    return buf.getvalue()
​
​
def test_forged_samplerate_rejected_by_memory_guard():
    """核心 PoC:高采样率绕过时长守卫,但字节守卫必须兜底拒绝。"""
    # 伪造采样率 655350 Hz(FLAC 上限)+ 8 声道 + 100 万帧:
    # 时长守卫看到 1_000_000 / 655_350 ≈ 1.5 s,会放行;
    # 而真实 PCM 分配需要 1_000_000 * 8 * 4 = 32 MB,超过 16 MiB 限制。
    payload = _make_flac_bytes(frames=1_000_000, channels=8, samplerate=655350)
    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():
    """验证 load_audio 能把 max_decode_bytes 透传到后端解码器。"""
    # 50_000 帧 * 4 声道 * 4 字节 = 800 KB,限制 512 KB 应被拒绝
    payload = _make_flac_bytes(frames=50_000, channels=4, samplerate=44100)
    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,
        )

评论区精华

测试导入整理到模块顶层 style

DarkLight1337 在测试文件 diff 上要求 "Please clean up the PR by moving imports to top level",针对测试类内部 `import io`、`import soundfile as sf` 的局部导入写法。

结论:作者回复 "Pushed" 后,`BytesIO`、`soundfile` 等导入被提升到文件顶部,与仓库 pytest 测试风格保持一致。 · 已解决

测试类分组模式是否多余 设计

NickLucche 对 `TestMaxDecodeBytes` 测试类提出意见:"can we drop the test class? I am not a fan of the grouping pattern claude uses and class is just overhead here",认为测试类分组在本仓库中没有必要。

结论:最终 head 版本中测试类被移除,改为顶层辅助函数 `_make_flac_bytes` 加独立测试函数,验收了该设计意见。 · 已解决

环境变量求值应在 __init__ 缓存 性能

NickLucche 指出 `envs.VLLM_MAX_AUDIO_DECODE_BYTES` 是运行时求值的 lambda,";probably worth to just cache it once in self in __init__",避免每次请求重复求值。

结论:作者确认 "This should also be solved",在 `serving.py` 的 `__init__` 中缓存为 `self.max_audio_decode_bytes`。 · 已解决

MiB 换算复用 MiB_bytes 常量 style

DarkLight1337 在 `audio.py` 错误信息中对 `1024**2` 提出 "Use `MiB_bytes`",要求复用 `vllm.utils.mem_constants` 中的现有常量。

结论:已修改为 `from vllm.utils.mem_constants import MiB_bytes`,错误信息统一用该常量换算。 · 已解决

VLLM_MAX_AUDIO_DECODE_BYTES 未写入 security.md documentation

TobyB1702 在 issue 评论中验证漏洞修复后指出:"security.md doesn't have VLLM_MAX_AUDIO_DECODE_BYTES, would follow the same pattern as VLLM_MAX...",要求补充安全配置文档。

结论:作者回应已将该参数加入 `docs/usage/security.md` 的安全配置表,并说明安全通告因尚未发布而未链接到 PR。 · 已解决

风险与影响

主要风险点如下:

  • 合法音频回归:默认 256 MiB 上限对 600 秒单声道 48 kHz float32(约 115 MB)是足够的,但多声道或更高采样率的合法长音频可能被新限制拒绝。文档已说明可通过 VLLM_MAX_AUDIO_DECODE_BYTES 调高,且设 0 可禁用(不推荐)。

  • PyAV 计数口径偏保守load_audio_pyavtotal_decode_bytes 按解码帧 arr.nbytes 累计,重采样路径统计的是中间 fltp 数组而非最终 float32 mono 数组,可能提前触发阈值。这偏向安全侧,但调用方需理解该语义差异。

  • 环境变量传播VLLM_MAX_AUDIO_DECODE_BYTES 已加入 compile_factors 列表,多进程场景下可正常传播;若部署环境遗漏,默认值仍生效。

  • 守卫覆盖范围:字节守卫只覆盖 AudioMediaIO 与 STT 服务入口,其他直接调用 load_audio_pyav 的路径若不显式传 max_decode_bytes 则无保护,需团队后续排查是否还有未接入的调用点。

影响范围集中在多模态音频输入链路:所有通过 OpenAI STT 服务或 AudioMediaIO 加载音频的用户默认获得 256 MiB 的 PCM 解码上限,攻击者无法再通过伪造容器头采样率触发大规模内存分配。对正常单声道语音文件(Whisper 等模型常用 16 kHz)影响可忽略;对多声道高采样率素材,部署方需按需调高环境变量。从团队视角,该 PR 与既有的 VLLM_MAX_IMAGE_PIXELSVLLM_MAX_AUDIO_CLIP_FILESIZE_MBVLLM_MAX_AUDIO_DECODE_DURATION_S 共同构成多模态输入的"解码预算"防线,完善了安全配置矩阵,也为此类漏洞提供了可复用的纵深防御模式。

安全修复(DoS) 默认限制 256 MiB 跨 soundfile/PyAV 双后端 独立字节守卫兜底 文档已同步

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论