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:
| Character | Escaped 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.