Skip to content

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:

  1. Strict allowlist validation - Restrict usernames/input to expected patterns (e.g., ^[a-zA-Z0-9._-]{3,64}$), then escape any remaining special characters (*, (, ), \)
  2. Search by attribute, use returned DN - Never construct DNs from user input; search first and use the trusted DN returned by the directory
  3. 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

// 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, and Encoder.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 *, (, ), \, and NUL that 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})), encode value1 and value2 but 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
       }
   }

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

Example Usage
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))

Additional Resources