A Trio of Bugs Used to Exploit Inductive Automation at Pwn2Own Miami
June 11, 2020 | Guest BloggerIn January 2020, the inaugural Pwn2Own Miami contest was held at the S4 Conference and targeted Industrial Control System (ICS) products. At the contest, the team of Pedro Ribeiro and Radek Domanski used an information leak and an unsafe deserialization bug to get code execution on the Inductive Automation Ignition system. Their final effort ending Day One of the contest earned them $25,000. Now that patches are available from the vendor, they have graciously provided the following write-up and demonstration video.
This post describes a chain of Java vulnerabilities that were found by Pedro Ribeiro (@pedrib1337) and Radek Domanski (@RabbitPro). These bugs were put to use in ZDI's Pwn2Own Miami 2020 competition in January. The vulnerabilities described are present in the Inductive Automation Ignition SCADA product, versions 8.0.0 up to and including 8.0.7. The vulnerabilities were recently patched by the vendor, who recommends users upgrade to version 8.0.10. Here’s a quick video of these bugs in action:
The default configuration of Ignition is exploitable by an unauthenticated attacker. Successful exploitation would achieve remote code execution as SYSTEM on Windows or root on Linux.
The exploit chains three vulnerabilities to achieve code execution:
1. Unauthenticated access to a sensitive resource.
2. An insecure Java deserialization.
3. The use of an insecure Java library.
All code snippets in this blog were obtained by decompiling JAR files from version 8.0.7.
Vulnerability Details
Before we dig deep into the vulnerabilities, let’s cover some background information on Ignition and the /system/gateway
endpoint. Ignition listens on a large number of TCP and UDP ports, as it has to handle several SCADA protocols in addition to its primary functionality.
The main ports are TCP 8088 and TCP/TLS 8043, which are used to control the administrative server over HTTP(S) and handle communication between various Ignition components.
Several API endpoints are listening on that port, but the one we’re concerned with is at /system/gateway
. This API endpoint allows the user to perform remote function calls. Only a few can be called by an unauthenticated user. The Login.designer()
function is one of them. It communicates with clients using XML containing serialized Java objects. Its code resides in the com.inductiveautomation.ignition.gateway.servlets.Gateway
class.
Usually, performing client-server communications with serialized Java objects can lead to direct code execution, but in this case, it is not that simple. Before we dive into that, let's look at what a Login.designer()
request looks like:
And its response:
The request and response contain serialized Java objects that are passed to functions that can be called remotely. The example above shows a call to the designer()
function of the com.inductiveautomation.ignition.gateway.servlets.gateway.functions.Login
class with four arguments.
The call stack before we reach Login.designer()
is as follows:
com.inductiveautomation.ignition.gateway.servlets.Gateway.doPost()
com.inductiveautomation.ignition.gateway.servlets.gateway.AbstractGatewayFunction.invoke()
com.inductiveautomation.ignition.gateway.servlets.gateway.functions.Login.designer()
The Gateway.doPost()
servlet performs some version and sanity checks then sends the request to AbstractGatewayFunction.invoke()
, which parses and validates it before calling Login.designer()
, as shown below:
This function does the following:
1 - Parses the received message.
2 - Identifies the function to be called.
3 - Checks the function arguments to determine if they are safe to be deserialized.
4 - Ensures the number of arguments corresponds to the expected number for the target function.
5 - Calls the function with the deserialized arguments.
6 - Sends the response back to the client.
Before being deserialized, the arguments are checked to ensure they contain “safe” objects. This is done by calling decodeToObjectFragile()
from com.inductiveautomation.ignition.common.Base64
. This function takes two arguments: a String with a Base64 encoded object and an allowed list of classes that are safe to deserialize.
As seen above, if decodeToObjectFragile()
receives null
instead of a list of allowed classes, it uses a “normal” ObjectInputStream
to deserialize the object, with all the problems and insecurity it brings. However, if an allowed list is specified, decodeToObjectFragile
uses the SaferObjectInputStream
class instead to deserialize the object.
The SaferObjectInputStream
class is a wrapper around ObjectInputStream
that checks the class of each object being deserialized. If the class is not part of the allowed list, it rejects all input and terminates processing before any harmful effects occur. Here’s how that looks:
As it can be seen in the snippet above, the default allow list (DEFAULT_WHITELIST
) is very strict. It only allows the following object types to be deserialized:
-- String
-- Byte
-- Short
-- Integer
-- Long
-- Number
-- Float
-- Double
-- Boolean
-- Date
-- Color
-- ArrayList
-- HashMap
-- Enum
Since these are all very simple types, the mechanism described here is an effective way to stop most Java deserialization attacks.
It is out of the scope of this blog to explain Java deserialization, how it happens, and how devastating it can be. If you’re interested in reading more about it, check out Java Unmarshaller Security or this Foxglove Security Blog Post. Now let’s get to the exploit chain we used at Pwn2Own.
Vulnerability 1: Unauthenticated Access to Sensitive Resource
The first vulnerability in this chain is an information leak, but not used as such in our exploit. An unauthenticated attacker can invoke the “project diff” functionality to obtain crucial information about a project. In our case, we used this as a springboard to attack other functionality.
The com.inductiveautomation.ignition.gateway.servlets.gateway.functions.ProjectDownload
class contains a number of actions that are accessible by an unauthenticated remote attacker. One of them is getDiffs()
, which is shown below:
As seen above, this function compares the provided data with the project data in the server and returns a diff. If an attacker provides a valid project name, it is possible to trick the server into handing over all the project data.
Again, this functionality is not used in the exploit. Instead, this function is used as a springboard to further attack the system, which will be further explained below.
Vulnerability #2: Insecure Java Deserialization
As it can be seen in Snippet 6, ProjectDownload.getDiffs()
uses Base64.decodeToObjectFragile()
function to decode project data. This function was already explained in Snippet 4. As we explained above, if no class allow list is provided in the second argument to the function, it uses the standard unsafe ObjectInputStream
class to decode the given object. This leads to a classic Java deserialization vulnerability, which ultimately results in remote code execution when chained with the final vulnerability.
Vulnerability #3: Use of Insecure Java Library
The final link in this chain is to abuse a Java class with vulnerable Java gadget objects that can be used to achieve remote code execution. Luckily for us, Ignition has exactly that. It uses a very old version of the Apache Commons Beanutils, version 1.9.2, which is from 2013.
There is a payload for this library in the famous ysoserial Java deserialization exploitation tool, named CommonsBeanutils1.
Exploitation
To summarize, to achieve remote code execution, we need to do the following:
1 - Create a ysoserial CommonsBeanutils1 payload.
2 - Base64 encode the payload.
3 - Encapsulate the payload in a Java String object.
4 - Serialize the String object using the standard Java serialization functionality.
5 - Base64 encode the serialized String object.
6 - Send a request to /system/gateway
invoking getDiffs()
with the malicious parameters.
We're able to bypass the serialization whitelist and execute our code! But how? Let's dig into it.
Our payload will have the following format:
base64(String(base64(YSOSERIAL_PAYLOAD))
The code shown in Snippet 3 will perform Base64 decoding on it, which will result in:
String(base64(YSOSERIAL_PAYLOAD))
This is checked against the whitelist shown in the previous section and allowed to be deserialized since it’s a String
class. We then go into ProjectDownload.getDiffs()
. It takes our String argument and calls Base64.decodeToObjectFragile()
on it without specifying a whitelist.
As shown in Snippet 4, this will Base64 decode the String and then invoke ObjectInputStream.readObject()
on our malicious object (YSOSERIAL_PAYLOAD), resulting in code execution!
Payload Generation
To create our payload, we start by calling ysoserial as shown below:
Then the following Java code can be used to encapsulate a payload inside a String and serialize it to disk:
In this code, <YSOSERIAL_BASE64_PAYLOAD>
should contain the output of Snippet 7.
Finally, we send the following request to the target:
The <PAYLOAD>
will contain the output of running Snippet 8. The target will respond with:
The response contains a stack trace indicating something went wrong, but the payload was executed as SYSTEM (or root on Linux).
With the payload provided in Snippet 7, a file will appear in C:\flashback.txt
with the text nt authority\system
. This shows we have achieved unauthenticated remote code execution.
Conclusion
We hope you enjoyed this explanation of the exploit we used at Pwn2Own Miami. Inductive Automation fixed these bugs with the release of 8.0.10. This release contains many other fixes as well as new features. If you would like to test your own systems, we’ve released a Metasploit module for your convenience. You can see it in action in the video above
Thanks again to Pedro and Radek for providing this great write-up. Their contributions to Pwn2Own Miami helped make it a great event, and we certainly hope to see more submissions from them in the future. Until then, follow the team for the latest in exploit techniques and security patches.