Topic: mcp server LDAP injection security

MCP server LDAP injection security — directory query vulnerabilities

Enterprise MCP servers that query Active Directory or LDAP for user authentication, group membership checks, or directory lookups are vulnerable to LDAP injection when they construct filter strings by concatenating user-controlled input. An injected filter can bypass authentication, enumerate users across organizational units, retrieve sensitive LDAP attributes, or in some configurations cause the directory server to perform unintended operations.

LDAP filter injection: authentication bypass

An MCP server that authenticates users against Active Directory by constructing an LDAP search filter to look up the user by their provided username is vulnerable:

// VULNERABLE — username interpolated into LDAP filter
const ldap = require('ldapjs');

server.tool('authenticate_user', {
  username: z.string(),
  password: z.string(),
}, async ({ username, password }) => {
  const client = ldap.createClient({ url: 'ldap://dc.corp.internal' });

  // First: find the user's DN
  const filter = `(sAMAccountName=${username})`;  // ← injection point
  const searchResult = await searchLdap(client, 'DC=corp,DC=internal', {
    filter,
    attributes: ['dn', 'cn', 'memberOf'],
  });

  if (!searchResult || searchResult.length === 0) return { authenticated: false };

  // Then: bind with that DN and the provided password
  await client.bind(searchResult[0].dn, password);
  return { authenticated: true, groups: searchResult[0].memberOf };
});

An attacker provides username: "admin)(|(objectClass=*" to construct the filter:

(sAMAccountName=admin)(|(objectClass=*)

Depending on the server's filter evaluation, this variant of a tautology injection may match all objects in the directory regardless of the actual username. The attacker then provides any password for the bind step — if the server accepts the first matching DN (which may be the administrator account if the directory is ordered that way), the bind succeeds and grants administrator-level access.

A more direct variant uses wildcard injection to enumerate valid usernames: username: "a*" constructs (sAMAccountName=a*), which returns all accounts starting with "a" — a directory enumeration oracle requiring no credentials at all.

Blind LDAP injection: boolean-based attribute extraction

Even when the MCP server does not return full LDAP search results to the caller, an attacker can extract attribute values through boolean-based blind injection:

// Vulnerable lookup: returns only "found"/"not found", not the full entry
server.tool('check_user_exists', { username: z.string() }, async ({ username }) => {
  const filter = `(&(objectClass=user)(sAMAccountName=${username}))`;
  const result = await searchLdap(client, 'DC=corp,DC=internal', { filter });
  return { content: [{ type: 'text', text: result.length > 0 ? 'User found' : 'Not found' }] };
});

An attacker uses the filter to probe attribute values character by character. To test whether the admin account's password hash starts with "a":

// Tests: (&(objectClass=user)(sAMAccountName=admin)(unicodePwd=a*))
username: "admin)(unicodePwd=a*)"  // returns "User found" if pwd starts with "a"
username: "admin)(unicodePwd=b*)"  // returns "Not found" → first char is not "b"

Most attributes are extractable this way given enough requests. The attack works even against attributes the MCP server does not explicitly return — the filter evaluates them server-side and the boolean result leaks the value.

DN injection in bind operations

If the MCP server constructs the Distinguished Name (DN) for a bind operation by interpolating user input:

// VULNERABLE — email used to construct bind DN
const userDN = `CN=${fullName},OU=Users,DC=corp,DC=internal`;
await client.bind(userDN, password);

An attacker providing fullName: "attacker,OU=Admins" constructs CN=attacker,OU=Admins,OU=Users,DC=corp,DC=internal — attempting to bind against an admin OU entry. Depending on how ldapjs parses the DN, the injected OU component may cause the bind to target an account in the Admins OU rather than the Users OU.

The correct pattern: LDAP escaping

All user input incorporated into LDAP filters must be escaped using the filter-escaping rules defined in RFC 4515. The ldapjs library provides an escaping function:

const { escape } = require('ldapjs').filters;

// CORRECT — user input escaped before insertion into filter
const safeUsername = escape(username);
const filter = `(sAMAccountName=${safeUsername})`;

// ldapjs escape() encodes: * → \2a, ( → \28, ) → \29, \ → \5c, NUL → \00
// An injection attempt "admin)(|(objectClass=*" becomes:
// "admin\29\28|\28objectClass=\2a" — interpreted as a literal string value

For DN construction, escape the individual RDN values using LDAP DN escaping (RFC 4514), which differs from filter escaping:

function escapeDNComponent(value) {
  return value
    .replace(/\\/g, '\\\\')
    .replace(/,/g, '\\,')
    .replace(/\+/g, '\\+')
    .replace(/"/g, '\\"')
    .replace(//g, '\\>')
    .replace(/;/g, '\\;')
    .replace(/^[ #]/, c => '\\' + c)
    .replace(/[ ]$/, '\\ ');
}

const safeFullName = escapeDNComponent(fullName);
const userDN = `CN=${safeFullName},OU=Users,DC=corp,DC=internal`;

Additionally, validate that the constructed DN resolves to an entry within the expected OU before binding. A bind should only be attempted against DNs that both pass escaping and are within the authorized subtree:

const userDN = `CN=${safeFullName},OU=Users,DC=corp,DC=internal`;
// Verify the DN is within the expected base:
if (!userDN.endsWith(',OU=Users,DC=corp,DC=internal')) {
  throw new Error('DN outside permitted subtree');
}

SkillAudit's static analysis flags LDAP filter construction patterns that interpolate variables without evidence of ldapjs's escape() function being applied. It also flags direct string concatenation patterns in LDAP bind DN construction.

Audit your MCP server's LDAP integration for injection vulnerabilities.

Run a free audit →