Skip to content

CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection') - C# / .NET

Overview

LDAP injection in .NET typically happens through System.DirectoryServices (DirectoryEntry, DirectorySearcher - Windows-only) or System.DirectoryServices.Protocols (cross-platform) when filter strings or Distinguished Name (DN) paths are built by concatenating or interpolating user input.

Primary Defence: Validate input against a strict allowlist first, never construct a DN from user input (search by attribute and use the DN the directory returns instead), and RFC 4515/4514-escape any value that must appear in a filter or DN.

Important: .NET has no first-party LDAP encoder, the way it has System.Text.Encodings.Web for HTML and URLs. OWASP's LDAP Injection Prevention Cheat Sheet names Encoder.LdapFilterEncode() / LdapDistinguishedNameEncode(), but those ship in the legacy Microsoft AntiXSS library, which is unmaintained and not reliably compatible with modern .NET; the cheat sheet's other recommendation, LinqToLdap, has had no release since 2020. There is one maintained package: AntiLdapInjection (LdapEncoder.FilterEncode() and DistinguishedNameEncode()), a community port of the AntiXSS encoders targeting .NET Standard 2.0/2.1, last released March 2026. Two things to weigh before taking it: it is a single-maintainer package, and it is safe-list based, so its DN encoder rewrites everything outside a small safe list into RFC 2253's hash-hex form rather than RFC 4514's backslash-hex: measured on 2.1.2, Lučić becomes Lu#C4#8Di#C4#87, and so does ordinary ASCII that happens to be off the list - svc-backup becomes svc#2Dbackup and O'Brien becomes O#27Brien, because - and ' are outside it. A directory server reads svc#2Dbackup literally, so the encoded name addresses no entry. Its filter encoder (FilterEncode()) is the conventional RFC 4515 backslash-hex and has no such problem; it is the DN encoder to avoid. Where DN values can contain a hyphen or an apostrophe - which is most directories - use the reference implementation below, and keep one copy of it in a shared security-utilities module rather than reimplementing it per call site. Better still, prefer the "search by attribute, use the returned DN" pattern below wherever it removes the need to escape at all.

Common Vulnerable Patterns

Direct String Concatenation in LDAP Filters

// VULNERABLE - No encoding
public User FindUser(string username)
{
    string filter = $"(uid={username})";  // Dangerous!

    using var entry = new DirectoryEntry("LDAP://dc=example,dc=com");
    using var searcher = new DirectorySearcher(entry) { Filter = filter };
    return MapToUser(searcher.FindOne());
}

// Attack: username = "*"
// Resulting filter: (uid=*) - a presence test, so the search returns every entry in the subtree

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.

The payload most write-ups quote for this shape, *)(uid=*))(|(uid=*, is worth knowing about but does not fire here: interpolated into (uid=) it yields (uid=*)(uid=*))(|(uid=*), which is two top-level filters rather than one, and .NET parses the filter before sending it - LdapException: The search filter is invalid. It belongs to a compound filter on a server that parses the first filter and ignores the rest. Against a single-term filter the working payload is the bare *, and that is the one to reach for when confirming the finding.

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.

Building DN Paths Without Encoding

// VULNERABLE - Constructing a DN from untrusted input
public DirectoryEntry BindToUserByConstructedDn(string username, string ouName)
{
    string dnPath = $"CN={username},OU={ouName},DC=example,DC=com";
    return new DirectoryEntry($"LDAP://{dnPath}");
}

// Attack: username = "svc-backup,OU=Service Accounts", ouName = "Users"
// Resulting DN: CN=svc-backup,OU=Service Accounts,OU=Users,DC=example,DC=com
// The injected comma adds an RDN, so the path addresses a service account the code
// never meant to reach - the caller asked for a user in OU=Users

Why these are vulnerable: Neither DirectorySearcher.Filter nor a DirectoryEntry path performs any escaping of the string you hand it. Special characters (*, (, ), \) in a filter change its logic; special characters (,, +, ", \, <, >, ;, =) in a DN change which directory object is addressed.

The injection has to go into the first component to be useful. The ,DC=example,DC=com suffix is fixed and DN syntax has no way to comment it away, so a value like ouName = "Admins,DC=evil,DC=com" builds CN=jsmith,OU=Admins,DC=evil,DC=com,DC=example,DC=com, which addresses nothing - measured, the server answers "The object does not exist". Injecting into username keeps the suffix intact and shifts the path within the tree, which is where the reachable entries are.

Secure Patterns

Allowlist Validation + RFC 4515 Filter Escaping (Primary)

// SECURE - allowlist first, then RFC 4515 escaping on the interpolated value
using System.Text.RegularExpressions;

public class LdapQueryService
{
    private static readonly Regex UsernamePattern = new(@"\A[a-zA-Z0-9._-]{3,64}\z");

    public User FindUser(string username)
    {
        // Step 1: Allowlist validation - rejects most injection attempts outright
        if (!UsernamePattern.IsMatch(username))
            throw new ArgumentException("Invalid username format", nameof(username));

        // Step 2: Escape remaining LDAP special characters (defense in depth)
        string safeUsername = LdapFilterEncoder.EscapeFilterValue(username);
        string filter = $"(uid={safeUsername})";

        using var entry = new DirectoryEntry("LDAP://dc=example,dc=com");
        using var searcher = new DirectorySearcher(entry) { Filter = filter };
        return MapToUser(searcher.FindOne());
    }
}

Why this works: Most LDAP deployments restrict usernames to a known character set. Rejecting anything outside that allowlist keeps the filter and DN metacharacters out of the value before it reaches LDAP. Escaping the (already-validated) value afterward is defense in depth in case the allowlist is ever loosened. The allowlist pattern should match your organization's actual username/attribute format - consult your directory schema, don't guess.

Preferred: Search by Attribute, Use the Returned DN

// SECURE - search by attribute, then use the DN the directory returned
public void MoveUserToOu(string username, string targetOuName)
{
    if (!Regex.IsMatch(username, @"\A[a-zA-Z0-9._-]{3,64}\z"))
        throw new ArgumentException("Invalid username format");
    if (!Regex.IsMatch(targetOuName, @"\A[a-zA-Z0-9\s-]{1,100}\z"))
        throw new ArgumentException("Invalid OU name format");

    string safeUsername = LdapFilterEncoder.EscapeFilterValue(username);
    string safeOu = LdapFilterEncoder.EscapeFilterValue(targetOuName);

    using var rootEntry = new DirectoryEntry("LDAP://dc=example,dc=com");

    using var userSearcher = new DirectorySearcher(rootEntry) { Filter = $"(sAMAccountName={safeUsername})" };
    var userResult = userSearcher.FindOne() ?? throw new InvalidOperationException("User not found");

    using var ouSearcher = new DirectorySearcher(rootEntry)
    {
        Filter = $"(&(objectClass=organizationalUnit)(ou={safeOu}))"
    };
    var ouResult = ouSearcher.FindOne() ?? throw new InvalidOperationException("OU not found");

    // Both DNs came from the directory itself - fully trusted, never constructed from input
    using var user = userResult.GetDirectoryEntry();
    using var targetOu = ouResult.GetDirectoryEntry();
    user.MoveTo(targetOu);
    user.CommitChanges();
}

Why this works: Searching for both the user and the target OU by escaped filter value, then using the DN the directory returns, eliminates DN injection entirely - the DN is never assembled from user input, so there's nothing to escape or get wrong. This is the recommended pattern for any operation that would otherwise require constructing a DN. Reserve manual DN construction for cases where the base DN is fixed, trusted configuration and only a validated, escaped RDN value is attacker-influenced.

Reference RFC 4515/4514 Escaping (Without a Dependency)

// SECURE - reference RFC 4515 filter and RFC 4514 DN encoders, no dependency
using System.Text;

/// <summary>
/// RFC 4515 filter escaping and RFC 4514 DN escaping, for codebases not taking the
/// AntiLdapInjection dependency - keep one copy of this in a shared security-utilities
/// module rather than reimplementing it at each call site.
/// </summary>
public static class LdapFilterEncoder
{
    public static string EscapeFilterValue(string input)
    {
        if (string.IsNullOrEmpty(input)) return input;

        var sb = new StringBuilder(input.Length);
        foreach (char c in input)
        {
            switch (c)
            {
                case '\\': sb.Append(@"\5c"); break;
                case '*':  sb.Append(@"\2a"); break;
                case '(':  sb.Append(@"\28"); break;
                case ')':  sb.Append(@"\29"); break;
                case '\0': sb.Append(@"\00"); break;
                default:   sb.Append(c); break;
            }
        }
        return sb.ToString();
    }

    // DN (RFC 4514) escaping uses a different character set than filter escaping -
    // do not reuse EscapeFilterValue for DN/RDN values or vice versa.
    public static string EscapeDnValue(string input)
    {
        if (string.IsNullOrEmpty(input)) return input;

        var sb = new StringBuilder(input.Length);
        for (int i = 0; i < input.Length; i++)
        {
            char c = input[i];
            bool first = i == 0;
            bool last = i == input.Length - 1;

            if (c == '\0')
                sb.Append(@"\00");                    // NUL has no single-character escape
            else if (c is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '=')
                sb.Append('\\').Append(c);
            else if (c == ' ' && (first || last))
                sb.Append(@"\20");                    // only a leading or trailing space is significant
            else if (c == '#' && first)
                sb.Append(@"\23");                    // a leading # would introduce a hex-encoded value
            else
                sb.Append(c);
        }
        return sb.ToString();                         // escape the edge characters, never trim them
    }
}

Why this works: RFC 4515 defines exactly which characters have syntactic meaning inside an LDAP filter (*, (, ), \, NUL); RFC 4514 defines a different set for DN/RDN values (,, +, ", \, <, >, ;, =, NUL, a leading #, and a leading or trailing space). The position-dependent ones are what partial implementations miss: a # at the start of a value marks the remainder as hex-encoded, and a space at either end is discarded by the DN parser unless escaped - so trimming or dropping those characters instead of escaping them quietly changes which object the DN addresses. Converting each special character to its backslash-hex escape turns it into a literal value the LDAP server cannot interpret as syntax. Using the wrong escaper for a context - DN-escaping a filter value, say - leaves the other character set unescaped: keep the two functions distinct and name them so the mismatch is obvious at the call site.

Testing

  • * as the username - the payload that works against a single-term filter; unescaped it turns (uid=alice) into (uid=*) and returns every entry in the subtree, and after EscapeFilterValue() it returns none
  • *)(uid=*))(|(uid=* - the payload most write-ups quote, worth running only against the fixed code. Interpolated into (uid=) it yields two top-level filters, and .NET refuses to send it (LdapException: The search filter is invalid), so the unescaped run throws instead of leaking and the test cannot tell a working fix from a broken filter. Escaped, it reaches the directory as the single assertion value \2a\29\28uid=\2a\29\29\28|\28uid=\2a and matches nothing
  • svc-backup,OU=Service Accounts as the first DN component, with a legitimate OU - confirm the built path still addresses the entry the code intended rather than the injected one. Injecting into a later component cannot escape the fixed suffix, so this is the position to test
  • A username with a trailing newline - confirm the allowlist refuses it. .NET's $ also matches immediately before a final newline, so IsMatch against ^...$ accepts alice followed by one; \A...\z does not
  • Null byte (\0) and backslash - confirm they're escaped, not passed through or causing an exception
  • A DN value that begins with #, or has a leading or trailing space - confirm the escaper emits \23 and \20 for those characters and the DN still addresses the same entry, rather than trimming the value into a different one
  • A legitimate username and OU name - confirm normal lookups still succeed after adding validation

Common Pitfalls

  • Using EscapeFilterValue() for a DN value or EscapeDnValue() for a filter value - the two RFCs escape different character sets.
  • Escaping input but still constructing the DN from it, instead of searching by attribute and using the returned DN.
  • Relying on client-side format validation with no server-side escaping.

Additional Resources