JenkinsPerformance插件反序列化RCE(CVE-2026-84670):一个”假白名单”,七年潜伏,一删了之

admin 2026-09-14 04:55:15 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入分析JenkinsPerformance插件反序列化RCE漏洞(CVE-2026-84670)。漏洞源于2019年引入的序列化缓存机制,攻击者利用假白名单类映射的fail-open缺陷,通过构造恶意.serialized文件触发readObject()执行任意代码。攻击者仅需Item/Configure权限即可控制Jenkinscontroller。官方修复方式为直接删除126行相关代码。文章详细剖析了漏洞原理、攻击路径及修复方案,对防御此类反序列化漏洞具有重要参考价值。 综合评分: 88 文章分类: 漏洞分析,代码审计,WEB安全,渗透测试


Jenkins Performance 插件反序列化 RCE(CVE-2026-84670):一个”假白名单”,七年潜伏,一删了之

原创

Kratos Kratos

Kratos Sec

2026年9月8日 20:50 北京

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

Jenkins Performance 插件反序列化 RCE(CVE-2026-84670):一个”假白名单”,七年潜伏,一删了之

9 月 2 日,Jenkins 官方发布本年度最大规模的安全公告,核心与二十多个插件共修了四十多个 CVE。大部分是权限校验缺失、XSS 这类”常规操作”,但其中有一个编号值得单独拎出来讲:CVE-2026-84670,Performance 插件不受限反序列化,可直接拿下 Jenkins controller

这个漏洞有意思的地方不在”又双叒是反序列化”,而在于三个细节:

  1. 代码里明明写了一个看起来像白名单的类映射表,实际是个装饰品;
  2. 这套缓存机制从 2019 年就存在,潜伏了七年才被发现;
  3. 官方的修复方式不是加过滤,而是把整个功能删了——126 行代码,一个不留。

这篇从代码层面把这三件事讲透。

0x0 漏洞概述

| 项目 | 内容 | | — | — | | CVE 编号 | CVE-2026-84670(SECURITY-4026) | | 组件 | Jenkins Performance Plugin | | 影响版本 | ≤ 1015.v09ca_52b_3370e | | 修复版本 | 1017.v9e9f7b_b_b_c5e7 | | 漏洞类型 | CWE-502 不受信任数据反序列化 | | CVSS | High,AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H | | 前置权限 | Item/Configure(job 配置权限) | | 攻击效果 | 以 controller JVM 身份执行任意代码 | | 报告者 | Bun (@bunmamd4cb13t) |

修复 commit 是 2026 年 8 月 13 日的 9e9f7bb,标题就叫 “[SECURITY-4026] Fix vulnerabilites”(官方拼错的 vulnerabilites,原样保留)。修复版本号 1017.v9e9f7b_b_b_c5e7 里嵌的就是这个 commit 的短哈希。

0x1 漏洞位置:一套存在七年的序列化缓存

Performance 插件用来解析 JMeter、JUnit、Taurus、Locust 等工具产出的性能报告。JMeter 的 JTL 文件动辄几百 MB,每次构建都重新解析一遍太慢,于是作者设计了一个缓存:第一次解析完,把结果用 Java 原生序列化存到磁盘;下次遇到同一个文件,直接读缓存

这套逻辑全部在 AbstractParser 里。插件的全部 8 个解析器(JMeter、JMeter CSV、JUnit、Iago、JmeterSummarizer、LoadRunner、Locust、Taurus、WrkSummarizer)都继承它,所以没有任何一种报告类型可以幸免

解析入口 parse() 的逻辑(修复前):

// AbstractParser.java(漏洞版本)
public&nbsp;Collection<PerformanceReport>&nbsp;parse(Run<?, ?> build, Collection<File> reports,
&nbsp; &nbsp; &nbsp; &nbsp; TaskListener listener)&nbsp;throws&nbsp;PerformanceReportParseError&nbsp;{
&nbsp; &nbsp;&nbsp;final&nbsp;List<PerformanceReport> result =&nbsp;new&nbsp;ArrayList<>();

&nbsp; &nbsp;&nbsp;for&nbsp;(File reportFile : reports) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 优先从缓存加载序列化实例
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;final&nbsp;PerformanceReport deserializedReport = loadSerializedReport(reportFile);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(deserializedReport !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; result.add(deserializedReport);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue; &nbsp;&nbsp;// ← 命中缓存,原始报告文件根本不会被解析
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;final&nbsp;PerformanceReport report = parse(reportFile);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; result.add(report);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; passBaselineBuild(report);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; saveSerializedReport(reportFile, report); &nbsp;// 解析完写缓存
&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;catch&nbsp;(Throwable e) { ... }
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;result;
}

注意那个 continue:只要旁车缓存文件存在,原始报告文件连解析都不进。这个短路后面会用到。

读缓存的方法:

protected&nbsp;static&nbsp;PerformanceReport&nbsp;loadSerializedReport(File reportFile)&nbsp;{
&nbsp; &nbsp;&nbsp;final&nbsp;String serialized = reportFile.getPath() + SERIALIZED_DATA_FILE_SUFFIX;&nbsp;// ".serialized"
&nbsp; &nbsp; File file =&nbsp;new&nbsp;File(serialized);
&nbsp; &nbsp;&nbsp;synchronized&nbsp;(CACHE) {
&nbsp; &nbsp; &nbsp; &nbsp; PerformanceReport report = CACHE.getIfPresent(serialized);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(report ==&nbsp;null&nbsp;&& file.exists() && file.canRead()) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try&nbsp;(FileInputStream fis =&nbsp;new&nbsp;FileInputStream(serialized);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; BufferedInputStream bis =&nbsp;new&nbsp;BufferedInputStream(fis);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ObjectInputStream in =&nbsp;new&nbsp;ObjectInputStreamWithClassMapping(bis)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; report = (PerformanceReport) in.readObject(); &nbsp;&nbsp;// ← 漏洞触发点
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; CACHE.put(serialized, report);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;report;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;catch&nbsp;(Exception ex) { ... }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;report;
&nbsp; &nbsp; }
}

规则很简单:对每个报告文件 xxx,去找同目录下的 xxx.serialized,找到就 readObject()。问题只剩一个——谁来保证 xxx.serialized 的内容是善意的

0x2 “假白名单”:ObjectInputStreamWithClassMapping 剖析

这是整个漏洞里最值得玩味的类。读文件用的不是裸 ObjectInputStream,而是一个自定义子类:

public&nbsp;static&nbsp;class&nbsp;ObjectInputStreamWithClassMapping&nbsp;extends&nbsp;ObjectInputStream&nbsp;{
&nbsp; &nbsp;&nbsp;protected&nbsp;Hashtable<String, Class<?>> classMapping =&nbsp;new&nbsp;Hashtable<>();

&nbsp; &nbsp;&nbsp;public&nbsp;ObjectInputStreamWithClassMapping(InputStream in)&nbsp;throws&nbsp;IOException&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;super(in);
&nbsp; &nbsp; &nbsp; &nbsp; classMapping.put("hudson.plugins.performance.PerformanceReport", PerformanceReport.class);
&nbsp; &nbsp; &nbsp; &nbsp; classMapping.put("hudson.plugins.performance.UriReport", UriReport.class);
&nbsp; &nbsp; &nbsp; &nbsp; classMapping.put("hudson.plugins.performance.UriReport$Sample", UriReport.Sample.class);
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;protected&nbsp;Class<?> resolveClass(ObjectStreamClass desc)&nbsp;throws&nbsp;IOException,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ClassNotFoundException {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;(classMapping.containsKey(desc.getName())) ?
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; classMapping.get(desc.getName()) :
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;super.resolveClass(desc); &nbsp; &nbsp;// ← 白名单外?照样解析
&nbsp; &nbsp; }
}

乍一看像白名单:一张表,登记了 PerformanceReportUriReportUriReport$Sample 三个类。但看 resolveClass 的逻辑——表里有的走映射,表里没有的,super.resolveClass(desc) 兜底

ObjectInputStream.resolveClass() 的默认行为是:拿着流里的类名去 classpath 上加载。也就是说这个类的真实语义是”三个类的改名兼容层”(估计是为了应付历史包名迁移),对安全而言是 fail-open 的

  • 恶意流里写 hudson.plugins.performance.PerformanceReport → 命中映射,正常加载;
  • 恶意流里写任何 gadget 链的类名 → 没命中映射 → 默认解析 → 照常实例化

这就是标题里说的”假白名单”。它不限制任何东西,只重命名三个东西。真正的白名单应该反过来写:**不在表内 → 抛 ClassNotFoundException**。一个字符方向的区别,决定了它是安全控件还是装饰品。

对比一下 Jenkins 核心的同类实现。JEP-200 里核心代码反序列化用的是 ObjectInputStreamProxy,其 resolveClass 会过 ClassFilter.check(),不在白名单直接抛异常。再看 Log4j2 修复 CNVD-2021-95914 时加的 FilteredObjectInputStream——同样是白名单外抛异常的写法。过滤器的生死线就在 default 分支是拒绝还是放行,这个类选择了放行。

顺带一提,(PerformanceReport) in.readObject() 这个强转也拦不住什么。反序列化攻击的全部破坏发生在 readObject() 期间——gadget 链在对象图还原过程中就被执行了,等到强转抛 ClassCastException 时,命令早跑完了。

0x3 恶意数据如何抵达 controller:一条完整的文件流

反序列化漏洞的另一半问题是数据投递。readObject() 的输入是 controller 本地构建目录里的 .serialized 文件——看起来是”自己的缓存”,怎么被污染?看 publisher 的数据流(修复前 PerformancePublisher.java):

// 1. 用 job 配置里的 glob,在 agent workspace 里匹配报告文件
List<FilePath> files = locatePerformanceReports(workspace, glob);

// 2. 原样拷贝到 controller 侧的构建目录,无任何内容校验
List<File> localReports = copyReportsToMaster(run, logger, files, parser.getDescriptor().getDisplayName());

// 3. 交给 AbstractParser.parse(),即 0x1 的入口
performanceReports.addAll(parser.parse(run, localReports, listener));

其中拷贝逻辑:

private&nbsp;List<File>&nbsp;copyReportsToMaster(Run<?, ?> build, PrintStream logger,
&nbsp; &nbsp; &nbsp; &nbsp; List<FilePath> files, String parserDisplayName)&nbsp;{
&nbsp; &nbsp; List<File> localReports =&nbsp;new&nbsp;ArrayList<>();
&nbsp; &nbsp;&nbsp;for&nbsp;(FilePath src : files) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;final&nbsp;File localReport = getPerformanceReport(build, parserDisplayName, src.getName());
&nbsp; &nbsp; &nbsp; &nbsp; src.copyTo(new&nbsp;FilePath(localReport)); &nbsp;&nbsp;// ← 字节级原样拷贝
&nbsp; &nbsp; &nbsp; &nbsp; localReports.add(localReport);
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;localReports;
}

getPerformanceReport 的落点在 build.getRootDir() 下,也就是 $JENKINS_HOME/jobs/<job>/builds/<N>/performance/

把三段代码连起来,攻击路径完全自洽:

攻击者持有 Item/Configure
&nbsp; &nbsp;│
&nbsp; &nbsp;│ ① workspace 里摆放两个文件:
&nbsp; &nbsp;│ &nbsp; &nbsp;report.jtl &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;← 内容随便,甚至空文件(0x1 的&nbsp;continue&nbsp;保证了它不会被解析)
&nbsp; &nbsp;│ &nbsp; &nbsp;report.jtl.serialized ← 恶意序列化流(ysoserial 风格的 gadget 链)
&nbsp; &nbsp;│ &nbsp; &nbsp;投递方式:SCM 检出恶意仓库即可,连 build step 执行权限都不需要
&nbsp; &nbsp;│
&nbsp; &nbsp;│ ② job 的 Performance Publisher 里把 glob 配成 **/* 或 **/report*
&nbsp; &nbsp;│ &nbsp; &nbsp;(glob 本身就是 Item/Configure 可改的配置项)
&nbsp; &nbsp;▼
构建结束,publisher 按 glob 匹配到两个文件
&nbsp; &nbsp;│ ③ copyReportsToMaster 把两个文件都拷进
&nbsp; &nbsp;│ &nbsp; &nbsp;$JENKINS_HOME/jobs/<job>/builds/<N>/performance/
&nbsp; &nbsp;▼
parse() 迭代到 report.jtl
&nbsp; &nbsp;│ ④ loadSerializedReport 发现 report.jtl.serialized 存在
&nbsp; &nbsp;▼
ObjectInputStreamWithClassMapping.readObject()
&nbsp; &nbsp;│ ⑤ 类不在映射表 → super.resolveClass() → 默认解析
&nbsp; &nbsp;▼
controller JVM 内 gadget 链触发,任意代码执行

两个细节加深这个漏洞的实战价值:

第一,第 ④ 步同时污染了原始解析路径。 命中缓存后 continuereport.jtl 本体不进 parser——意味着攻击者的伪装报告文件可以是零字节的,不会因为格式错误在构建日志里留下可疑的解析失败记录。

第二,.serialized 是持久化的。 它躺在 build 目录里不清理。Performance 插件的基线比较(compareWithRelativeThreshold → getBuildUriReports)会在后续构建里重新解析历史 build 目录,恶意缓存文件会被反复反序列化。一次投毒,长期有效——即使攻击者事后失去 job 配置权限,留在历史 build 目录里的 .serialized 依然是活雷。

0x4 为什么 JEP-200 拦不住它

Jenkins 核心在 2.102 之后实现了 JEP-200,用 ClassFilter 把 remoting 通道(controller ↔ agent、CLI)上的反序列化管得死死的。很多人因此以为”Jenkins 的反序列化问题早就修完了”。

但 JEP-200 管辖的是网络通道上的 XStream/Java 序列化。插件在自己代码里 new ObjectInputStream(...) 读本地文件,根本不经过那条防线。这次公告里同批的核心漏洞 CVE-2026-84645 打的是 config.xml 的 XStream 路径,84670 打的是插件私有缓存路径——同一个 CWE-502,两条不同的投递管道,JEP-200 两条都罩不住。

规律可以总结成一句话:平台级反序列化防护的保护范围,止步于平台自己的序列化入口;插件自建的每一个 readObject(),都是防护地图上的空白区。 审计 Jenkins 生态时,全库搜 extends ObjectInputStream 和 new ObjectInputStream(,比看 CVE 列表更能找到下一发子弹。

0x5 修复剖析:删功能,而不是修过滤

看修复 diff 的统计就很有信息量:

src/main/java/hudson/plugins/performance/parsers/AbstractParser.java &nbsp;+1/-126

修复 commit 干的事:

  1. 整体删除loadSerializedReport()saveSerializedReport()ObjectInputStreamWithClassMapping 和那个 Guava CACHE
  2. parse() 里删掉缓存短路,回归”每次老老实实解析报告文件”;
  3. 全库清理对 .serialized / .serialized-v2 后缀的各种排除逻辑(PerformancePublisherPerformanceReportMapPerformanceProjectAction 里那些 endsWith(".serialized") 的防御性跳过,全部失去存在意义,一并移除)。

官方公告的措辞也很直白:插件 “no longer deserializes cached performance reports“——不再反序列化缓存报告。

为什么选择删而不是修?算一笔账就明白:

  • 修过滤:给 resolveClass 加真正的白名单,得处理所有历史缓存文件的兼容(类名改过、字段演进过),还得防新的绕过姿势,长期维护成本高;
  • 算收益:这个缓存省下的只是重复解析 JTL 文件的时间——CI 场景里每个 build 的报告文件内容基本都不同,缓存命中率本来就存疑;
  • 风险收益比:为一个边际性能优化保留一条 controller RCE 通道,不值。

这是安全修复里值得记住的决策模式:当一个功能的全部价值是省一点 CPU,而它的实现引入的是 RCE 级攻击面时,正确答案不是把锁焊得更结实,是把门拆了。 对比同批公告里其他插件的修法(加 ObjectInputFilter、加权限检查),官方对 84670 的处理反而是对漏洞严重性最诚实的表态。

0x6 无害验证与自查

验证自己环境是否存在漏洞面(不涉及攻击 payload):

# 1. 插件版本指纹
# 浏览器登录后访问 /pluginManager/installed/ 搜 performance
# 或 CLI:
java -jar jenkins-cli.jar -s <JENKINS_URL> list-plugins | grep performance
# 命中条件:版本 ≤ 1015.v09ca_52b_3370e

研究者在测试环境确认任意类实例化的无害方法:构造一个引用不存在类名的序列化流放进测试 build 目录,观察 Jenkins 日志——若出现对应的 ClassNotFoundException 而非在白名单层被拒绝,即证明 resolveClass 对任意类名放行。全程不涉及 gadget,不需要执行任何代码。

研判是否已被利用

  1. 文件面:全盘排查 build 目录下的 *.serialized 文件,头部魔数 AC ED 00 05 之外,重点看流中是否引用了 PerformanceReport/UriReport 之外的类名(序列化流的类名是明文,strings 直接可见);
  2. 日志面:搜 Reading serialized PerformanceReport instance from file ... failed 后跟 ClassCastException / ClassNotFoundException 的组合——攻击失败会留痕,成功则可能伴随构建日志里莫名的 JVM 异常;
  3. 权限面:审计谁持有 Item/Configure,尤其是能改 glob 和 publisher 配置的账号;
  4. 进程面:Jenkins JVM 的异常子进程、/tmp 下新增 jar、异常网络外连。

0x7 修复与缓解

  • 首选:插件管理器里直接升级到 1017.v9e9f7b_b_b_c5e7+(官方未提供 workaround);
  • 临时止血:不使用性能报告功能的实例,直接卸载 Performance 插件——这个漏洞没有”半开”状态,留着插件就留着攻击面;
  • 权限收敛:Item/Configure 只授予确实需要配置 job 的开发者。这个前置权限和 CVE-2026-84645(核心反序列化)、CVE-2026-84671(file-parameters 任意文件写入)完全相同——同批三个 RCE 共享同一个入口权限,收紧它是性价比最高的动作
  • 纵深防御:升级后排查历史 build 目录里遗留的 .serialized 文件(升级不清理它们,虽然不再被读取,但作为入侵痕迹值得取证留存)。

写在最后

这个漏洞的完整故事线:2019 年,一位开发者为了解析大文件慢的问题,写了一套序列化缓存,顺手加了个”改名兼容层”;七年间没人注意到那个类映射表对外部类名完全不设防;2026 年 8 月,一位研究者发现 workspace 到 controller 之间存在一条无校验的文件搬运管道,两者一接,controller 沦陷;官方的最终处理是把七年前那个优化整个删掉。

三段代码——resolveClass 的 fail-open、copyTo 的无校验、continue 的短路——单独看每一个都不算大事,连起来就是一条完整的 RCE 链。反序列化漏洞从来不是单点失误,它是数据流上每一环都选择信任下一环的累积结果。


免责声明:本文仅用于安全研究与学习,分析基于公开的官方公告与开源代码,未经授权不得用于任何非法渗透测试活动。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Kratos Sec Kratos Kratos《Jenkins Performance 插件反序列化 RCE(CVE-2026-84670):一个”假白名单”,七年潜伏,一删了之》

评论:0   参与:  0