DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-24031 Analysis — Dovecot SQL-Based Authentication Bypass (an auth_username_chars Regression)

1. Overview

Item Detail
CVE ID CVE-2026-24031
Component Dovecot Core (SQL passdb/userdb authentication)
Vulnerability class CWE-89 (SQL Injection)
CVSS 3.1 7.7 (High) — AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:L
Affected versions OX Dovecot CE core 2.4.0–2.4.2, OX Dovecot Pro core 3.1.0–3.1.3
Fixed in CE core 2.4.3, Pro core 3.1.4
Internal tracking ID DOV-8781
Discovery / disclosure 2026-02-23 / 2026-03-27
Discoverer whisperer (YesWeHack)

Dovecot's official security advisory (OXDC-2026-0001) summarizes it in one line:

Dovecot SQL based authentication can be bypassed when auth_username_chars is cleared by admin. This vulnerability allows bypassing authentication for any user and user enumeration.

Read at face value it sounds like a pure configuration mistake, but the actual root cause is a regression introduced during the 2.4 settings-system refactor, where an explicitly-requested SQL escape function is silently discarded. auth_username_chars being cleared is only the precondition that lets the bug get triggered in practice.

This post is based on a direct clone of the dovecot/core repository, comparing the vulnerable (2.4.2) and patched (2.4.3) source to trace the actual root cause.


2. Background — what auth_username_chars actually does

Dovecot reuses the username a client supplies at login in several places — SQL/LDAP queries, file paths (%u, %n, %d variable substitution), and more. auth_username_chars is the first line of defense against dangerous characters ending up in those places.

Default value in src/auth/auth-settings.c:

.username_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@",
Enter fullscreen mode Exit fullscreen mode

By default only letters, digits, and . - _ @ are allowed. SQL metacharacters like a single quote (') or semicolon are not part of the whitelist at all.

That string is converted into a 256-byte bitmap (username_chars_map) with a special case worth noting:

src/auth/auth-settings.c (around lines 623–630):

if (*set->username_chars == '\0') {
    /* all chars are allowed */
    memset(set->username_chars_map, 1,
           sizeof(set->username_chars_map));
} else {
    for (p = set->username_chars; *p != '\0'; p++)
        set->username_chars_map[(int)(uint8_t)*p] = 1;
}
Enter fullscreen mode Exit fullscreen mode

In other words, setting auth_username_chars to an empty string doesn't mean "no characters allowed" — it means the exact opposite: "allow every byte (0x00–0xFF)." Admins sometimes clear this setting in practice to support wide/unicode usernames, and the moment they do, the first-line filter against SQL metacharacters disappears completely.

The actual filtering happens where auth_request_fix_username() in src/auth/auth-request-fields.c checks that map:

for (p = (unsigned char *)user; *p != '\0'; p++) {
    if (set->username_translation_map[*p & 0xff] != 0)
        *p = set->username_translation_map[*p & 0xff];
    if (set->username_chars_map[*p & 0xff] == 0) {
        *error_r = t_strdup_printf(
            "Username character disallowed by auth_username_chars: "
            "0x%02x (username: %s)", *p,
            str_sanitize(*username, 128));
        return -1;
    }
}
Enter fullscreen mode Exit fullscreen mode

When username_chars_map is filled with 1s across the board, this check effectively rejects nothing. Up to this point we're still in "admin misconfiguration" territory. The real problem is what happens next.


3. How the SQL query gets built — passdb-sql.c

An SQL passdb builds its query string (e.g. SELECT username, password FROM users WHERE username = '%u') by expanding variables like %u (username) inside the configured passdb_sql_query template. To prevent SQL injection, the substituted value must always go through SQL escaping.

src/auth/passdb-sql.c:

static int passdb_sql_escape(const char *str, const char **output_r,
                             void *context, const char **error_r)
{
    struct sql_db *db = context;
    return sql_escape_string(db, str, output_r, error_r);
}

static void sql_lookup_pass(struct passdb_sql_request *sql_request)
{
    ...
    const struct settings_get_params params = {
        .escape_func = passdb_sql_escape,
        .escape_context = module->db,
    };
    if (settings_get_params(authdb_event(sql_request->auth_request),
                            &passdb_sql_setting_parser_info, &params,
                            &set, &error) < 0) {
        ...
    }

    e_debug(authdb_event(sql_request->auth_request),
        "query: %s", set->query);

    auth_request_ref(sql_request->auth_request);
    sql_query(module->db, set->query, sql_query_callback, sql_request);
    ...
}
Enter fullscreen mode Exit fullscreen mode

At first glance this looks correct. settings_get_params() is called with an explicit escape_func = passdb_sql_escape, and that function calls the underlying DB driver's sql_escape_string() (MySQL/PostgreSQL/SQLite) to properly escape quotes and the like. settings.h even documents the contract:

struct settings_get_params {
    /* If non-NULL, all %variables are escaped with this function. */
    var_expand_escape_func_t *escape_func;
    ...
};
Enter fullscreen mode Exit fullscreen mode

"If non-NULL, it is guaranteed to be used for escaping" — except that contract wasn't actually being honored.


4. The real root cause — an escape_func override bug in settings.c

The bug isn't in passdb-sql.c at all — it's one layer down, in settings_var_expand_init() inside src/lib-settings/settings.c. Dovecot's settings system can receive a var-expand escape function from two places:

  1. The explicit escape_func a caller passes into settings_get_params() (e.g. passdb_sql_escape)
  2. A default escape callback registered somewhere up the event hierarchy (init_ctx.escape_func)

The vulnerable 2.4.0–2.4.2 code unconditionally used option 2, no matter what the caller passed.

Patch commit 34fbd3956d diff (src/lib-settings/settings.c):

    ctx->var_params.tables_arr = array_front(&init_ctx.tables);
    ctx->var_params.providers_arr = array_front(&init_ctx.providers);
    ctx->var_params.contexts = array_front(&init_ctx.contexts);
-   ctx->var_params.escape_func = init_ctx.escape_func;
-   ctx->var_params.escape_context = init_ctx.escape_context;
+   if (ctx->escape_func != NULL) {
+       /* settings_get_params()'s escape_func overrides all others */
+       ctx->var_params.escape_func = ctx->escape_func;
+       ctx->var_params.escape_context = ctx->escape_context;
+   } else {
+       ctx->var_params.escape_func = init_ctx.escape_func;
+       ctx->var_params.escape_context = init_ctx.escape_context;
+   }
    ctx->var_params.event = ctx->event;
Enter fullscreen mode Exit fullscreen mode

So even though passdb-sql.c explicitly requests escape_func = passdb_sql_escape, the actual variable-substitution step silently discarded it and used whatever (unrelated, or absent) escape function the event chain happened to expose instead. The net effect: the username value substituted for %u could end up not escaped at all, or escaped incorrectly, for SQL.

This bug was introduced by the 2024 commit ef0c63b6 ("auth: passdb/userdb sql - Convert to new settings"), a large refactor that moved SQL passdb/userdb onto the new settings framework. The Dovecot developers themselves flag it in the commit log as a "v2.4 regression."

The same class of bug also produced a sibling CVE in the LDAP passdb (CVE-2026-27860, LDAP filter injection) — both trace back to the same settings_var_expand_init() defect.


5. Full attack chain

Two conditions have to line up for real exploitation:

  • Precondition A: An admin has set auth_username_chars to an empty string (disabling the character whitelist)
  • Root cause B: The settings_var_expand_init() bug that ignores passdb_sql_escape — present in every 2.4.0–2.4.2 / 3.1.0–3.1.3 install regardless of configuration

The CVSS vector's AC:H (high attack complexity) reflects the fact that condition A has to be true.


6. The actual fix (v2.4.3)

The core patch is the priority reordering inside settings_var_expand_init() shown above.

--- a/src/lib-settings/settings.c
+++ b/src/lib-settings/settings.c
@@ -1673,8 +1673,14 @@ settings_var_expand_init(struct settings_apply_ctx *ctx)
    ctx->var_params.tables_arr = array_front(&init_ctx.tables);
    ctx->var_params.providers_arr = array_front(&init_ctx.providers);
    ctx->var_params.contexts = array_front(&init_ctx.contexts);
-   ctx->var_params.escape_func = init_ctx.escape_func;
-   ctx->var_params.escape_context = init_ctx.escape_context;
+   if (ctx->escape_func != NULL) {
+       /* settings_get_params()'s escape_func overrides all others */
+       ctx->var_params.escape_func = ctx->escape_func;
+       ctx->var_params.escape_context = ctx->escape_context;
+   } else {
+       ctx->var_params.escape_func = init_ctx.escape_func;
+       ctx->var_params.escape_context = init_ctx.escape_context;
+   }
    ctx->var_params.event = ctx->event;
 }
Enter fullscreen mode Exit fullscreen mode

Now, when a caller of settings_get_params() explicitly supplies an escape_func (like passdb_sql_escape), that value takes priority; the event-chain default is only used as a fallback when the caller didn't specify one. This is what finally makes the escape functions passed by passdb-sql.c, userdb-sql.c, and db-ldap.c actually take effect.

A few related commits shipped alongside it, cleaning up the same pattern:

  • 25c34e5084passdb sql - Fix escaping for set_credentials() (a missing-escape path in the OTP credential-update flow; not a separate CVE, but the same underlying pattern)
  • 6a8f2daf15passdb/userdb ldap - Fix escaping ldap filter, base and bind_userdn (CVE-2026-27860, the LDAP counterpart of this exact bug)
  • 74a6f1612eRewrite ldap_escape() with a unit test (regression-prevention hardening)

7. Detection and mitigation

Check the configuration

# check dovecot.conf or conf.d/*.conf
doveconf -n | grep auth_username_chars
Enter fullscreen mode Exit fullscreen mode
  • An empty value (auth_username_chars =) is an immediate red flag. At minimum, restore it to something at or above the default whitelist (abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@).
  • If unicode usernames are genuinely required, don't remove the restriction entirely — build an explicit whitelist that still excludes characters used in SQL injection (', ", ;, \, whitespace, etc.).

Apply the patch

  • CE core → 2.4.3 or later
  • Pro core → 3.1.4 or later

Log-based detection

With auth_debug = yes enabled, check the query: ... debug line emitted by passdb-sql.c for usernames containing abnormal quote or comment patterns (--, #, /*).

e_debug(authdb_event(sql_request->auth_request), "query: %s", set->query);
Enter fullscreen mode Exit fullscreen mode

Operational and network controls

  • Apply rate limiting on authentication endpoints, since response-time or error-message differences could be leveraged for user enumeration.
  • On the database side, if SQL query logging is enabled, add detection rules for abnormal WHERE-clause patterns in password_query/user_query.

Top comments (0)