LDAP Injection: Authentication Bypass in Directory Service Integrations

LDAP injection lets attackers bypass authentication, enumerate directory entries, and extract sensitive attributes by manipulating filter strings passed to Active Directory or OpenLDAP. This guide covers the attack mechanics and the safe query patterns that eliminate it.

LDAP injection is the directory service equivalent of SQL injection. When an application builds LDAP filter strings by concatenating user input, an attacker can inject LDAP metacharacters to change the logic of the query — bypassing authentication, extracting attributes outside the intended scope, or enumerating directory entries.

Unlike SQL injection, LDAP injection gets less attention because LDAP integrations tend to be narrower in scope. But the impact is high: a successful authentication bypass against an Active Directory-integrated application gives an attacker access under a legitimate identity.

How LDAP Filters Work

LDAP uses filter strings in prefix (Polish) notation. A filter checking username and password looks like this:

(&(uid=alice)(userPassword=secret))

Read: AND of (uid=alice) AND (userPassword=secret). The & is a logical AND; | is OR; ! is NOT.

When an application constructs this filter from user input:

filter_str = f"(&(uid={username})(userPassword={password}))"

…an attacker who enters *)(uid=*))(|(uid=* as the username changes the filter to:

(&(uid=*)(uid=*))(|(uid=*)(userPassword=secret))

The (&(uid=*)(uid=*)) portion is always true, and the overall filter returns all users. Authentication is bypassed.

Attack Patterns

1. Authentication Bypass

The classic attack. By injecting * as a wildcard or using the (|(condition=*)) pattern, an attacker causes the filter to match all entries regardless of password:

  • Username: admin)(&) — results in (&(uid=admin)(&)(userPassword=anything)), where (&) is always true in many LDAP implementations
  • Username: * — often matches all entries if the application logic accepts the first result

2. Attribute Enumeration (Blind LDAP Injection)

When the application doesn’t return LDAP errors but behaves differently based on whether a filter matches (login success vs. failure), attackers can use boolean-based blind injection to extract attribute values character by character:

# Test if admin password starts with 'a'
uid=admin)(userPassword=a*

By iterating over characters and comparing application responses, the full attribute value can be extracted.

3. DN Injection

Distinguished Name injection affects applications that construct DNs from input:

# Vulnerable DN construction
dn = f"cn={username},ou=users,dc=example,dc=com"

Injecting alice,ou=admins into the username slot can redirect the bind to a different OU: cn=alice,ou=admins,ou=users,dc=example,dc=com — potentially accessing a privileged container.

4. Scope Manipulation

Applications that construct search bases or scopes from input can be manipulated to broaden scope beyond the intended subtree, allowing enumeration of directory entries the user should not see.

Exploitation Example

A login form that builds this filter:

# Vulnerable
def authenticate(username, password):
    filter_str = f"(&(sAMAccountName={username})(objectClass=user))"
    conn.search("dc=corp,dc=example,dc=com", filter_str, attributes=["dn"])
    if conn.entries:
        # Bind to verify password
        user_dn = conn.entries[0].entry_dn
        return bind_as_user(user_dn, password)

With input *)(objectClass=*) the filter becomes:

(&(sAMAccountName=*)(objectClass=*)(objectClass=user))

The search returns all AD users. The application then binds as the first result — often an admin account.

Safe Query Construction

Escape Special Characters

RFC 4515 defines the characters that must be escaped in LDAP filter values. The special characters are: ( ) * \ NUL — escape them as their hex representation:

CharacterEscaped form
*\2a
(\28
)\29
\\5c
NUL\00

Python with ldap3 (safe pattern):

from ldap3 import Server, Connection, SUBTREE
from ldap3.utils.conv import escape_filter_chars

def authenticate(username, password):
    safe_username = escape_filter_chars(username)
    filter_str = f"(&(sAMAccountName={safe_username})(objectClass=user))"
    
    server = Server("ldap://dc.corp.example.com")
    conn = Connection(server, auto_bind=True)
    conn.search(
        "dc=corp,dc=example,dc=com",
        filter_str,
        search_scope=SUBTREE,
        attributes=["distinguishedName"]
    )
    
    if not conn.entries:
        return False
    
    user_dn = str(conn.entries[0].distinguishedName)
    # Verify password by binding as the user
    user_conn = Connection(server, user=user_dn, password=password)
    return user_conn.bind()

Java (safe pattern using Spring LDAP):

import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;

public boolean authenticate(String username, String password) {
    // Spring LDAP filter objects handle escaping automatically
    AndFilter filter = new AndFilter();
    filter.and(new EqualsFilter("sAMAccountName", username));
    filter.and(new EqualsFilter("objectClass", "user"));
    
    return ldapTemplate.authenticate(
        LdapUtils.emptyLdapName(),
        filter.encode(),
        password
    );
}

Node.js with ldapjs (safe pattern):

const ldap = require('ldapjs');

function escapeFilter(value) {
    return value
        .replace(/\\/g, '\\5c')
        .replace(/\*/g, '\\2a')
        .replace(/\(/g, '\\28')
        .replace(/\)/g, '\\29')
        .replace(/\0/g, '\\00');
}

async function authenticate(username, password) {
    const safeUsername = escapeFilter(username);
    const filter = `(&(sAMAccountName=${safeUsername})(objectClass=user))`;
    
    return new Promise((resolve, reject) => {
        client.search('dc=corp,dc=example,dc=com', { filter }, (err, res) => {
            if (err) { reject(err); return; }
            
            res.on('searchEntry', (entry) => {
                const dn = entry.objectName;
                // Verify password by binding
                const userClient = ldap.createClient({ url: 'ldap://dc.corp.example.com' });
                userClient.bind(dn, password, (bindErr) => {
                    resolve(!bindErr);
                    userClient.destroy();
                });
            });
        });
    });
}

Validate Input Before Escaping

Escaping is the primary defence, but input validation adds defence in depth. A username that contains (, ), or * is almost certainly malicious — no legitimate AD username contains these characters. Reject input that fails a allowlist of expected characters before it reaches the LDAP layer.

import re

def validate_username(username: str) -> bool:
    # AD sAMAccountName: letters, digits, spaces, hyphens, underscores, periods
    return bool(re.match(r'^[\w.\- ]{1,20}$', username))

Escape DN Components Separately

When constructing DNs rather than filters, RFC 4514 applies different escaping rules. Characters requiring escape in a DN include ,, +, ", \, <, >, ;, and leading/trailing spaces:

from ldap3.utils.dn import escape_rdn

def get_user_dn(username, base_dn):
    safe_rdn = escape_rdn(f"cn={username}")
    return f"{safe_rdn},{base_dn}"

Privilege Separation

Beyond escaping, limit what the service account used for LDAP searches can do:

  • The application’s LDAP service account should have read-only access scoped to the specific OU containing users
  • It should not have access to privileged OUs or sensitive attributes (password hashes, etc.)
  • Bind credentials for the service account should be stored in a secrets manager, not configuration files

Testing for LDAP Injection

Manual testing: inject *, )(, )(|(, and *)(objectClass=*)(cn= into any input field that authenticates against LDAP. Observe whether unexpected authentication succeeds or error messages reveal filter structure.

Automated testing: Burp Suite’s active scanner detects LDAP injection. OWASP’s testing guide (WSTG-INPV-06) covers the full manual methodology.

The safe fix is always the same: escape filter values using the library’s built-in escaping function before concatenation, and validate input at the boundary. Never build LDAP filter strings through string formatting without escaping.