How Doom Manages LDAP Connections: Protocol Fallbacks and NTLM Authentication

Doom manages LDAP connections through a two-stage fallback strategy that attempts plain LDAP on port 389 before automatically escalating to LDAPS on port 636, handling NTLM authentication with automatic hash detection and providing safe attribute accessors for robust directory queries.

Doom, an open-source Active Directory security tool, centralizes its LDAP handling in the doom.protocols.ldap module. The implementation features intelligent protocol negotiation and credential preprocessing to support both standard passwords and NTLM hashes. This architecture ensures reliable authentication across diverse domain controller configurations while maintaining session persistence throughout the UI lifecycle.

Core LDAP Connection Architecture

The get_ldap_connection Function

The primary entry point for LDAP connectivity resides in src/doom/protocols/ldap.py. The get_ldap_connection function accepts four parameters—host, username, password, and domain—and orchestrates the entire authentication flow. It constructs the NTLM user string using the domain\username format and prepares connection objects for both plain LDAP and LDAPS protocols before attempting sequential binds.

NTLM Authentication and Hash Detection

Doom automatically detects raw NTLM hashes to support pass-the-hash scenarios. When the supplied password matches a 32-character hexadecimal pattern, the function prepends the well-known LM hash prefix aad3b435b51404eeaad3b435b51404ee before binding. This preprocessing allows the tool to authenticate using hash-only credentials against domain controllers without requiring plaintext passwords.

def get_ldap_connection(host: str, username: str, password: str, domain: str):
    """Try LDAP on 389, then LDAPS on 636."""
    user = f"{domain}\\{username}"

    # Detect raw NTLM hash and prepend the known prefix

    if len(password) == 32 and all(c in "0123456789abcdefABCDEF" for c in password):
        password = f"aad3b435b51404eeaad3b435b51404ee:{password}"

    tls = ldap3.Tls(validate=ssl.CERT_NONE,
                    version=ssl.PROTOCOL_TLSv1_2,
                    ciphers="ALL:@SECLEVEL=0")

    ldaps_server = ldap3.Server(f"ldaps://{host}", port=636,
                                use_ssl=True, get_info=ldap3.ALL, tls=tls)
    ldap_server = ldap3.Server(f"ldap://{host}", port=389,
                               use_ssl=False, get_info=ldap3.ALL)

    # 1️⃣ Try plain LDAP first

    try:
        conn = ldap3.Connection(
            server=ldap_server,
            user=user,
            password=password,
            authentication=ldap3.NTLM,
            auto_bind=True,
            session_security="ENCRYPT",
            auto_referrals=False,
            raise_exceptions=True,
        )
        base_dn = conn.server.info.naming_contexts[0]
        return conn, base_dn
    except LDAPBindError as e:
        if "strongerAuthRequired" not in str(e):
            raise Exception(f"LDAP bind error: {e}")

    # 2️⃣ Fallback to LDAPS

    try:
        conn = ldap3.Connection(
            server=ldaps_server,
            user=user,
            password=password,
            authentication=ldap3.NTLM,
            auto_bind=True,
            auto_referrals=False,
            raise_exceptions=True,
        )
        base_dn = conn.server.info.other.get("defaultNamingContext", ["<none>"])[0]
        return conn, base_dn
    except Exception as e:
        raise Exception(f"LDAPS bind failed: {e}")

Two-Stage Protocol Fallback Strategy

Plain LDAP Attempt on Port 389

Doom initially attempts to establish connectivity using unencrypted LDAP on port 389. This connection utilizes session_security="ENCRYPT" to negotiate encryption after the bind. Upon successful authentication, the function extracts the base DN from conn.server.info.naming_contexts[0], which represents the root naming context of the directory.

Automatic LDAPS Escalation on Port 636

If the initial bind fails with a strongerAuthRequired error—or any other exception—the implementation immediately falls back to LDAPS on port 636. The LDAPS configuration explicitly disables certificate validation using ssl.CERT_NONE and forces TLS 1.2 with permissive cipher suites (ciphers="ALL:@SECLEVEL=0"). This configuration ensures compatibility with domain controllers using self-signed or enterprise certificates without manual trust store management. For LDAPS connections, the base DN retrieves from conn.server.info.other.get("defaultNamingContext", ["<none>"])[0].

Safe Attribute Access with safe_ldap_attr

To prevent crashes when processing LDAP entries with missing or optional attributes, src/doom/protocols/ldap.py provides the safe_ldap_attr helper. This function wraps attribute access with exception handling for LDAPCursorAttributeError and AttributeError, returning a configurable fallback value when fields are absent.

def safe_ldap_attr(entry, attr_name, fallback=None) -> None:
    """Return a LDAP attribute value or a fallback to avoid exceptions."""
    try:
        attr = getattr(entry, attr_name, None)
        return attr.value if attr else fallback
    except (AttributeError, LDAPCursorAttributeError):
        return fallback

Downstream modules such as src/doom/modules/enumerate_templates.py utilize this helper exclusively when extracting certificate template attributes, ensuring robust enumeration even against non-standard directory schemas.

UI Integration and Session Management

Asynchronous Connection in LoadingScreen

The UI layer handles LDAP authentication in src/doom/screens/loading_screen.py to prevent blocking the interface. The LoadingScreen gathers credentials from form inputs and executes get_ldap_connection within an async thread using asyncio.to_thread. Upon success, both the connection object and base DN are stored as instance variables for transition to the main interface.

async def authenticate_ldap(self) -> None:
    status_label = self.query_one("#status-label")
    try:
        status_label.update("Attempting LDAP connection...")
        connection_result = await asyncio.to_thread(
            get_ldap_connection,
            host=self.login_data.get('ip', ''),
            username=self.login_data.get('username', ''),
            password=self.login_data.get('password', ''),
            domain=self.login_data.get('domain', '')
        )
        if connection_result:
            self.ldap_connection, self.base_dn = connection_result
            status_label.update("Authentication successful!")
            # Switch to the main UI screen...

    except Exception as e:
        status_label.update(f"Authentication error: {e}")

Connection Persistence in MainScreen

MainScreen receives the active connection and base DN from the loading screen, maintaining these objects for the entire UI session. This persistence allows enumeration modules to execute multiple queries without re-authenticating. The connection remains bound until explicit cleanup occurs, optimizing performance for extended reconnaissance operations.

LDAP Connection Cleanup

Both LoadingScreen and MainScreen implement explicit cleanup protocols by calling self.ldap_connection.unbind() during logout or application exit. This method gracefully terminates the TCP session and releases directory server resources, preventing stale connection accumulation during long-running engagements.

Summary

  • Two-stage fallback: Doom attempts LDAP on port 389 before automatically escalating to LDAPS on port 636 when authentication requires stronger security.
  • Certificate bypass: The LDAPS configuration disables certificate validation and uses permissive TLS settings to support self-signed enterprise certificates.
  • Hash support: Automatic detection of 32-character NTLM hashes with LM hash prefixing enables pass-the-hash authentication without plaintext credentials.
  • Safe access: The safe_ldap_attr helper prevents crashes when querying optional LDAP attributes across diverse directory schemas.
  • Async architecture: The UI executes LDAP operations in background threads to maintain responsiveness during authentication and enumeration.
  • Session reuse: Active connections persist across UI screens, eliminating redundant authentication overhead during multi-phase operations.

Frequently Asked Questions

How does Doom handle self-signed certificates on LDAPS connections?

Doom explicitly disables certificate validation by configuring the ldap3.Tls object with validate=ssl.CERT_NONE and ciphers="ALL:@SECLEVEL=0" in src/doom/protocols/ldap.py. This allows the tool to establish LDAPS connections on port 636 against domain controllers using self-signed or internally-issued certificates without requiring manual trust store modifications.

What authentication method does Doom use for LDAP binds?

Doom utilizes NTLM authentication exclusively for LDAP connections. The get_ldap_connection function constructs the user principal in domain\username format and passes credentials with authentication=ldap3.NTLM to the underlying ldap3 library, supporting both plaintext passwords and NTLM hashes.

How does Doom differentiate between plaintext passwords and NTLM hashes?

The connection logic checks if the password parameter is exactly 32 characters and contains only hexadecimal characters (0-9, a-f, A-F). When these criteria match, Doom prepends the LM hash prefix aad3b435b51404eeaad3b435b51404ee: to convert the raw NT hash into a format compatible with NTLM authentication.

What happens if the LDAP connection fails on both ports?

If the initial LDAP attempt on port 389 fails with strongerAuthRequired or any other error, Doom automatically attempts LDAPS on port 636. Should both attempts fail, the get_ldap_connection function raises an exception containing the specific bind error message, which the UI layer catches and displays to the user without crashing the application.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →