用高级模糊测试武装 SRTP 安全:SRS 仓库中 libsrtp-2-fit Fuzzer 的构建、原理与实战
【免费下载链接】srsSRS is a simple, high-performance, AI-driven real-time media server supporting RTMP, WebRTC, HLS, HTTP-FLV, HTTP-TS, SRT, MPEG-DASH, and GB28181, with codec support for H.264, H.265, AV1, VP9, AAC, Opus, and G.711.项目地址: https://gitcode.com/GitHub_Trending/sr/srs
本文深入剖析 SRS 仓库内
trunk/3rdparty/libsrtp-2-fit/fuzzer/下这套为 libSRTP 量身打造的高级模糊测试器:它如何用可移植 PRNG 保证语料可复现、如何用自定义分配器模拟内存耗尽与零尺寸分配、如何利用 32 位地址空间的极端地址检测指针算术溢出,以及如何借助 ASan/MSan 验证输出缓冲区合法性。读完本文,你既能亲手编译运行srtp-fuzzer,也能理解这些可复用的模糊测试技巧背后的工程考量。
一、背景:libSRTP 在 SRS 中的角色
SRS(Simple Realtime Server)是一款高性能实时媒体服务器,WebRTC 是其核心能力之一。WebRTC 的媒体面安全依赖 SRTP(Secure Real-time Transport Protocol)对 RTP/RTCP 报文进行加密与完整性保护,而 SRS 使用的正是仓库内定制的 libsrtp 2.x 版本(trunk/3rdparty/libsrtp-2-fit/)。从 SRS WebRTC 数据面代码 可以看到,SRS 在 DTLS 握手完成后通过srtp_init()、srtp_create()建立 SRTP 会话,并在收发方向分别调用srtp_protect()/srtp_protect_rtcp()与srtp_unprotect()/srtp_unprotect_rtcp()完成加解密(见 srs_app_rtc_dtls.cpp)。
库的健壮性直接关系到生产环境的媒体面安全,因此 libsrtp-2-fit/fuzzer 中这套由 Guido Vranken 编写的高级模糊测试器,正是保障该库(以及上游 libSRTP)安全性的重要工具。它不止是简单的"往接口里灌随机字节",而是实现了几项在常规 fuzzer 中难得一见的特殊技术,原文作者也鼓励将这些思路移植到各自项目的 fuzzer 中。
在 SRS 的构建体系中,libsrtp-2-fit 通过 trunk/auto/depends.sh 自动复制、打补丁(如3rdparty/patches/srtp/gcc10-01.patch)并编译安装,而 fuzzer 目录则作为库的安全测试资产随仓库一同分发。
二、构建与运行 srtp-fuzzer
原文档给出了在 libSRTP 仓库根目录下的完整构建命令,需要在 configure 阶段同时启用编译插桩与地址/未定义行为消毒器,并在链接阶段注入 libFuzzer:
CC=clang CXX=clang++ CXXFLAGS="-fsanitize=fuzzer-no-link,address,undefined -g -O3" CFLAGS="-fsanitize=fuzzer-no-link,address,undefined -g -O3" LDFLAGS="-fsanitize=fuzzer-no-link,address,undefined" ./configure LIBFUZZER="-fsanitize=fuzzer" make srtp-fuzzer逐项拆解这条命令的含义:
| 参数 | 作用 |
|---|---|
CC=clang/CXX=clang++ | libFuzzer 是 clang 工具链的一部分,必须用 clang 编译 |
-fsanitize=fuzzer-no-link | 在编译所有目标文件时插入覆盖率与消毒器探针,但暂不链接 fuzzer 运行时(链接发生在最后一步) |
-fsanitize=address,undefined | 启用 AddressSanitizer(检测越界、UAF、非法指针)与 UndefinedBehaviorSanitizer(检测整数溢出、非法移位等未定义行为) |
-g -O3 | 保留调试信息的同时开启优化,兼顾崩溃定位与模糊测试吞吐 |
LIBFUZZER="-fsanitize=fuzzer" | 仅在链接srtp-fuzzer这一步注入 libFuzzer 运行时(含 main 入口),从而只让最终二进制以 fuzz 模式运行 |
对应地,fuzzer/Makefile.in 定义了三个编译单元与最终链接规则:
mt19937.o:由mt19937.cpp以-std=c++11编译,导出 C++11 梅森旋转算法;fuzzer.o:核心驱动fuzzer.c,依赖fuzzer.h与testmem.h;testmem.o:testmem.c必须以-O0关闭优化单独编译(原因见下文"输出内存测试"一节);- 链接命令通过
$(LIBFUZZER)变量接入 libFuzzer 运行时,并与-lsrtp2库静态链接成最终二进制srtp-fuzzer。
编译产物srtp-fuzzer是标准的 libFuzzer 可执行文件,支持 libFuzzer 的全部命令行参数(种子语料目录、-runs、-max_len等),可直接将其指向存放种子输入的目录开始模糊测试。
三、核心特性之一:可移植 PRNG(mt19937)
模糊测试的输入输出需要可复现:某个输入若能触发崩溃,开发者应当能在任何机器上重放该输入,得到一致的执行行为。这要求 fuzzer 内部的所有随机决策(见后文的分配失败策略、mmap 地址选择等)都来自一个跨平台行为一致的随机数源。
原文档指出,标准库rand()是"善变的"——它针对给定种子产生的随机序列可能随操作系统与 libc 实现的不同而不同,这会让模糊语料失去可移植性。解决方案是直接复用 C++11 标准库的std::mt19937梅森旋转伪随机数生成器,它由 C++ 标准明确定义了算法与初始序列,行为在平台上高度一致。采用它既避免了自行实现 PRNG 的额外工作,也规避了从其他项目导入实现可能带来的许可证不兼容风险。
从源码看,mt19937.cpp 仅做了极简封装:
extern "C" void fuzz_mt19937_init(uint32_t seed) { mt_rand = new std::mt19937(seed); } extern "C" uint32_t fuzz_mt19937_get(void) { return (*mt_rand)(); } extern "C" void fuzz_mt19937_destroy(void) { delete mt_rand; mt_rand = NULL; }用extern "C"导出三个函数,使 C 代码(fuzzer.c)可以调用 C++ 实现的 PRNG。值得注意的细节是:PRNG 的种子本身是从 fuzzer 输入的前 4 个字节中提取的(见 fuzzer.c 中的EXTRACT_IF(&randseed, ...)与fuzz_mt19937_init(randseed))。这意味着"输入即种子"——同一个输入字节串在任意平台上都会展开为完全相同的随机决策序列,从而保证分配器行为、mmap 地址选择等随机环节的可复现性。
四、核心特性之二:Size 0 分配检测
C 标准规定malloc(0)可以返回NULL,也可以返回一个唯一但不可解引用的指针。实践中的问题是:有些实现(如 glibc)会返回一个小的、看似"可用"的内存区域,导致开发者写出"分配 0 字节后向该区域写入单个字节"的未定义行为代码而毫无察觉;而另一些实现(如 OpenBSD)在写入时会直接崩溃。这类缺陷极难在常规开发环境下暴露。
fuzzer 的自定义分配器 fuzz_alloc() 采用了更激进的做法:当请求大小为 0 时,故意返回一个非法地址(0x01 + (fuzz_mt19937_get() % 1024),落在 1~1024 区间的无效页)。任何对该指针的解引用或写入都会立刻触发 ASan 崩溃,从而高效暴露不健全的零尺寸分配代码。
配套的 fuzz_free() 通过fuzz_is_special_pointer()识别这一指针区间,直接返回而不调用free()/munmap(),避免了对非法指针的释放操作。
五、核心特性之三:随机分配失败
真实生产环境可能面临内存短缺,库必须在malloc/calloc返回NULL时仍能正确报告错误而非崩溃或泄漏。为测试这种韧性,自定义分配器会周期性随机返回NULL。
具体实现(fuzzer.c)中,fuzz_calloc()在初始化阶段(g_post_init == true)之后,平均每 64 次分配就有 1 次返回NULL。这一频率由 PRNG 决定,而 PRNG 种子又来自 fuzzer 输入,因此分配失败的时机是确定性的——同一输入在任何机器上都会在完全相同的调用点遭遇分配失败,bug 可以可靠复现。
同时,fuzzer 内部自身的分配(如从输入中解析 policy 结构所需的临时内存)走的是fuzz_alloc_succeed()(fuzzer.c):优先尝试可能失败的fuzz_alloc(),若失败则回退到 libc 的malloc/calloc兜底。这样既能保持对被测库施加分配失败压力,又不会因 fuzzer 自身内存不足而中断执行。
六、核心特性之四:检测不充分的指针算术(32 位专属)
这是整套方案中最精妙的一项技术,仅对 32 位构建生效。其思想是:将某些分配放置在虚拟地址空间的两个极端——高地址0xFFFF0000或低地址0x00010000,从而放大指针算术溢出的后果。
考虑如下典型代码模式:
if ( start + n < end ) { memset(start, 0, n); }其中start/end界定了一块内存区域,n是某个正整数。若n足够大,start + n会发生指针加法溢出。例如start分配在0xFFFF0000、end为0xFFFF1000、n为0xFFFFF时:
if ( 0xFFFF0000 + 0x000FFFFF < 0xFFFF1000 ) { memset(0xFFFF0000, 0, 0x000FFFF); }加法0xFFFF0000 + 0x000FFFFF溢出回绕为0x000EFFFF,于是条件0x000EFFFF < 0xFFFF1000解析为真,memset违背程序员本意被执行,产生段错误。虽然这属于边界情形,但无法排除其出现在生产环境的可能性;更重要的是,分析崩溃的分析师可以通过推算n的由来,构造出令n极大的特制输入,使该"exploit"即使在普通虚拟地址下也能成功。
因此 fuzzer 一方面通过mmap()在0xFFFF0000放置分配以暴露加法溢出(fuzzer.c),另一方面在低地址0x00010000放置分配,用于检测涉及减法的非法指针算术:
if ( end - n > start ) {源码细节还显示,选择"是否用 mmap""用高地址还是低地址"这两个随机决策即使传入--no_mmap也会照常消耗 PRNG 输出(见 fuzzer.c 注释),这是为了让 PRNG 输出流在不同 fuzzer 配置间保持一致,维持语料的可复现性。同时,由于同一时刻只支持一个并发 mmap 分配,fuzz_free()会通过g_mmap_allocation记录指针并在释放时调用munmap()(失败则abort())。
七、核心特性之五:输出内存测试(testmem.c)
模糊测试不仅要保证输入被正确解析,还要保证被测函数的输出缓冲区是合法的内存区域。testmem.c 导出的fuzz_testmem()做的事非常简单:把传入的缓冲区复制到一块新分配的堆内存,然后立即释放。
- 若启用AddressSanitizer:复制过程中的读写、释放后的访问都会触发 ASan 检查,从而验证输入缓冲区是否为合法内存区域;
- 若启用MemorySanitizer(编译时定义
FUZZ_MSAN):fuzz_testmem()会转调fuzz_testmem_msan(),把数据fwrite到/dev/null。这是让 MSan 评估数据是否含未初始化字节的巧妙技巧——任何未初始化字节流向 I/O 都会触发 MSan 崩溃。
关键工程细节:从优化编译器视角看,fuzz_testmem()是"复制即释放"的无意义操作,很可能被优化掉,因此该文件必须用-O0关闭优化编译(对应 Makefile.in 中的$(COMPILE) -O0 testmem.c -c -o testmem.o)。
fuzz_testmem()在 fuzzer 中应用广泛:每次run_srtp_func()执行完 protect/unprotect 后都会调用它验证输出(fuzzer.c);自定义事件处理器fuzz_srtp_event_handler()也会用它检查事件数据结构及其session指针(fuzzer.c)。
八、模糊输入如何驱动整个 SRTP 状态机
阅读 fuzzer.c 的LLVMFuzzerTestOneInput()可以看到,fuzzer 输入不只是"一段待加解密的报文",而是一份完整的、可编程的测试脚本,通过EXTRACT_IF/EXTRACT宏(定义于 fuzzer.h)逐段消费。每次调用的大致流程为:
- 提取 PRNG 种子:取输入前 4 字节初始化
mt19937与srand; - 提取两份 policy 链:第一份用于
srtp_create()初始化上下文,第二份预留给srtp_update()(源码中该项仍为 TODO,见 fuzzer.c); - 创建 SRTP 上下文:
srtp_create(&srtp_ctx, policy_chain); - 提取流移除与 ROC 操作:
extract_remove_stream_ssrc()与extract_set_roc()从输入中解析一组组srtp_remove_stream()、srtp_set_stream_roc()/srtp_get_stream_roc()的参数; - 循环执行 SRTP 函数:每次从输入提取一个函数选择(
protect/unprotect/protect_rtcp/unprotect_rtcp,以及带 MKI 的变体,函数表见 fuzzer.h)与一段载荷,先执行一次,再把输出作为第二次操作的输入,形成链式调用; - 交错状态操作:在循环间隙按输入指令移除流、设置/读取 ROC,驱动库的会话状态机;
- 清理:释放所有 policy、SSRC 列表、上下文。
policy 的提取同样值得注意(fuzzer.c):输入中的srtp_crypto_policy_func会被模运算映射到 fuzzer.h 中的加密策略表(AES-CM-128/192/256 与 HMAC-SHA1 系列组合、空密码/空认证等,其中 192 位与 AES-GCM 系列仅在#ifdef OPENSSL下启用),SSRC 类型被映射到ssrc_undefined/ssrc_specific/ssrc_any_inbound/ssrc_any_outbound,保证任何随机字节都能生成合法参数组合,从而最大化路径覆盖率。
九、可用的 fuzzer 命令行开关
从LLVMFuzzerInitialize()(fuzzer.c)的参数解析可以看到,srtp-fuzzer支持如下自定义开关:
| 开关 | 作用 | 适用构建 |
|---|---|---|
--no_align | 关闭 4 字节对齐强制(默认对 SRTP 载荷强制 4 字节对齐,见 fuzzer.c) | 全部 |
--no_mmap | 禁用极端地址 mmap 分配策略(PRNG 输出流保持一致) | 仅 32 位构建(FUZZ_32BIT) |
--no_custom_event_handler | 不安装自定义fuzz_srtp_event_handler,使用库默认事件处理 | 全部 |
--write_input | 将当前输入原样写入input.bin,便于复现崩溃用例(fuzzer.c) | 全部 |
其中 32 位构建由 fuzzer.h 根据UINTPTR_MAX自动判定(0xffffffff定义FUZZ_32BIT),并包含<sys/mman.h>头文件。
十、贡献指南:保持语料可移植
扩展 fuzzer 时,原文档给出了一条明确约束:尽可能使用跨系统宽度一致的整数类型,例如使用uint64_t而非unsigned long。原因在于 fuzzer 输入与 PRNG 输出流构成了一份"可移植语料"——若某段解析逻辑依赖unsigned long这类在 32 位与 64 位平台上宽度不同的类型,同一输入在不同平台上会产生不同行为,破坏语料的可复现性,也使崩溃用例失去跨平台重放能力。这一点与全文反复强调的"确定性""可复现"原则一脉相承。
结语
这套 libsrtp fuzzer 的价值不仅在于其被测对象(SRTP 协议栈)本身的重要性——在 SRS 中它直接护卫着 WebRTC 的媒体面安全——更在于其方法论上的可迁移性:用输入内嵌种子实现跨平台可复现的随机决策、用非法指针主动引爆零尺寸分配的未定义行为、用极端虚拟地址放大指针算术溢出、用-O0编译的"无意义"函数驱动 MSan 检查未初始化字节。这些技巧远超常规 fuzzer 的范畴,正如原文档所言,值得被移植到各类 C/C++ 项目的安全测试实践中。
【免费下载链接】srsSRS is a simple, high-performance, AI-driven real-time media server supporting RTMP, WebRTC, HLS, HTTP-FLV, HTTP-TS, SRT, MPEG-DASH, and GB28181, with codec support for H.264, H.265, AV1, VP9, AAC, Opus, and G.711.项目地址: https://gitcode.com/GitHub_Trending/sr/srs
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考