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_charsis 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.-_@",
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;
}
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;
}
}
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, ¶ms,
&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);
...
}
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;
...
};
"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:
- The explicit
escape_funca caller passes intosettings_get_params()(e.g.passdb_sql_escape) - 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;
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_charsto an empty string (disabling the character whitelist) -
Root cause B: The
settings_var_expand_init()bug that ignorespassdb_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;
}
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:
-
25c34e5084—passdb 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) -
6a8f2daf15—passdb/userdb ldap - Fix escaping ldap filter, base and bind_userdn(CVE-2026-27860, the LDAP counterpart of this exact bug) -
74a6f1612e—Rewrite 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
- 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);
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)