CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection') - Python
Overview
LDAP injection in Python applications happens when untrusted input is used to build LDAP search filters or Distinguished Names (DNs) via string concatenation or f-strings. Neither python-ldap (legacy, C-based) nor ldap3 (modern, pure Python, recommended) escapes a filter string you hand it - escaping is the application's responsibility. Each ships a helper you have to call: ldap.filter.filter_format('(uid=%s)', [username]) in python-ldap, which escapes every assertion value as it substitutes, and ldap3.utils.conv.escape_filter_chars() in ldap3, which escapes one value at a time.
Primary Defence: Validate input against a strict allowlist, escape any value used in a filter with ldap3.utils.conv.escape_filter_chars() (RFC 4515) or a DN/RDN component with ldap3.utils.dn.escape_rdn() (RFC 4514), never accept bare wildcards in authentication queries, and prefer a search-then-bind flow over constructing DNs from input.
Common Vulnerable Patterns
String Concatenation in a Search Filter
# VULNERABLE - user input directly in the filter
import ldap
def vulnerable_ldap_auth(username, password):
conn = ldap.initialize('ldap://localhost:389')
search_filter = f"(uid={username})" # VULNERABLE
try:
result = conn.search_s('ou=users,dc=example,dc=com', ldap.SCOPE_SUBTREE, search_filter)
if result:
user_dn = result[0][0]
conn.simple_bind_s(user_dn, password)
return True
except ldap.INVALID_CREDENTIALS:
return False
return False
# Attack: username = "*"
# Resulting filter: (uid=*) - matches every entry, so the code goes on to bind as whichever
# account the directory returns first. simple_bind_s() still checks the password, so what the
# attacker gains is control over which account is probed and a directory enumeration
# primitive - not a password-free login. The next pattern is where the bypass is real.
Why this is vulnerable: An LDAP filter is a parenthesised expression tree, not a flat string, so the metacharacters an attacker needs are structural: ) closes the current term and (| opens an OR that is trivially satisfied. * alone turns an equality test into a wildcard, which is why (uid=*) matches every entry and authentication checks written as filters can be answered without a password.
Filters and distinguished names need different escaping and this is where a partial fix usually goes wrong. RFC 4515 governs filter values - escape *, (, ), \ and NUL as \XX hex - while RFC 4514 governs DN components, where the significant characters are ,, +, ", \, <, >, ;, =, NUL, a leading #, and a leading or trailing space. An encoder written for one leaves the other injectable.
Password Compared Inside the Filter
# VULNERABLE - the credential check is filter syntax, so injection decides the login
def vulnerable_filter_auth(username, password):
conn = ldap.initialize('ldap://localhost:389')
conn.simple_bind_s('cn=service,dc=example,dc=com', SERVICE_PASSWORD)
search_filter = f"(&(uid={username})(userPassword={password}))" # VULNERABLE
results = conn.search_s('ou=users,dc=example,dc=com', ldap.SCOPE_SUBTREE, search_filter)
return bool(results) # "a match means the password was right"
# Attack: username = "admin", password = "*"
# Resulting filter: (&(uid=admin)(userPassword=*)) - `*` is a presence test, so it matches any
# admin account that has a password set at all, and the caller is authenticated without
# knowing it. No bind happens, so nothing else checks the credential.
Why this is vulnerable: Once the password is part of the filter, filter syntax and the credential check are the same thing, and a metacharacter in either field decides the outcome. Escaping both values closes the injection, but the pattern is still wrong: the directory only answers a userPassword comparison when the bind account can read or compare that attribute, and it compares against the stored hash format rather than the password. Verify credentials with a bind, as the secure pattern below does.
DN Injection and Attacker-Controlled Attribute Names
# VULNERABLE - user input in a Distinguished Name
def vulnerable_dn_lookup(org_unit, user_id):
conn = ldap.initialize('ldap://localhost:389')
user_dn = f"uid={user_id},ou={org_unit},dc=example,dc=com" # VULNERABLE
try:
conn.simple_bind_s(user_dn, 'some_password')
return True
except ldap.INVALID_CREDENTIALS:
return False
# Attack: user_id = "svc-backup,ou=service-accounts", org_unit = "users"
# Resulting DN: uid=svc-backup,ou=service-accounts,ou=users,dc=example,dc=com - the injected
# comma adds an RDN, so the bind targets an entry in a different OU than the code built the
# DN for
# VULNERABLE - caller controls which attribute is returned
def vulnerable_attribute_search(username, attribute_name):
conn = ldap.initialize('ldap://localhost:389')
conn.simple_bind_s('cn=admin,dc=example,dc=com', 'password')
search_filter = f"(uid={username})"
results = conn.search_s('ou=users,dc=example,dc=com', ldap.SCOPE_SUBTREE, search_filter, [attribute_name])
if results:
return results[0][1].get(attribute_name, [b''])[0].decode('utf-8')
# Attack: attribute_name = "userPassword" - discloses a sensitive attribute the caller
# should never have been able to select directly
Why these are vulnerable: Neither library escapes what you hand it. Special characters in the filter (*, (, ), \) change its logic; special characters in a DN (,, +, ", \, <, >, ;, =) change which object is addressed; and an unvalidated attribute name lets the caller request attributes the application never intended to expose.
Secure Patterns
ldap3 with escape_filter_chars (Primary)
# SECURE - ldap3 with allowlist validation + RFC 4515 escaping
import os
import re
from ldap3 import Server, Connection, ALL, NO_ATTRIBUTES
from ldap3.utils.conv import escape_filter_chars
USERNAME_PATTERN = re.compile(r'[a-zA-Z0-9._-]{3,64}')
def authenticate_user(username, password):
# Step 1: allowlist validation - rejects most injection attempts outright
# fullmatch, not match: a `$` anchor also matches before a trailing newline
if not USERNAME_PATTERN.fullmatch(username):
raise ValueError('Invalid username format')
# A blank password field must never reach the bind below - see the note after this example
if not password:
return False
server = Server('ldaps://ldap.example.com:636', get_info=ALL)
search_conn = Connection(server, 'cn=service,dc=example,dc=com',
os.environ['LDAP_SERVICE_PASSWORD'], auto_bind=True)
# Step 2: escape the (already-validated) value - defense in depth
safe_username = escape_filter_chars(username)
# NO_ATTRIBUTES ('1.1') asks for the entry with no attributes; only the DN is needed here
search_conn.search('ou=users,dc=example,dc=com', f"(&(objectClass=person)(uid={safe_username}))",
attributes=[NO_ATTRIBUTES])
if not search_conn.entries:
search_conn.unbind()
return False
user_dn = str(search_conn.entries[0].entry_dn)
search_conn.unbind()
user_conn = Connection(server, user_dn, password)
authenticated = user_conn.bind()
user_conn.unbind()
return authenticated
Why this works: escape_filter_chars() converts LDAP filter special characters (*, (, ), \, NUL) to their RFC 4515 backslash-hex form, so an attacker-supplied *)(uid=*))(|(uid=* becomes a literal string rather than filter syntax. Allowlist validation catches obviously malicious input before it reaches LDAP at all; escaping ensures anything that passes validation still can't carry filter operators. The two-step flow - search for the user with a service account, then bind as the returned DN - never trusts a DN the caller supplied, and leaves the password to the directory's own bind check rather than to a filter.
Two details in that example are easy to miss, and getting either wrong produces a function that fails on legitimate logins rather than an insecure one:
attributes=[NO_ATTRIBUTES], notattributes=['dn']. A DN is not an attribute type, so'dn'is not a name the schema knows. ldap3 checks requested attribute names against the schema whenever it has one - whichget_info=ALLguarantees against a real directory - and raisesLDAPAttributeError: invalid attribute type dnbefore the search is sent.NO_ATTRIBUTESis ldap3's constant for the1.1OID, the LDAP way of asking for the entry and none of its attributes. The DN arrives either way, onentry_dn.- The empty-password guard is about the client's behaviour, not the server's. ldap3 refuses to send a simple bind with an empty password at all, raising
LDAPPasswordIsMandatoryError, so without the guard a blank form field surfaces as an unhandled exception rather than a failed login. Clients that do send it - python-ldap'ssimple_bind_s(dn, '')among them - issue what RFC 4513 calls an unauthenticated bind request, which a directory may answer with success while granting only anonymous access; code that reads that result as "the password was correct" has an authentication bypass. Check for the empty password yourself rather than relying on either behaviour.
DN Escaping and Attribute Allowlisting
# SECURE - escape_rdn for DN components; allowlist for returnable attributes
from ldap3.utils.dn import escape_rdn
ALLOWED_ATTRIBUTES = {'cn', 'mail', 'telephoneNumber', 'title'}
def secure_dn_construction(org_unit, user_id):
if not re.fullmatch(r'[a-zA-Z0-9]+', org_unit):
raise ValueError('Invalid org unit')
if not re.fullmatch(r'[a-zA-Z0-9._-]+', user_id):
raise ValueError('Invalid user ID')
# escape_rdn uses RFC 4514 rules, distinct from filter escaping
return f"uid={escape_rdn(user_id)},ou={escape_rdn(org_unit)},dc=example,dc=com"
def secure_attribute_search(username, attribute_name):
if attribute_name not in ALLOWED_ATTRIBUTES:
raise ValueError('Attribute not permitted')
safe_username = escape_filter_chars(username)
# ... search using safe_username and the now-allowlisted attribute_name
Why this works: escape_rdn() escapes the character set RFC 4514 assigns structural meaning to within a DN (,, +, ", \, <, >, ;, =) - a different set than filter escaping, so the two functions aren't interchangeable. Even with escaping, constructing DNs from input is riskier than searching by attribute and using the DN the directory returns; reserve manual DN construction for a fixed, trusted base DN with only a validated RDN component from input. The attribute allowlist closes the second vulnerability directly: a caller can request cn or mail, never userPassword or anything else outside the set.
Testing
*as the username - the payload that actually works against a single-term filter. Unescaped it turns(uid=alice)into(uid=*), a presence test matching every entry; afterescape_filter_chars()the search returns nothing*as the password, if any filter still compares one - confirm authentication fails*)(uid=*))(|(uid=*- the payload most write-ups quote, worth running only against the fixed code. Interpolated into(uid={username})it produces(uid=*)(uid=*))(|(uid=*), which is two top-level filters rather than one; ldap3 rejects that in its own parser before anything is sent, so the unescaped run raises instead of leaking and the test cannot tell a working fix from a broken filter. Escaped, it becomes the literal assertion value\2a\29\28uid=\2a\29\29\28|\28uid=\2aand matches nothingsvc-backup,ou=service-accountsas a user-ID value - confirm the comma is escaped into the RDN value and the DN still addresses the intended OUuserPasswordas a requested attribute name (if attributes are caller-influenced) - confirm it's rejected- A username with a trailing newline - confirm the allowlist refuses it.
re.match()against a$-anchored pattern acceptsalicefollowed by a newline, because Python's$also matches immediately before a final newline;re.fullmatch()does not - A legitimate username and search term - confirm normal lookups still succeed after adding validation
Common Pitfalls
- Calling
escape_filter_chars()on a value destined for a DN, orescape_rdn()on a value destined for a filter - the two functions implement different RFCs (4515 vs 4514) and aren't interchangeable. - Hand-rolling sanitization with
username.replace('*', '')instead of callingescape_filter_chars()- a manualreplace()for one character misses the others ((,),\, NUL) that also have syntactic meaning in a filter. - Escaping the primary search value but leaving a second interpolated value unescaped in the same compound filter, e.g.
f"(&(uid={safe_username})(department={department}))"wheredepartmentnever passes throughescape_filter_chars()- each interpolated value needs its own escaping call, not just the one the developer was focused on.