Summary
The registration-time email uniqueness validator, da_unique_email_validator, formatted the submitted email address straight into an LDAP search filter with Python's % operator, so filter metacharacters in the email were interpreted as filter syntax. The fix wraps the value in ldap.filter.escape_filter_chars() (and imports the ldap.filter submodule explicitly), so a submitted address is always treated as a literal attribute value. Any deployment with ldap login enabled and a bind account configured was reachable from an unauthenticated registration form.
Introduction
Registration forms are the least-authenticated code path in a web application, and this one reached all the way into a corporate directory. When ldap login is enabled, the validator that answers "is this email already taken?" does not just check the local user table — it opens an LDAP connection, binds with the configured service account, and runs a search built like this:
search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (form.email.data,)
form.email.data is whatever the visitor typed into the email field. The % operator does not know or care that the result is about to be parsed as an RFC 4515 search filter, so every (, ), *, and \ in that string becomes filter syntax rather than filter data. The validator then hands the string to a search_s() call on a connection bound with privileged credentials, and turns the presence or absence of results into a visible form validation message.
That combination — attacker-controlled filter grammar, privileged bind, and an observable boolean answer — is the textbook setup for blind LDAP injection. If you maintain code that builds LDAP filters, DNs, or any other query grammar with %, +, or f-strings, this is the shape of bug to look for.
Affected Versions
| Affected | not applicable (first-party code) — any build where the ldap login configuration block is enabled with base dn, bind email, and bind password set |
| Fixed in | not applicable (first-party code) — fixed by the security pull request that adds escape_filter_chars() to the validator |
| Ecosystem | not applicable (first-party Python / Flask-WTForms code) |
| CVE / GHSA | not assigned |
| CWE | unknown (no CWE was assigned to this finding; the class is LDAP filter injection) |
The Vulnerability Explained
The vulnerable code
The entire flaw fits on one line inside da_unique_email_validator:
if daconfig['ldap login'].get('enable', False) and ...:
ldap_server = daconfig['ldap login'].get('server', 'localhost').strip()
base_dn = daconfig['ldap login']['base dn'].strip()
search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (form.email.data,)
connect = ldap.initialize('ldap://' + ldap_server)
connect.simple_bind_s(daconfig['ldap login']['bind email'],
daconfig['ldap login']['bind password'])
Two properties make this dangerous rather than merely sloppy:
- The pattern is a filter template, not a parameterized query. There is no placeholder API in python-ldap that escapes values for you.
"mail=%s" % valueproduces a raw filter string; the LDAP server receives exactly what the string says. - The bind is privileged. The search runs as the configured
bind emailservice account, not as the anonymous visitor. Whatever the injected filter can see, the attacker can infer.
Exploiting it
Take the default pattern mail=%s and submit this as the registration email:
*)(uid=*))(|(uid=*
The interpolation yields:
mail=*)(uid=*))(|(uid=*
The mail= comparison is now closed off early and new filter components are grafted on. Variations of this payload let an attacker:
- Turn the check into a tautology. A payload reducing to
mail=*matches every entry that has amailattribute. The uniqueness check now reports a collision for every address a legitimate user tries — a denial of registration for the whole instance, achievable by one HTTP POST. - Enumerate the directory as a boolean oracle. Because the validator's outcome is rendered back in the form ("already in use" versus accepted), each submission returns one bit. Payloads that append conditions such as
)(mail=admin@corp.exampleor)(cn=Domain Adminslet an attacker confirm whether specific accounts, groups, or objectClasses exist behind the bind account's view. - Extract attribute values character by character. Wildcard-anchored probes —
)(mail=a*,)(mail=ab*, and so on — narrow down real addresses one character at a time. This is classic blind extraction; the only cost is HTTP requests. - Probe attributes the web tier never intended to expose. The injected filter is not constrained to
mail. Any attribute the bind account can read is fair game as a search condition, includinguserPasswordpresence checks,employeeNumber, or custom schema fields, depending on directory ACLs. - Push expensive searches at the directory. Unindexed wildcard filters over a large
base dnare cheap to send and expensive to answer, giving an unauthenticated visitor a lever on directory CPU.
Note what the attacker does not need: no account, no session, no knowledge of the base dn, and no visibility into the configured search pattern. The default pattern is guessable, and the response of the registration form tells them whether their payload parsed.
Real-world impact
For an organization that wired this application to its central directory, an internet-exposed registration page became a read-side query interface to that directory, running under service-account privileges. That leaks the employee roster and group membership — exactly the reconnaissance material that precedes credential-stuffing and phishing. The secondary effect is availability: a tautological filter makes the "is this email unique?" answer always "no", blocking new signups until the payload is noticed.
The Fix
The change is deliberately small and surgical. First, the ldap.filter submodule is imported explicitly alongside ldap, inside the same try/except ImportError guard that disables ldap login when python-ldap is not installed:
try:
import ldap
import ldap.filter
except ImportError:
if 'ldap login' not in daconfig:
daconfig['ldap login'] = {}
This matters: import ldap does not reliably bind filter as an attribute of the ldap package, so without this line the call below could raise AttributeError at request time. Keeping it inside the existing try block preserves the graceful degradation path for installs without python-ldap.
Then the interpolation itself is fixed.
Before:
search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (form.email.data,)
After:
search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (ldap.filter.escape_filter_chars(form.email.data),)
escape_filter_chars() implements RFC 4515 escaping: \, *, (, ), and NUL are rewritten into their \5c, \2a, \28, \29, and \00 hex forms. The payload *)(uid=*))(|(uid=* therefore reaches the directory as the literal string \2a\29\28uid=\2a\29\29\28|\28uid=\2a — a single, harmless attribute value that will simply match nothing.
Crucially, the escaping is applied to the value, not to the pattern. Deployments that override search pattern with something like (&(objectClass=person)(mail=%s)) keep their custom filter semantics intact and still get a safely-escaped substitution. And because normal email addresses do not contain *, (, ), \, or NUL, legitimate registrations produce byte-identical filters before and after the fix — behavior is preserved for every non-malicious input.
One boundary worth stating: escape_filter_chars() is the correct escaper for search filters only. If user data ever flows into a distinguished name, that requires ldap.dn.escape_dn_chars() instead, since DN syntax escapes a different character set. The fix here is scoped to the filter path, which is the only place the submitted email lands.
Key Takeaways
"mail=%s" % form.email.datais not a parameterized query. python-ldap has no bind-parameter API for filters; the only safe substitution is a value passed throughldap.filter.escape_filter_chars()first.- A configurable filter template such as
search patterndoes not make the interpolation safe — escape the value being substituted so that operator-supplied patterns keep working unchanged. import ldapalone is not enough to callldap.filter.escape_filter_chars(); the submodule needs its own import, and it belongs inside the sameImportErrorguard that disables the LDAP login feature.- A uniqueness validator that reports "this email is already in use" is a boolean oracle. Combined with injectable filter syntax and a privileged
bind emailaccount, one bit per request is enough to enumerate a directory. - Unauthenticated registration fields deserve the same scrutiny as admin query endpoints, precisely because they reach backend directories before any session exists.
How Orbis AppSec Detected This
- Source: the
emailfield of the registration form, read asform.email.datainsideda_unique_email_validator— unauthenticated, attacker-controlled input. - Sink: an LDAP search filter string built by
%-formatting the configuredsearch pattern(defaultmail=%s), then executed over a connection created byldap.initialize()and bound with thebind emailservice credentials. - Missing control: no RFC 4515 filter escaping. The characters
(,),*,\, and NUL passed through unmodified, so the submitted address could restructure the filter expression instead of acting as a value. - CWE: unknown — no CWE identifier was assigned to this finding. The behavior is LDAP filter injection.
- Fix: the email value is now wrapped in
ldap.filter.escape_filter_chars()before interpolation, withimport ldap.filteradded so the escaper is guaranteed to be available.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
A single % operator turned a registration-form uniqueness check into an unauthenticated, privileged query channel into an organization's LDAP directory. The payload *)(uid=*))(|(uid=* is all it took to escape the mail= comparison in the default search pattern and start asking the directory questions the application never meant to ask — with each answer handed back as a form validation message.
The remedy is one call: ldap.filter.escape_filter_chars(form.email.data), plus the import ldap.filter that makes it callable. No behavior changes for real email addresses, no configuration migration, and no way left for filter metacharacters to be parsed as filter grammar. If your codebase builds LDAP filters from request data anywhere, grep for %, +, and f-strings around search_s() — that is where the next one of these is hiding.