Pwn2Owning Two Hosts at the Same Time: Abusing Inductive Automation Ignition’s Custom Deserialization
February 08, 2023 | Piotr BazydłoPwn2Own Miami 2022 was a fine competition. At the contest, I successfully exploited three different targets. In this blog post, I would like to show you my personal best research of the competition: the custom deserialization issue in Inductive Automation Ignition.
There are several things that make this vulnerability interesting, including the following:
· It exists in a custom deserialization routine, which seems to derive some inspiration from the Java XMLDecoder.
· It allows you to gain Remote Code Execution on two hosts at the same time: the client where the malicious project file is initially loaded, as well as the server that ultimately handles the file.
· There is a nice platform that can help an attacker deliver the malicious file to potential victims.
· In addition to the vector that involves a victim opening a malicious file locally on a client, it can also be exploited through a purely remote vector, in two different ways: either via an API call or via the Project Import functionality in the admin panel.
Since the remote vector requires an authentication bypass, at Pwn2Own, I decided to keep it simple and stick with the local vector.
This vulnerability was discovered through static code analysis. My full write-up for the contest was 50 pages long. Here I will try my best to provide you with as much information as possible, while not producing a blog post of excessive length. First, here’s a quick video of the exploit in action, showing RCE on both the client and the server! I popped calc.exe
on the client, whereas cmd.exe /c whoami > C:\poc.txt
was executed on the server.
Introduction to Ignition Projects
According to the Ignition manual, projects are one of the two main components of this platform, the other component being Ignition Gateway. Projects allow you to specify views, data operations, reports and so forth. Moreover, an official “Ignition Exchange” platform exists, which allows users to share their projects globally. This results in a very interesting vector, where a project file can be shared through the vendor’s website. Some of the projects I found have been downloaded hundreds of times. As file handling bugs in this product were in scope for this Pwn2Own, I decided to learn something more about project files.
Let’s have a quick look at project file structure. In recent Ignition versions, a project file is a ZIP-compressed archive containing multiple files and directories. We will highlight several basic components:
-- project.json
– this file contains basic information about the project, such as its name.
-- ignition\global-props
directory – this directory stores properties of the project. This directory will include files named data.bin
and resource.json
.
-- com.inductiveautomation.perspective
directory – this directory stores all the data concerning the visual aspects of the projects, such as page configurations, views and styles.
-- com.inductiveautomation.reporting
directory – this directory stores all the data concerning reports.
Every project contains multiple pairs of corresponding data.bin
and resource.json
files. The following screenshot presents several data.bin
files that are included in the sample project delivered by the vendor:
Some of these files contain JSON data, whereas others are gzip-compressed. Let’s open one of the gzip-compressed files in a text editor, after decompression:
Red flags! This file contains:
-- Full names of Java classes.
-- Something that looks like setters.
At this stage, I knew that I was dealing with something interesting and that it involved a custom serialization mechanism. I decided to dig further and see where it leads.
data.bin Handling and Two Deserializers
The main class responsible for the handling of data.bin
files is called XMLDeserializer
. It implements multiple deserialize
methods. The following code snippet presents the one that is interesting for us:
The method checks to see if the input is in a binary format by calling the isBinaryFormat
function. Depending on the result, it calls either deserializeBinary
or deserializeXML
. This decision is based on a magic number stored in the first bytes of the file, and it is not interesting for us.
It seems that both the binary and XML deserializers can be used to achieve remote code execution. They are based on the same deserialization handling classes. I focused solely on the XML deserialization, as it seemed less error-prone and I did not want any surprises during the contest. I had no sample XML file and I had to recreate the format from scratch.
Inner Workings of XMLDeserializer
This section describes the main aspects of the Ignition XMLDeserializer
. It contains a lot of source code, which may be hard to follow during the first read. Don’t worry, the end of this chapter contains a summary, which fully describes the deserialization scheme. If you feel overwhelmed by the amount of code, go straight to the end of this section.
Now, let’s have a look at the fragments of the deserializeXML
function.
At [1], we see the reference to the org.xml.sax.XMLReader
.
At [2], the ParseContext
is created. This is an Ignition-specific class.
At [3], code initializes the XMLParser
object. This is also an Ignition-specific class. The constructor accepts the ParseContext
object as a parameter.
At [4], code sets the content handler of the SAX XMLReader
to the XMLParser
.
At [5], the XML is parsed.
It seems that the XMLParser
and the ParseContext
are the key objects here. They will define the behavior of the XMLReader
. When we deal with the SAX XMLReader
, we should see calls to two main methods:
-- startElement
, which will be called when a new element starts (like
-- endElement
, which will be called when an element ends (like
Let’s look at three main parts of XMLParser
: the constructor, the startElement
method and the endElement
method.
The constructor basically sets the context
member to the provided context (the ParseContext
class implements the ParsingHandler
interface, so we are good here).
Two lines can be highlighted here:
-- The subName
string is retrieved using the getSubElementName
method.
-- The code calls this.context.onElementStart
, which accepts both name
and subName
as arguments.
We can skip a detailed analysis of the endElement
method, as its functioning is analogous to the previously shown method:
-- It retrieves the subName
in the same way as startElement
does.
-- It calls this.context.onElementEnd
.
We must investigate the getSubElementName
, as it is something new and not typical for SAX.
As shown here, getSubElementName
just checks if the element’s name contains either a colon or hyphen, and retrieves the part after the first such character. If there is no colon or hyphen it returns null
.
At this point, we know that the XMLDeserializer
will call the following two methods:
-- ParseContext.onElementStart
when an XML element starts.
-- ParseContext.onElementEnd
when an XML element ends.
These methods are crucial, as they define the whole behavior of the deserializer. Let’s have a look at the first of them.
We can see that this function implements special handling for an element with the name objects
. This suggests an element containing serialized objects. We can also expect the function to act differently in response to “main” elements (no colon or hypen) versus sub-elements. Let’s start with the main elements.
For a main element, at [4] an object of type DeserializationHandler
is retrieved via the lookupHandler
method. An important point to remember: the handler retrieval is based on the element name. Then, the handler’s startElement
method will be called at [5]. Finally, the handler will be added to the stack (list) at [6].
Let’s go back to the sub-elements. At [3], the code retrieves the last handler from the stack (see [6]). It then calls its startSubElement
method.
Finally, we will analyze the onElementEnd
function, together with the very important foundObject
method.
When dealing with a sub-element, the code retrieves the last handler from the stack and calls the handler’s endSubElement
method at [2]. Please note that it accepts the whole current object as an input!
When the code deals with something that is not a sub-element, the last handler is removed from the stack at [3]. Then, it calls the handler’s endElement
method at [4]. This method also accepts the whole current object.
Finally, the deserialized object is retrieved with the handler’s getObject
method, and the retrieved obj
is passed to foundObject
.
If the stack size is equal to 0, this indicates that a root object’s deserialization has been completed. In this case, the deserialized object is added to the this.rootObjects
list at [7]. Note that this means that the XML can contain multiple root objects! If we still have handlers on the stack, the endObject
method of the previous handler is called at [8].
XMLDeserializer - Summary
Now we will summarize the behavior of XMLDeserializer
. XMLDeserializer
retrieves a deserialization handler based on the first tag that defines an object (a root tag). During the deserialization process, it will call the following methods on the handler methods at the appropriate times:
-- startElement
-- startSubElement
-- endElement
-- endSubElement
-- endObject
Let’s try to visualize it with a simplified schema, which presents an order of the calls. It should provide you an idea of the whole deserialization flow (read from the top to the bottom):
We can:
-- Define multiple objects that we want to be deserialized (here: handler1 and handler4).
-- Define an object nested in an object (handler2 and handler 3). Nested objects might represent values to be assigned to members of a root object.
The exact outcome of the deserialization is highly dependent on the selected deserialization handlers. Let’s check them out.
Deserialization Handlers
We know that deserialization handlers are retrieved with the lookupHandler
method.
At [1], the handlers are obtained through the staticHandlers.get
method.
[2] presents the addStaticHandler
function. It shows that the handlers are inserted into the staticHandlers
HashMap. The key into the HashMap is equal to the output of the handler.getElementName
method.
We will look at the available handlers now. They are defined in initializeDeserializationHandlers
. There are more than 40 unique handlers implemented and the following screenshot presents a few of them. Does any of them catch your eye?
The ObjectDeserializationHandler
immediately drew my attention. It looks very generic, and generic things tend to be powerful. Let’s look at its definition.
The getElementName
method returns the literal string “o”. If we want to use this handler, the XML tag of our root object must have the name “o”. We can also see that this handler defines multiple very interesting members, such as Class clazz
and String methodName
. In addition, the AbstractDeserializationHandler
class defines the Object object
member. It seems that we are making progress towards RCE, but we still need to fully understand this handler.
In following chapters, I will go through the methods of the ObjectDeserializationHandler
. Again, if you do not want to read all the source code, you can go straight to the “ObjectDeserializationHandler – Summary” section.
ObjectDeserializationHandler - startElement
Let me start with the suspiciously simple startElement
method.
The call to AttributesMap.getClass
leads to the execution of the majority of code here. I am going to keep it simple, so you must know two things.
1) The class name will be retrieved from the XML tag’s “cls” attribute.
2) It will retrieve the corresponding object of type Class
by calling ClassNameResolver.classForName
.
A quick look at the relevant constructor code in ClassNameResolver
is now necessary.
One can see that the constructor defines some HashMaps and Arrays. The static createBasic
factory method creates a new ClassNameResolver
instance and then calls the addDefaults
method. This method inserts elements into the aliasMap
, classMap
and the searchPaths
members.
We can now analyze the classForName
method fragment. I believe that it is the root cause of this vulnerability. The following code snippet also includes the classForNameImpl
function
The classForNameImpl
method retrieves the class using the Java Class.forName
method. If we can reach this part of the code with our class name, we should be able to retrieve any class.
Now back to the classForName
. At [1], it checks if the class is included in the aliasMap
. If not, it will just call the desired classForNameImpl
at [2]. If a ClassNotFoundException
is thrown, it will iterate through the defined search paths and once again try to retrieve the class at [5].
In general, we have two major security problems here:
-- Ignition resolves the user-specified class without any validation.
-- Even if the list of aliases and paths is generated with the addDefaults
method, nevertheless "java.lang"
is included in the search path. As "java.lang"
includes many classes that can be potentially abused, the default search paths are dangerous.
One can also notice that if the provided class name starts with the “[“ character, an array type will be specified.
To sum up, we know that we can retrieve any class and we know how to define the first fragment of our malicious XML:
ObjectDeserializationHandler - startSubElement
The following code snippet presents the startSubElement
method of ObjectDeserializationHandler
.
We can see that we have two sub-elements defined for this handler: “ctor” (probably constructor) and “c” (probably call). In case of both the “ctor” and the “c” element, the method retrieves the methodSig
member through the getSignature
method. The signature defines the input arguments. For example, the signature of a method which accepts one argument, having type Array<String>
:
In the case of a call, the methodName
member is retrieved from the “m” XML attribute.
This function looks interesting, especially if we see that at some point the Java newInstance
method is called. We are going to stop now and go straight to the remaining methods. Soon we will circle back and connect all the dots.
ObjectDeserializationHandler – endObject
Before we move on to the endSubElement
method, the endObject
function must be analyzed. It is an important fragment of the deserialization flow, as it is called on any non-root object.
It just adds the freshly deserialized object to the args
list. This list of objects is very important for the next method we will analyze.
ObjectDeserializationHandler – endSubElement
We can finally move to the most important method - endSubElement
.
Let’s divide it into three main parts.
a) Argument retrieval
At [1], a new array of objects is created.
At [2], the arguments that were added with the endObject
method are retrieved.
b) Handling the case of a “ctor” sub-element
At [3], the code checks to see if the sub-element name is equal to “ctor”.
At [4], the constructor is retrieved with the method signature extracted in the startSubElement
method.
At [5], the object is initialized with the Java newInstance
method, passing the deserialized argument list.
As you can see, we are able to initialize a new object, with any public constructor and with arbitrary argument values.
c) Handling the case of a “c” sub-element
At [6], the code checks to see if the sub-element name is equal to “c”.
At [7], it retrieves the method having the specified method name and signature that were extracted in the startSubElement
method.
At [8], it invokes this method on the already initialized object, using the provided arguments.
Before the function ends, it clears the argument array at [9].
ObjectDeserializationHandler - endElement
This final code snippet presents the endElement
method.
This method is very simple. If the object
member was not already set, a new object is instantiated with the default public constructor that has no arguments (Java newInstance
method).
We can see that ObjectDeserializationHandler
leads to insecure reflection! We can provide any class, constructor, methods and arguments, though we are restricted to public constructors and methods. The handler will retrieve the specified class, instantiate it with the specified constructor and invoke the specified methods. We can even provide method arguments, where again we can control the type. This mechanism is ripe for misuse.
ObjectDeserializationHandler – Summary
Let’s try to summarize ObjectDeserializationHandler
. It allows us to retrieve any Java class through the startElement
method. It also allows us to retrieve an arbitrary constructor and arbitrary methods through the startSubElement
and endSubElement
methods. Both the constructor and the methods can be invoked with arbitrary arguments. To sum up, we have almost unlimited reflection capabilities here, with the main restriction being that we are limited to public methods.
One more word about the arguments. Ignition already defines its own handlers for some basic types, such as int, string and array. If we would like to provide some more complex types as arguments, we can use the ObjectDeserializationHandler
again to create the desired argument values.
The following figure presents a visualization of a sample serialized object and the deserialization process:
In the first step, the deserializer retrieves the MyClass
class. Then, it gets the constructor that accepts one string as an input and initializes the object with it. It passes string “inputForConstructor” as an argument to the constructor. After that, it retrieves the method MyClass.myMethod
function that accepts one argument of type string
. It invokes the method on the already initialized MyClass
object, passing the string “inputForMethod” as an argument. Finally, it adds the MyClass
object to the rootObjects
list.
Malicious Serialized Object – RCE Payload
We now have everything we need to know to create a malicious serialized object that gives us code execution. Since we have access to almost unlimited reflection, the task is very simple. For my Pwn2Own PoC, I used the java.lang.ProcessBuilder
class. I chose the constructor that accepts one array of strings, and then used the zero-parameter start
method.
The following XML presents the complete payload, which pops calculator.
Payload Delivery – Importing Project Files
We can now move on to the payload delivery phase. First, we will briefly describe the project import operation. Importing a project can be performed in two main ways:
-- Through the web application.
-- Through the Ignition Designer client.
We will focus on the latter, as this is the vector that was eligible under the rules of the competition. The project import operation can be summarized as follows:
-- The engineer starts the Ignition Designer client.
-- The engineer connects to a remote or local Ignition server.
-- The engineer opens a local ZIP project file in the client.
-- The client reads the default initial properties for the project.
-- (Optional) The engineer modifies the default properties.
-- The engineer finalizes the project import operation. The client sends the project to the server via the API.
-- The server imports the project and handles its files.
Two of these points are highlighted for a reason. I have already mentioned that this vulnerability produces RCE on both the client and the server. In my exploitation scenario, those are the steps that give us code execution on the client and the server, respectively.
The following screenshot presents the structure of my malicious project.
We have two malicious files here:
-- ignition/global-props/data.bin – default project properties are retrieved from this file by the Ignition Designer client.
-- Com.inductiveautomation.reporting/reports/Audit Report/data.bin – a report specification is retrieved by the Ignition server from this file.
To sum up, if the engineer who loads this project connects to the remote Ignition server, we get code execution on two different machines: the engineer’s workstation as well as the Ignition server!
Exploitation #1 – the Client RCE
Let’s see what happens when we open the already presented malicious XML file in Ignition Designer.
The calc was popped, thus our exploit works. However, a ClassCastException
was thrown and the project import cannot be finalized. This makes sense: Ignition Designer expects an object of type GlobalProps
type, but it instead received a ProcessBuilder
.
Luckily, this issue can be solved easily. Do you remember that we are able to provide multiple objects in one payload? I am going to skip the source code for this one, but the Designer project properties deserialization operates as follows:
-- It deserializes the data.bin file.
-- It retrieves the first object from the rootObjects
list.
-- It casts it to the GlobalProps
type.
The solution is simple. Our payload must contain two objects: a legitimate GlobalProps
object and a malicious ProcessBuilder
object. Both will be deserialized, but only the first one will be used by the Designer. The following XML presents an exemplary payload that contains two objects.
With this modification, we get code execution on the client and the victim can finalize the project import operation, allowing us to go on to compromise the server. The following screenshot demonstrates clean code execution on the client.
Bonus points for style: This attack on the Designer client leaves few traces. Our malicious XML file will be overwritten with the new data.bin properties file as soon as the “Import Project” button is clicked.
Exploitation #2 – the Server RCE
As explained above, when the victim clicks the “Import Project” button (see previous screenshot), the server imports the project and performs the deserialization of the included data.bin files. After a while, we should get our payload executed. The following screenshot presents the reverse shell obtained from the server.
Pure Remote Exploitation
There are at least two ways to exploit this vulnerability via the network, and both require authentication.
1) Project Import through the configuration panel
Projects can be imported through the Ignition configuration panel. When the malicious project gets imported, Ignition Gateway processes it and deserialization is triggered. The following screenshot shows the Project Import functionality.
2) Gateway API
When a user loads a project through the Ignition Designer client, Ignition Designer sends it to the Gateway via the API. A remote attacker can use this API directly to load a project and gain code execution on the server.
Moreover, in separate research, Gateway API authentication was bypassed by Chris Anastasio and Steven Seeley. The PoC for their authentication bypass can be found here.
When you have a valid API cookie, you can load a malicious project with the following HTTP request:
Conclusion
Inductive Automation Ignition is a powerful product that provides great deal of functionality for ICS engineers. One must remember, though, that a rich feature set can also mean a large attack surface. In this blog post, I have shown you the custom deserialization implementation used by Ignition when processing project files. The wide flexibility it offers opens the door to misuse.
I hope you liked this post. If you ever see names of Java classes in an unknown data structure, I encourage you to dig deeper. There is a chance that you will find something interesting there! Until my next post, you can follow me @chudypb and follow the team on Twitter or Instagram for the latest in exploit techniques and security patches.