文章总结: 本文深入分析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。
这个漏洞有意思的地方不在”又双叒是反序列化”,而在于三个细节:
- 代码里明明写了一个看起来像白名单的类映射表,实际是个装饰品;
- 这套缓存机制从 2019 年就存在,潜伏了七年才被发现;
- 官方的修复方式不是加过滤,而是把整个功能删了——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 Collection<PerformanceReport> parse(Run<?, ?> build, Collection<File> reports,
TaskListener listener) throws PerformanceReportParseError {
final List<PerformanceReport> result = new ArrayList<>();
for (File reportFile : reports) {
// 优先从缓存加载序列化实例
final PerformanceReport deserializedReport = loadSerializedReport(reportFile);
if (deserializedReport != null) {
result.add(deserializedReport);
continue; // ← 命中缓存,原始报告文件根本不会被解析
}
try {
final PerformanceReport report = parse(reportFile);
result.add(report);
passBaselineBuild(report);
saveSerializedReport(reportFile, report); // 解析完写缓存
} catch (Throwable e) { ... }
}
return result;
}
注意那个 continue:只要旁车缓存文件存在,原始报告文件连解析都不进。这个短路后面会用到。
读缓存的方法:
protected static PerformanceReport loadSerializedReport(File reportFile) {
final String serialized = reportFile.getPath() + SERIALIZED_DATA_FILE_SUFFIX; // ".serialized"
File file = new File(serialized);
synchronized (CACHE) {
PerformanceReport report = CACHE.getIfPresent(serialized);
if (report == null && file.exists() && file.canRead()) {
try (FileInputStream fis = new FileInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(fis);
ObjectInputStream in = new ObjectInputStreamWithClassMapping(bis)) {
report = (PerformanceReport) in.readObject(); // ← 漏洞触发点
CACHE.put(serialized, report);
return report;
} catch (Exception ex) { ... }
}
return report;
}
}
规则很简单:对每个报告文件 xxx,去找同目录下的 xxx.serialized,找到就 readObject()。问题只剩一个——谁来保证 xxx.serialized 的内容是善意的?
0x2 “假白名单”:ObjectInputStreamWithClassMapping 剖析
这是整个漏洞里最值得玩味的类。读文件用的不是裸 ObjectInputStream,而是一个自定义子类:
public static class ObjectInputStreamWithClassMapping extends ObjectInputStream {
protected Hashtable<String, Class<?>> classMapping = new Hashtable<>();
public ObjectInputStreamWithClassMapping(InputStream in) throws IOException {
super(in);
classMapping.put("hudson.plugins.performance.PerformanceReport", PerformanceReport.class);
classMapping.put("hudson.plugins.performance.UriReport", UriReport.class);
classMapping.put("hudson.plugins.performance.UriReport$Sample", UriReport.Sample.class);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException,
ClassNotFoundException {
return (classMapping.containsKey(desc.getName())) ?
classMapping.get(desc.getName()) :
super.resolveClass(desc); // ← 白名单外?照样解析
}
}
乍一看像白名单:一张表,登记了 PerformanceReport、UriReport、UriReport$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 List<File> copyReportsToMaster(Run<?, ?> build, PrintStream logger,
List<FilePath> files, String parserDisplayName) {
List<File> localReports = new ArrayList<>();
for (FilePath src : files) {
final File localReport = getPerformanceReport(build, parserDisplayName, src.getName());
src.copyTo(new FilePath(localReport)); // ← 字节级原样拷贝
localReports.add(localReport);
}
return localReports;
}
getPerformanceReport 的落点在 build.getRootDir() 下,也就是 $JENKINS_HOME/jobs/<job>/builds/<N>/performance/。
把三段代码连起来,攻击路径完全自洽:
攻击者持有 Item/Configure
│
│ ① workspace 里摆放两个文件:
│ report.jtl ← 内容随便,甚至空文件(0x1 的 continue 保证了它不会被解析)
│ report.jtl.serialized ← 恶意序列化流(ysoserial 风格的 gadget 链)
│ 投递方式:SCM 检出恶意仓库即可,连 build step 执行权限都不需要
│
│ ② job 的 Performance Publisher 里把 glob 配成 **/* 或 **/report*
│ (glob 本身就是 Item/Configure 可改的配置项)
▼
构建结束,publisher 按 glob 匹配到两个文件
│ ③ copyReportsToMaster 把两个文件都拷进
│ $JENKINS_HOME/jobs/<job>/builds/<N>/performance/
▼
parse() 迭代到 report.jtl
│ ④ loadSerializedReport 发现 report.jtl.serialized 存在
▼
ObjectInputStreamWithClassMapping.readObject()
│ ⑤ 类不在映射表 → super.resolveClass() → 默认解析
▼
controller JVM 内 gadget 链触发,任意代码执行
两个细节加深这个漏洞的实战价值:
第一,第 ④ 步同时污染了原始解析路径。 命中缓存后 continue,report.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 +1/-126
修复 commit 干的事:
- 整体删除
loadSerializedReport()、saveSerializedReport()、ObjectInputStreamWithClassMapping和那个 GuavaCACHE; parse()里删掉缓存短路,回归”每次老老实实解析报告文件”;- 全库清理对
.serialized/.serialized-v2后缀的各种排除逻辑(PerformancePublisher、PerformanceReportMap、PerformanceProjectAction里那些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,不需要执行任何代码。
研判是否已被利用:
- 文件面:全盘排查 build 目录下的
*.serialized文件,头部魔数AC ED 00 05之外,重点看流中是否引用了PerformanceReport/UriReport之外的类名(序列化流的类名是明文,strings直接可见); - 日志面:搜
Reading serialized PerformanceReport instance from file ... failed后跟ClassCastException/ClassNotFoundException的组合——攻击失败会留痕,成功则可能伴随构建日志里莫名的 JVM 异常; - 权限面:审计谁持有 Item/Configure,尤其是能改 glob 和 publisher 配置的账号;
- 进程面: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):一个”假白名单”,七年潜伏,一删了之》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论