Skip to content

Commit e68cc41

Browse files
authored
Merge pull request Pennyw0rth#587 from Mercury0/ldap-checker-refactor
Improve reliability of ldap-checker module
2 parents 9161b29 + a91761a commit e68cc41

2 files changed

Lines changed: 187 additions & 145 deletions

File tree

nxc/modules/ldap-checker.py

Lines changed: 186 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import socket
22
import ssl
33
import asyncio
4+
import hashlib
5+
import random
46

57
from msldap.connection import MSLDAPClientConnection
68
from msldap.commons.target import MSLDAPTarget
@@ -10,142 +12,168 @@
1012
from asyauth.common.credentials.kerberos import KerberosCredential
1113

1214
from asysocks.unicomm.common.target import UniTarget, UniProto
13-
import sys
15+
import contextlib
1416

1517

1618
class NXCModule:
1719
"""
18-
Checks whether LDAP signing and channelbinding are required.
20+
Checks whether LDAP signing and LDAPS channel binding are required and/or enforced.
1921
20-
Module by LuemmelSec (@theluemmel), updated by @zblurx
22+
Module by LuemmelSec (@theluemmel), updated by @zblurx/@Mercury0
2123
Original work thankfully taken from @zyn3rgy's Ldap Relay Scan project: https://github.com/zyn3rgy/LdapRelayScan
2224
"""
23-
2425
name = "ldap-checker"
25-
description = "Checks whether LDAP signing and binding are required and / or enforced"
26+
description = "Checks whether LDAP signing and channel binding are required and / or enforced"
2627
supported_protocols = ["ldap"]
2728
opsec_safe = True
2829
multiple_hosts = True
2930

3031
def options(self, context, module_options):
3132
"""No options available."""
3233

33-
def on_login(self, context, connection):
34-
# Conduct a bind to LDAPS and determine if channel
35-
# binding is enforced based on the contents of potential
36-
# errors returned. This can be determined unauthenticated,
37-
# because the error indicating channel binding enforcement
38-
# will be returned regardless of a successful LDAPS bind.
39-
async def run_ldaps_noEPA(target, credential):
40-
ldapsClientConn = MSLDAPClientConnection(target, credential)
41-
_, err = await ldapsClientConn.connect()
42-
43-
# Required step to try to bind without channel binding
44-
ldapsClientConn.cb_data = None
45-
46-
if err is not None:
47-
context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err))
48-
sys.exit()
49-
50-
valid, err = await ldapsClientConn.bind()
51-
if "data 80090346" in str(err):
52-
return True # channel binding IS enforced
53-
elif "data 52e" in str(err):
54-
return False # channel binding not enforced
55-
elif err is None:
56-
# LDAPS bind successful
57-
# because channel binding is not enforced
58-
return False
34+
# Conduct a bind to LDAPS and determine if channel
35+
# binding is enforced based on the contents of potential
36+
# errors returned. This can be determined unauthenticated,
37+
# because the error indicating channel binding enforcement
38+
# will be returned regardless of a successful LDAPS bind.
39+
async def run_ldaps_noEPA(self, context, connection, target, credential):
40+
try:
41+
client = MSLDAPClientConnection(target, credential)
42+
_, err = await client.connect()
43+
if err:
44+
context.log.debug(f"Error connecting to {connection.domain}: {err}")
45+
return None
5946

60-
# Conduct a bind to LDAPS with channel binding supported
61-
# but intentionally miscalculated. In the case that and
62-
# LDAPS bind has without channel binding supported has occurred,
63-
# you can determine whether the policy is set to "never" or
64-
# if it's set to "when supported" based on the potential
65-
# error received from the bind attempt.
66-
async def run_ldaps_withEPA(target, credential):
67-
ldapsClientConn = MSLDAPClientConnection(target, credential)
68-
_, err = await ldapsClientConn.connect()
69-
if err is not None:
70-
context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err))
71-
sys.exit()
72-
# forcing a miscalculation of the "Channel Bindings" av pair in Type 3 NTLM message
73-
ldapsClientConn.cb_data = b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
74-
_, err = await ldapsClientConn.bind()
75-
if "data 80090346" in str(err):
76-
return True
77-
elif "data 52e" in str(err):
78-
return False
79-
elif err is not None:
80-
context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err))
47+
client.cb_data = None
48+
_, err = await client.bind()
49+
if err and "data 80090346" in str(err):
50+
return True # -> channel binding IS enforced
51+
elif err and "data 52e" in str(err):
52+
return False # -> channel binding not enforced
8153
elif err is None:
82-
return False
54+
return False # LDAPS bind successful -> channel binding not enforced
55+
else:
56+
context.log.debug(f"Unexpected error during LDAPS bind (noEPA): {err}")
57+
return None
58+
except Exception as e:
59+
context.log.debug(f"Exception in run_ldaps_noEPA: {e}")
60+
return None
61+
finally:
62+
with contextlib.suppress(Exception):
63+
await client.disconnect()
64+
65+
# Conduct a bind to LDAPS with channel binding supported
66+
# but intentionally miscalculated. In the case that an
67+
# LDAPS bind without channel binding supported has occurred,
68+
# you can determine whether the policy is set to "never" or
69+
# if it's set to "when supported" based on the potential
70+
# error received from the bind attempt.
71+
async def run_ldaps_withEPA(self, context, connection, target, credential):
72+
try:
73+
client = MSLDAPClientConnection(target, credential)
74+
_, err = await client.connect()
75+
if err:
76+
context.log.fail(f"Error connecting to {connection.domain}: {err}")
77+
return None
8378

84-
# Domain Controllers do not have a certificate setup for
85-
# LDAPS on port 636 by default. If this has not been setup,
86-
# the TLS handshake will hang and you will not be able to
87-
# interact with LDAPS. The condition for the certificate
88-
# existing as it should is either an error regarding
89-
# the fact that the certificate is self-signed, or
90-
# no error at all. Any other "successful" edge cases
91-
# not yet accounted for.
92-
def DoesLdapsCompleteHandshake(dcIp):
93-
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
94-
s.settimeout(5)
95-
ssl_context = ssl.create_default_context()
96-
ssl_context.check_hostname = False
97-
ssl_sock = ssl_context.wrap_socket(
98-
s,
99-
do_handshake_on_connect=False,
100-
suppress_ragged_eofs=False,
101-
)
102-
try:
103-
ssl_sock.connect((dcIp, 636))
104-
ssl_sock.do_handshake()
105-
ssl_sock.close()
106-
return True
107-
except Exception as e:
108-
if "CERTIFICATE_VERIFY_FAILED" in str(e):
109-
ssl_sock.close()
110-
return True
111-
if "handshake operation timed out" in str(e):
112-
ssl_sock.close()
113-
return False
114-
else:
115-
context.log.fail("Unexpected error during LDAPS handshake: " + str(e))
116-
ssl_sock.close()
117-
return False
118-
119-
# Conduct and LDAP bind and determine if server signing
120-
# requirements are enforced based on potential errors
121-
# during the bind attempt.
122-
async def run_ldap(target, credential):
12379
try:
124-
ldapsClientConn = MSLDAPClientConnection(target, credential)
125-
ldapsClientConn._disable_signing = True
126-
_, err = await ldapsClientConn.connect()
127-
if err is not None:
128-
context.log.fail(str(err))
129-
return None
80+
context.log.debug("Retrieving TLS certificate hash...")
81+
ssl_context = ssl.create_default_context()
82+
ssl_context.check_hostname = False
83+
ssl_context.verify_mode = ssl.CERT_NONE
13084

131-
_, err = await ldapsClientConn.bind()
132-
if err is not None:
133-
errstr = str(err).lower()
134-
if "stronger" in errstr:
135-
return True
136-
# because LDAP server signing requirements ARE enforced
137-
else:
138-
context.log.fail(str(err))
85+
with socket.create_connection((connection.host, 636)) as sock, ssl_context.wrap_socket(sock, server_hostname=connection.host) as ssl_sock:
86+
cert = ssl_sock.getpeercert(binary_form=True)
87+
88+
if cert:
89+
cert_hash = hashlib.sha256(cert).digest()
90+
context.log.debug(f"Original certificate hash: {cert_hash.hex()}")
91+
pos = random.randint(0, len(cert_hash) - 1)
92+
tampered_bytes = bytearray(cert_hash)
93+
tampered_bytes[pos] = (tampered_bytes[pos] + 1) % 256
94+
context.log.debug(f"Tampered certificate hash: {bytes(tampered_bytes).hex()}")
95+
context.log.debug(f"Modified byte at position {pos}")
96+
client.cb_data = b"tls-server-end-point:" + bytes(tampered_bytes)
13997
else:
140-
# LDAPS bind successful
141-
return False
142-
# because LDAP server signing requirements are not enforced
98+
client.cb_data = b"\x00" * 64
14399
except Exception as e:
144-
context.log.debug(str(e))
100+
context.log.debug(f"Failed to retrieve TLS certificate hash: {e}")
101+
client.cb_data = b"\x00" * 64
102+
103+
_, err = await client.bind()
104+
if err and "data 80090346" in str(err):
105+
return True
106+
elif (err and "data 52e" in str(err)) or err is None:
107+
return False
108+
else:
109+
context.log.fail(f"Unexpected error during LDAPS bind (withEPA): {err}")
110+
return None
111+
except Exception as e:
112+
context.log.fail(f"Exception in run_ldaps_withEPA: {e}")
113+
return None
114+
115+
116+
# Domain Controllers do not have a certificate setup for
117+
# LDAPS on port 636 by default. If this has not been setup,
118+
# the TLS handshake will hang and you will not be able to
119+
# interact with LDAPS. The condition for the certificate
120+
# existing as it should is either an error regarding
121+
# the fact that the certificate is self-signed, or
122+
# no error at all. Any other "successful" edge cases
123+
# not yet accounted for.
124+
def does_ldaps_complete_handshake(self, context, dc_ip):
125+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
126+
s.settimeout(5)
127+
ssl_context = ssl.create_default_context()
128+
ssl_context.check_hostname = False
129+
ssl_sock = ssl_context.wrap_socket(s, do_handshake_on_connect=False, suppress_ragged_eofs=False)
130+
try:
131+
ssl_sock.connect((dc_ip, 636))
132+
ssl_sock.do_handshake()
133+
return True
134+
except Exception as e:
135+
if "CERTIFICATE_VERIFY_FAILED" in str(e):
136+
return True
137+
elif "handshake operation timed out" in str(e):
138+
return False
139+
else:
140+
context.log.fail(f"Unexpected error during LDAPS handshake: {e}")
141+
return False
142+
finally:
143+
ssl_sock.close()
144+
145+
# Conduct an LDAP bind and determine if server signing
146+
# requirements are enforced based on potential errors
147+
# during the bind attempt.
148+
async def run_ldap(self, context, target, credential):
149+
try:
150+
client = MSLDAPClientConnection(target, credential)
151+
client._disable_signing = True # deliberately disable LDAP signing on client connection
152+
_, err = await client.connect()
153+
if err:
154+
context.log.fail(f"Error connecting for LDAP bind: {err}")
145155
return None
146-
147156

148-
# Run trough all our code blocks to determine LDAP signing and channel binding settings.
157+
_, err = await client.bind()
158+
if err:
159+
errstr = str(err).lower()
160+
if "stronger" in errstr:
161+
return True
162+
# because LDAP server signing requirements ARE enforced
163+
else:
164+
context.log.fail(f"LDAP bind error: {err}")
165+
return None
166+
else:
167+
# LDAPS bind successful
168+
return False
169+
# because LDAP server signing requirements are not enforced
170+
except Exception as e:
171+
context.log.debug(f"Exception during LDAP bind: {e}")
172+
return None
173+
174+
# Determine authentication context and proceed to
175+
# enumerate LDAP signing and channel binding settings
176+
def on_login(self, context, connection):
149177
stype = asyauthSecret.PASS
150178
secret = connection.password
151179
if connection.nthash:
@@ -154,21 +182,24 @@ async def run_ldap(target, credential):
154182
if connection.aesKey:
155183
stype = asyauthSecret.AES
156184
secret = connection.aesKey
157-
if connection.username == "" and secret == "":
158-
credential = NTLMCredential(
159-
secret=None,
160-
username="Guest",
161-
domain=None,
162-
stype=stype,
163-
)
164-
context.log.info("No username used, skipping LDAP signing check")
185+
186+
anon_credential = NTLMCredential(
187+
secret="",
188+
username="",
189+
domain=connection.domain,
190+
stype=asyauthSecret.PASS
191+
)
192+
193+
if not connection.username and not secret:
194+
context.log.highlight("No credentials provided, skipping LDAP signing check")
195+
credential = anon_credential
165196
else:
166197
if not connection.kerberos:
167198
credential = NTLMCredential(
168199
secret=secret,
169200
username=connection.username,
170201
domain=connection.domain,
171-
stype=stype,
202+
stype=stype
172203
)
173204
else:
174205
kerberos_target = UniTarget(
@@ -189,29 +220,40 @@ async def run_ldap(target, credential):
189220
stype=stype,
190221
)
191222

192-
target = MSLDAPTarget(connection.host, 389, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost)
193-
ldapIsProtected = asyncio.run(run_ldap(target, credential))
194-
if ldapIsProtected is False:
195-
context.log.highlight("LDAP Signing NOT Enforced!")
196-
elif ldapIsProtected is True:
197-
context.log.fail("LDAP Signing IS Enforced")
223+
ldap_signing_status = None
224+
if connection.username or secret:
225+
target = MSLDAPTarget(
226+
connection.host, 389,
227+
hostname=connection.remoteName,
228+
domain=connection.domain,
229+
dc_ip=connection.kdcHost,
230+
)
231+
ldap_signing_status = asyncio.run(self.run_ldap(context, target, credential))
232+
if ldap_signing_status is True:
233+
context.log.highlight("LDAP signing IS enforced")
234+
elif ldap_signing_status is False:
235+
context.log.highlight("LDAP signing NOT enforced")
198236
else:
199-
context.log.fail("Connection fail, exiting now")
200-
sys.exit()
201-
202-
if DoesLdapsCompleteHandshake(connection.host) is True:
203-
target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost)
204-
ldapsChannelBindingAlwaysCheck = asyncio.run(run_ldaps_noEPA(target, credential))
205-
target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost)
206-
ldapsChannelBindingWhenSupportedCheck = asyncio.run(run_ldaps_withEPA(target, credential))
207-
if ldapsChannelBindingAlwaysCheck is False and ldapsChannelBindingWhenSupportedCheck is True:
208-
context.log.highlight('LDAPS Channel Binding is set to "When Supported"')
209-
elif ldapsChannelBindingAlwaysCheck is False and ldapsChannelBindingWhenSupportedCheck is False:
210-
context.log.highlight('LDAPS Channel Binding is set to "NEVER"')
211-
elif ldapsChannelBindingAlwaysCheck is True:
212-
context.log.fail('LDAPS Channel Binding is set to "Required"')
237+
context.log.fail("Could not determine LDAP signing requirement.")
238+
239+
if self.does_ldaps_complete_handshake(context, connection.host):
240+
target = MSLDAPTarget(
241+
connection.host, 636,
242+
UniProto.CLIENT_SSL_TCP,
243+
hostname=connection.remoteName,
244+
domain=connection.domain,
245+
dc_ip=connection.kdcHost,
246+
)
247+
ldaps_noEPA = asyncio.run(self.run_ldaps_noEPA(context, connection, target, anon_credential))
248+
ldaps_withEPA = asyncio.run(self.run_ldaps_withEPA(context, connection, target, anon_credential))
249+
250+
if ldaps_noEPA is False and ldaps_withEPA is True:
251+
context.log.highlight("LDAPS channel binding is set to: When Supported")
252+
elif ldaps_noEPA is False and ldaps_withEPA is False:
253+
context.log.highlight("LDAPS channel binding is set to: Never")
254+
elif ldaps_noEPA is True:
255+
context.log.highlight("LDAPS channel binding is set to: Required")
213256
else:
214-
context.log.fail("\nSomething went wrong...")
215-
sys.exit()
257+
context.log.fail("Could not determine LDAPS channel binding settings")
216258
else:
217-
context.log.fail(connection.domain + " - cannot complete TLS handshake, cert likely not configured")
259+
context.log.fail(f"{connection.domain} - TLS handshake failed; certificate likely not configured")

nxc/protocols/ldap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ def check_if_admin(self):
573573
attributes = ["objectSid"]
574574
resp = self.search(search_filter, attributes, sizeLimit=0)
575575
answers = []
576-
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "") and self.username != "":
576+
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "") and self.username != "":
577577
for attribute in resp[0][1]:
578578
if str(attribute["type"]) == "objectSid":
579579
sid = self.sid_to_str(attribute["vals"][0])

0 commit comments

Comments
 (0)