CWE-90: LDAP Injection - C# / .NET
Overview
LDAP Injection occurs when untrusted data is used to construct LDAP queries without proper encoding, allowing attackers to manipulate LDAP searches and potentially access unauthorized data.
Primary Defence:
- Strict allowlist validation - Restrict usernames/input to expected patterns (e.g.,
^[a-zA-Z0-9._-]{3,64}$), then escape any remaining special characters (*,(,),\) - Search by attribute, use returned DN - Never construct DNs from user input; search first and use the trusted DN returned by the directory
- Manual encoding (when needed) - For systems requiring Unicode or special characters in names, use RFC-compliant encoding (see "Manual LDAP Encoding" section) or the legacy Microsoft AntiXSS library if compatible
IMPORTANT WARNING: Unlike HTML/JavaScript/URL encoding, .NET does not provide first-party encoder helpers for LDAP filter/DN strings.
Common Vulnerable Patterns
Direct String Concatenation in LDAP Queries
// VULNERABLE - No encoding
using System.DirectoryServices; // .NET 6+ Windows-only; for cross-platform use System.DirectoryServices.Protocols
public User FindUser(string username)
{
string filter = $"(uid={username})"; // Dangerous!
DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com");
DirectorySearcher searcher = new DirectorySearcher(entry);
searcher.Filter = filter;
SearchResult result = searcher.FindOne();
return MapToUser(result);
}
Building DN Paths Without Encoding
// VULNERABLE - Constructing DN from untrusted input
public DirectoryEntry BindToUserByConstructedDn(string username, string ouName)
{
// Dangerous: building DN path from user input
string dnPath = $"CN={username},OU={ouName},DC=example,DC=com";
DirectoryEntry user = new DirectoryEntry($"LDAP://{dnPath}");
return user;
// This binds to an entry, but doesn't validate the path is safe
}
Secure Patterns
Input Validation with Allowlist (Recommended for Most Cases)
// SECURE - Recommended approach for typical LDAP deployments
using System.DirectoryServices;
using System.Text.RegularExpressions;
public class LdapQueryService
{
// Allowlist patterns for expected input format
private static readonly Regex UsernamePattern = new Regex(@"^[a-zA-Z0-9._-]{3,64}$");
private static readonly Regex DepartmentPattern = new Regex(@"^[a-zA-Z0-9\s]{1,100}$");
public User FindUser(string username)
{
// Step 1: Validate against allowlist
if (!UsernamePattern.IsMatch(username))
{
throw new ArgumentException("Invalid username format", nameof(username));
}
// Step 2: Escape remaining LDAP special characters (defense in depth)
// Even with allowlist, escape *, (, ), \ in case pattern allows them
string safeUsername = LdapEncoder.EscapeFilterValue(username); // RFC 4515
string filter = $"(uid={safeUsername})";
using (DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(entry))
{
searcher.Filter = filter;
return MapToUser(searcher.FindOne());
}
}
public List<User> FindByDepartment(string department)
{
if (string.IsNullOrWhiteSpace(department))
{
throw new ArgumentException("Department cannot be empty", nameof(department));
}
if (!DepartmentPattern.IsMatch(department))
{
throw new ArgumentException("Invalid department format", nameof(department));
}
string safeDept = LdapEncoder.EscapeFilterValue(department); // RFC 4515
string filter = $"(department={safeDept})";
using (DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(entry))
{
searcher.Filter = filter;
return MapToUsers(searcher.FindAll());
}
}
// Limited escape for LDAP filter values under strict ASCII allowlists.
// Prefer LdapEncoder.EscapeFilterValue for RFC-compliant UTF-8 byte escaping.
private static string EscapeBasicLdapChars(string input)
{
return input
.Replace("\\", "\\5c") // \ must be first
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00"); // NUL
}
}
Why this works:
- Most LDAP deployments restrict usernames to alphanumeric characters with limited punctuation (
.,_,-). - Strict allowlist validation rejects any input outside this expected format, eliminating most injection attempts before they reach LDAP.
- The allowlist pattern should match your organization's actual username/attribute format - consult your directory schema or identity team.
- After validation, a simple escape of the few remaining LDAP special characters (
*,(,),\) provides defense-in-depth. - This two-layer approach is simpler, safer, and easier to audit than complex encoding.
- For DN construction, reject any input containing DN special characters (
,,=,+, etc.) or preferably use Pattern 3 (search first, use returned DN). Most applications never need to construct DNs from user input.
When NOT to use this pattern: If your organization genuinely requires Unicode characters, international names with diacritics, or special characters in LDAP attributes, use the "Manual LDAP Encoding" pattern below or AntiXSS library.
Using Microsoft AntiXSS Library (Legacy Systems)
// SECURE - For legacy .NET Framework applications
// WARNING: AntiXSS library is unmaintained and may not work on .NET Core/5+/6+
using Microsoft.Security.Application;
using System.DirectoryServices;
public User FindUser(string username)
{
// Encode the username for safe use in LDAP filter
string safeUsername = Encoder.LdapFilterEncode(username);
string filter = $"(uid={safeUsername})";
using (DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(entry))
{
searcher.Filter = filter;
SearchResult result = searcher.FindOne();
return MapToUser(result);
}
}
// For Distinguished Names
public void UpdateUserByDn(string username, string ouName)
{
string safeCN = Encoder.LdapDistinguishedNameEncode(username);
string safeOU = Encoder.LdapDistinguishedNameEncode(ouName);
string dnPath = $"CN={safeCN},OU={safeOU},DC=example,DC=com";
using (DirectoryEntry user = new DirectoryEntry($"LDAP://{dnPath}"))
{
// ... update user properties
user.CommitChanges();
}
}
// NuGet: Install-Package AntiXSS
Why this works:
- The Microsoft AntiXSS library provides
Encoder.LdapFilterEncode()which escapes LDAP filter special characters (*,(,),\,NUL) per RFC 4515, andEncoder.LdapDistinguishedNameEncode()which escapes DN special characters (,,+,",\,<,>,;) per RFC 4514. - These methods convert special characters to backslash-hex escape sequences, preventing attackers from injecting filter operators or DN components.
- However, this library is legacy/unmaintained and may not be compatible with modern .NET versions (Core, 5+, 6+). Verify compatibility before use, and prefer allowlist validation (Pattern 1) or manual RFC-compliant encoding (see "Manual LDAP Encoding" section) for new projects.
Manual LDAP Encoding (When Broader Input Required)
// SECURE - Use when Unicode/special characters genuinely needed
// IMPORTANT: Only use this if allowlist validation (Pattern 1) is too restrictive
// Most organizations should prefer strict allowlisting for simplicity and safety
// See "Manual LDAP Encoding" section at bottom for LdapEncoder implementation
using System.DirectoryServices;
public User FindUser(string username)
{
// Use manual RFC-compliant encoding (implementation at bottom of document)
string safeUsername = LdapEncoder.EscapeFilterValue(username);
string filter = $"(uid={safeUsername})";
using (DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(entry))
{
searcher.Filter = filter;
SearchResult result = searcher.FindOne();
return MapToUser(result);
}
}
// SECURE - Complex filter with multiple parameters
public List<User> SearchUsers(string firstName, string lastName, string department)
{
string safeFirst = LdapEncoder.EscapeFilterValue(firstName);
string safeLast = LdapEncoder.EscapeFilterValue(lastName);
string safeDept = LdapEncoder.EscapeFilterValue(department);
string filter = $"(&(givenName={safeFirst})(sn={safeLast})(department={safeDept}))";
using (DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(entry))
{
searcher.Filter = filter;
SearchResultCollection results = searcher.FindAll();
return MapToUsers(results);
}
}
Why this works:
- RFC-compliant LDAP encoding escapes special characters like
*,(,),\, andNULthat have syntactic meaning in LDAP filter expressions. - The manual
LdapEncoder.EscapeFilterValue()implementation (see bottom of document) converts these special characters to backslash-hex escape sequences (e.g.,*becomes\2a,(becomes\28), treating user input as literal data rather than filter operators. - This approach handles Unicode characters, international names with diacritics, and other special characters that strict allowlisting would reject.
- Use this only when your organization genuinely needs to support such characters - for most deployments, allowlist validation is simpler, safer, and sufficient.
Using Distinguished Name Encoding (When Necessary)
// SECURE-ISH - But prefer searching by attribute instead (see 'Search by Attribute, Use Returned DN')
// Do not accept an entire DN from the user (even if 'escaped');
// only accept a constrained name (e.g., from an allowlisted set)
// and build the DN under a fixed base, or search and use returned DNs.
public void OpenUserByConstructedDnAndUpdate(string username, string ouName)
{
string safeCN = LdapEncoder.EscapeDnValue(username); // Legacy AntiXSS: Encoder.LdapDistinguishedNameEncode(...)
string safeOU = LdapEncoder.EscapeDnValue(ouName); // Legacy AntiXSS: Encoder.LdapDistinguishedNameEncode(...)
string dnPath = $"CN={safeCN},OU={safeOU},DC=example,DC=com";
using (DirectoryEntry user = new DirectoryEntry($"LDAP://{dnPath}"))
{
// ... update user properties
user.CommitChanges();
}
}
// IMPORTANT: Prefer 'Search by Attribute, Use Returned DN' below - search first, then use returned DN
Why this works:
- Distinguished Name encoding escapes DN-specific special characters like
,,+,",\,<,>,;,=, and leading/trailing spaces that have structural meaning in LDAP DNs. - Attackers can inject DN components to traverse the directory tree (e.g.,
username = "admin,OU=Admins,DC=evil,DC=com"breaks out of the intended path). LdapEncoder.EscapeDnValue()escapes these characters so they're treated as literal RDN values, not DN separators.- However, this pattern is a compromise - constructing DNs from user input is inherently risky even with encoding.
- An attacker controlling the entire DN structure (even escaped) may access unintended directory locations.
- Best practice is to use the 'Search by Attribute, Use Returned DN' approach: search for the object by attribute and use the DN returned by the directory, which is fully trusted.
Preferred: Search by Attribute, Use Returned DN
// SECURE - BEST PRACTICE: Don't construct DN from user input
using System.Text.RegularExpressions;
public void MoveUserToOU(string username, string targetOuName)
{
// Step 1: Validate username against allowlist
if (!Regex.IsMatch(username, @"^[a-zA-Z0-9._-]{3,64}$"))
{
throw new ArgumentException("Invalid username format");
}
// Step 2: Search by username (escaped filter value - defense in depth)
string safeUsername = LdapEncoder.EscapeFilterValue(username); // RFC 4515
string filter = $"(sAMAccountName={safeUsername})";
using (DirectoryEntry rootEntry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(rootEntry))
{
searcher.Filter = filter;
SearchResult result = searcher.FindOne();
if (result == null)
throw new InvalidOperationException("User not found");
// Step 3: Use the DN returned by directory (trusted)
using (DirectoryEntry user = result.GetDirectoryEntry())
{
// Step 4: Construct target OU path from trusted configuration
// If OU name comes from user input, validate and escape it
if (!Regex.IsMatch(targetOuName, @"^[a-zA-Z0-9\s-]{1,100}$"))
{
throw new ArgumentException("Invalid OU name format");
}
string safeOuRdn = LdapEncoder.EscapeDnValue(targetOuName);
string targetOuPath = $"LDAP://OU={safeOuRdn},DC=example,DC=com";
using (DirectoryEntry targetOu = new DirectoryEntry(targetOuPath))
{
// Step 4: Move user to target OU using MoveTo
user.MoveTo(targetOu);
user.CommitChanges();
}
}
}
}
// Alternative: Search for both user and target OU
public void MoveUserToOU_SearchBoth(string username, string targetOuName)
{
using (DirectoryEntry rootEntry = new DirectoryEntry("LDAP://dc=example,dc=com"))
{
// Find user by escaped filter
string safeUsername = LdapEncoder.EscapeFilterValue(username); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
using (DirectorySearcher userSearcher = new DirectorySearcher(rootEntry))
{
userSearcher.Filter = $"(sAMAccountName={safeUsername})";
SearchResult userResult = userSearcher.FindOne();
if (userResult == null) throw new InvalidOperationException("User not found");
// Find target OU by escaped filter (even better - both are trusted DNs)
string safeOuName = LdapEncoder.EscapeFilterValue(targetOuName); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
using (DirectorySearcher ouSearcher = new DirectorySearcher(rootEntry))
{
ouSearcher.Filter = $"(&(objectClass=organizationalUnit)(ou={safeOuName}))";
SearchResult ouResult = ouSearcher.FindOne();
if (ouResult == null) throw new InvalidOperationException("OU not found");
// Both DNs are now from directory - completely trusted
using (DirectoryEntry user = userResult.GetDirectoryEntry())
using (DirectoryEntry targetOu = ouResult.GetDirectoryEntry())
{
user.MoveTo(targetOu);
user.CommitChanges();
}
}
}
}
}
// RECOMMENDATION: Prefer the "SearchBoth" version above when possible.
// It retrieves both the user DN and target OU DN from the directory (fully trusted).
// Only construct target OU paths when you're targeting a known, fixed OU structure.
Why this works:
- Searching by attribute with an escaped filter, then using the DN returned from the directory eliminates DN injection entirely.
- The returned DN comes from the LDAP server itself (a trusted source), not from user input.
- This two-step approach separates authentication from object location: the filter search validates the user exists and matches criteria (with proper escaping), then the server provides the canonical DN which you use for subsequent operations.
- Attackers cannot manipulate the DN structure because they never control it - they only control the search criteria, which are safely escaped.
- The SearchBoth variant is even more secure, retrieving both object DNs from directory queries, making all DNs trusted.
- This is the recommended pattern for any operation requiring DN manipulation.
Encode Each Value Separately in Complex Filters
// SECURE - Encode each user-supplied value separately
public class SecureLdapService
{
public User AuthenticateUser(string username, string password)
{
// Encode username for use in filter
string safeUsername = LdapEncoder.EscapeFilterValue(username); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
using (DirectoryEntry entry = new DirectoryEntry("LDAP://dc=example,dc=com"))
using (DirectorySearcher searcher = new DirectorySearcher(entry))
{
// Only the VALUE portion is escaped, not filter syntax (& | ( ))
searcher.Filter = $"(sAMAccountName={safeUsername})";
SearchResult result = searcher.FindOne();
if (result == null)
return null;
// Use the found DN to authenticate
using (DirectoryEntry userEntry = result.GetDirectoryEntry())
{
try
{
// Authenticate using the DirectoryEntry
object native = userEntry.NativeObject;
return MapToUser(result);
}
catch
{
return null; // Authentication failed
}
}
}
}
}
Why this works:
- Encoding each user-supplied value separately in complex filters ensures that filter structure (operators like
&,|,(,)) is preserved while user data is treated as literals. - The key principle is that only user input gets encoded, not the filter syntax you control.
- When building filters like
(&(givenName={value1})(sn={value2})), encodevalue1andvalue2but not the&,(, or)operators. - This allows you to construct sophisticated search logic while preventing injection.
- Each encoded value is isolated to its attribute comparison, unable to break out and manipulate the broader filter structure.
- Even if attackers inject filter operators in their input, encoding converts them to literal characters (e.g.,
)becomes\29), rendering them harmless. - This pattern works for any filter complexity.
Common Attack Vectors
LDAP Filter Injection
Attack: username = "*)(uid=*))(|(uid=*"
Results in malformed filter: (uid=*)(uid=*))(|(uid=*)
This can bypass authentication or extract all users
Encoding prevents this:
string safe = LdapEncoder.EscapeFilterValue("*)(uid=*))(|(uid=*"); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
// Encoding transforms special characters to \HH hex sequences:
// * -> \2a, ( -> \28, ) -> \29, \ -> \5c
// Result is a literal string value, not filter syntax
DN Injection
Attack: ou = "Admins,DC=evil,DC=com"
Could result in: CN=user,OU=Admins,DC=evil,DC=com
Instead of: CN=user,OU=Users,DC=example,DC=com
Encoding prevents this (for RDN values only)
string safe = LdapEncoder.EscapeDnValue("Admins,DC=evil,DC=com"); // Legacy AntiXSS: Encoder.LdapDistinguishedNameEncode(...)
// Encoding escapes structural characters in the RDN VALUE:
// Commas, equals, etc. in the value become \, and \=
// Result: Admins\,DC=evil\,DC=com (treated as literal OU name)
// IMPORTANT: EscapeDnValue is for RDN VALUES (the part after CN= or OU=)
// Don't escape the structural commas/equals that form the DN hierarchy
// Example: CN=<EscapeThis>,OU=<EscapeThis>,DC=example,DC=com
Testing LDAP Encoding Correctness
The set of Theories and Tests available at Dipsy.Security.Ldap verify that encoding behaves as expected (RFC-style escaping).
Not Escaping LDAP Special Characters
// WRONG - Special characters allow filter injection
string filter = $"(uid={username})";
searcher.Filter = filter;
// Attack: username = "*)(uid=*))(|(uid=*"
// Results in: (uid=*)(uid=*))(|(uid=*)
// CORRECT - Encode special characters in user values
string safeUsername = LdapEncoder.EscapeFilterValue(username); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
string filter = $"(uid={safeUsername})";
searcher.Filter = filter;
Using String Concatenation in LDAP Filters
// WRONG - Direct concatenation is vulnerable
string filter = "(|(uid=" + user1 + ")(uid=" + user2 + "))";
// CORRECT - Encode each user input separately
string safe1 = LdapEncoder.EscapeFilterValue(user1); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
string safe2 = LdapEncoder.EscapeFilterValue(user2); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
string filter = $"(|(uid={safe1})(uid={safe2}))";
Constructing DN from User Input Instead of Searching
// WRONG - Building DN from user input
string dn = $"CN={username},OU={ouName},DC=example,DC=com";
DirectoryEntry entry = new DirectoryEntry($"LDAP://{dn}");
// Attack: ouName = "Admins,DC=evil,DC=com"
// Results in: CN=user,OU=Admins,DC=evil,DC=com
// BETTER - Encode DN components if you must construct DN
string safeCN = LdapEncoder.EscapeDnValue(username); // Legacy AntiXSS: Encoder.LdapDistinguishedNameEncode(...)
string safeOU = LdapEncoder.EscapeDnValue(ouName); // Legacy AntiXSS: Encoder.LdapDistinguishedNameEncode(...)
string dn = $"CN={safeCN},OU={safeOU},DC=example,DC=com";
DirectoryEntry entry = new DirectoryEntry($"LDAP://{dn}");
// BEST - Search by attribute, use returned DN
string safeUsername = LdapEncoder.EscapeFilterValue(username); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
string filter = $"(sAMAccountName={safeUsername})";
SearchResult result = searcher.FindOne();
// Use result.Path or result.Properties["distinguishedName"]
Not Using Allowlist Validation
// WRONG - No validation, just encoding
string filter = $"(department={LdapEncoder.EscapeFilterValue(userDept)})";
// CORRECT - Allowlist validation FIRST (recommended for most cases)
// Restrict to expected format - consult your directory schema
if (!Regex.IsMatch(userDept, @"^[a-zA-Z0-9\s]{1,100}$"))
{
throw new ArgumentException("Invalid department format");
}
// Then simple escape of remaining special chars (defense in depth)
string safeDept = LdapEncoder.EscapeFilterValue(userDept); // RFC 4515
string filter = $"(department={safeDept})";
// For usernames specifically - typical enterprise pattern:
if (!Regex.IsMatch(username, @"^[a-zA-Z0-9._-]{3,64}$"))
{
throw new ArgumentException("Invalid username format");
}
string safeUser = LdapEncoder.EscapeFilterValue(username); // RFC 4515
// Only use full RFC encoding if allowlist is too restrictive:
string safeUser = LdapEncoder.EscapeFilterValue(username);
Improper Use of LDAP Encoding Methods
// WRONG - Using filter encoding for DN
string dn = $"CN={EscapeBasicLdapChars(username)},DC=example,DC=com"; // Wrong escaping!
// WRONG - Using DN encoding for filter
string filter = $"(uid={LdapEncoder.EscapeDnValue(username)})"; // Wrong escaping!
// CORRECT - Use appropriate encoding method
// For filters (with allowlist validation first):
if (!Regex.IsMatch(username, @"^[a-zA-Z0-9._-]{3,64}$"))
{
throw new ArgumentException("Invalid username format");
}
string safeFilter = LdapEncoder.EscapeFilterValue(username); // RFC 4515
string filter = $"(uid={safeFilter})";
// For DNs (prefer search first, but if you must construct):
string safeDN = LdapEncoder.EscapeDnValue(username);
string dn = $"CN={safeDN},DC=example,DC=com";
// For DNs (legacy AntiXSS):
string safeDN = LdapEncoder.EscapeDnValue(username); // Legacy AntiXSS: Encoder.LdapDistinguishedNameEncode(...)
string dn = $"CN={safeDN},DC=example,DC=com";
Relying on Client-Side Validation Alone
// WRONG - Only client-side validation
// JavaScript validates format, but no server-side encoding
[HttpPost]
public ActionResult SearchUsers(string username)
{
string filter = $"(uid={username})";
// Vulnerable if client validation is bypassed
}
// CORRECT - Server-side encoding required
[HttpPost]
public ActionResult SearchUsers(string username)
{
// Always encode on server, regardless of client validation
string safeUsername = LdapEncoder.EscapeFilterValue(username); // Legacy AntiXSS: Encoder.LdapFilterEncode(...)
string filter = $"(uid={safeUsername})";
using (DirectorySearcher searcher = new DirectorySearcher())
{
searcher.Filter = filter;
// ... perform search
}
}
Manual LDAP Encoding (Recommended)
RFC-compliant manual encoding implementation. This is the recommended approach for modern .NET applications.
Implementation based on Microsoft AntiXSS Library source code analysis:
- UTF-8 byte encoding before escaping (handles non-ASCII correctly)
- Forward slash
/escaping (OWASP best practice, defense in depth) - All control characters (0x00-0x1F, 0x7F) and high bytes (0x80-0xFF)
- ALL leading/trailing spaces (AntiXSS only escapes first/last)
- Escapes
=in DN values (optional per RFC 4514 but safer)
Source Code and packaging available from Dipsy.Security.Ldap
using Dipsy.Security.Ldap;
// User input that needs to be escaped
string userInput = "John*(admin)";
// Escape for use in filter (returns null if input is null)
string? escapedValue = LdapEncoder.EscapeFilterValue(userInput);
// Use in LDAP filter
string filter = $"(&(objectClass=user)(cn={escapedValue}))";
// Result: (&(objectClass=user)(cn=John\2a\28admin\29))