ProFTPD's mod_sql resolves every logging-format variable, %U, %{basename}, %r, %m, into a SQL string by appending each piece to a buffer. Before each append, the resolver asks one question: does this text look already-escaped? If yes, skip sql_escapestring. If no, run it through.
The check is is_escaped_text(). It returns TRUE when the text starts with ', ends with ', and contains no internal '. That is the byte pattern mod_quotatab_sql writes when it pre-escapes an INSERT value before handing it to the resolver. It is also the byte pattern an FTP client gets back by typing USER '<anything>'.
The heuristic was added in November 2020 to fix a regression in one internal caller. Since November 2020, mod_sql has been asking it about every caller, including the one across the network.
The heuristic was correct for the caller it was added for
In November 2020, TJ Saunders committed 86bdcf59 against Issue #1149: "Skip escaping of already-escaped SQL text." The regression report was specific. ProFTPD's then-new Jot API had unified text-resolution across logging modules, and as part of that unification it ran sql_escapestring on every value it resolved. mod_quotatab_sql.c had been escaping its own INSERT values before handing them off to the resolver, and the new uniform-escape pass turned that into double-escaping. Quotatab's own writes started landing in the database with backslashes the database stored verbatim.
The fix added one function. is_escaped_text() looked at the bytes and returned TRUE when they matched a single specific shape: start with ', end with ', no ' in between. The shape was what mod_quotatab_sql produced when it pre-quoted a value for safety. The resolver, before applying its own escape, asked is_escaped_text() and skipped its own escape if the answer was TRUE.
/* contrib/mod_sql.c, vulnerable commit ae25959 */
static int is_escaped_text(const char *text, size_t text_len) {
register unsigned int i;
if (text[0] != '\'') {
return FALSE;
}
if (text[text_len-1] != '\'') {
return FALSE;
}
for (i = 1; i < text_len-1; i++) {
if (text[i] == '\'') {
return FALSE;
}
}
return TRUE;
}
/* contrib/mod_sql.c:777, the caller */
if (is_escaped_text(text, text_len) == FALSE) {
modret_t *mr;
mr = sql_dispatch(sql_make_cmd(p, 2, resolved->conn_name, text),
"sql_escapestring");
/* ... */
} else {
pr_trace_msg(trace_channel, 17,
"text '%s' is already escaped, skipping escaping it again", text);
new_text = (char *) text;
new_textlen = text_len;
}
The function does what its name says. Given the bytes 'guest_account', it returns TRUE. Given the bytes guest_account, it returns FALSE. Given the bytes O'Brien', it returns FALSE because there is an internal '. The check is honest about itself: it is a structural test on bytes.
The check is also honest about what it cannot see. It does not see who wrote the bytes. It does not know that mod_quotatab_sql is the producer. It does not know that the resolver is being called on behalf of a logging directive whose values arrived from an FTP client one greeting ago. It sees bytes.
The same byte pattern is what an FTP client gets for free
mod_sql exposes a directive called SQLNamedQuery that lets an admin define an arbitrary SQL template with logging-format variables interpolated into it. The most common form, the one the upstream docs use as the canonical example, looks like this:
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
%U resolves to the original username from the FTP USER command, set before authentication and available even on a failed login. %r is the full FTP command. %m is the FTP method. The wrapping '...' is admin convention: the admin wraps each variable in single quotes so the rendered SQL has the value as a string literal.
The resolver runs is_escaped_text() on the value of %U before appending it. The %U value is whatever bytes the FTP client typed after USER. ProFTPD does not pre-escape it. The resolver receives the bytes the network sent.
If the FTP client sent USER ftpuser, the bytes the resolver sees are ftpuser. is_escaped_text("ftpuser", 7) returns FALSE. The resolver calls sql_escapestring. The resulting SQL is INSERT INTO activity_log VALUES('ftpuser', '<%r>', '<%m>'). The literal is closed by the wrapping '...' in the template, the value is escaped, the row is logged.
If the FTP client sent USER '<payload>', where <payload> contains no ', the bytes the resolver sees are '<payload>'. is_escaped_text("'<payload>'", N) returns TRUE. The trace log writes: text '<payload>' is already escaped, skipping escaping it again. The resolver appends the bytes as-is. The wrapping '...' in the template now becomes ''<payload>'' in the rendered SQL. The two empty-string close-outs end the surrounding literals; whatever is between them runs as raw SQL.
# preauth_user_backdoor.py, the payload, lifted verbatim
payload = (
f"', null, null); "
f"INSERT INTO users VALUES("
f"$${BACKDOOR_USER}$$, $${BACKDOOR_PASS}$$, 0, 0, $$/$$, $$/bin/bash$$"
f"); --'"
)
assert payload[0] == "'" and payload[-1] == "'" and "'" not in payload[1:-1]
The assert is the bypass written down. PostgreSQL's $$...$$ dollar-quoting lets the payload string the backdoor account's username, password, homedir, and shell without ever using a ', which keeps the outer wrapper inside the heuristic's accepting set. PQexec, ProFTPD's PostgreSQL driver, supports stacked queries. The stacked INSERT INTO users writes a uid=0 row into the auth table the FTP daemon reads from. The next USER backdoor; PASS pwned123 succeeds with homedir=/.
SQLLog ERR_* fires on the failed login that delivered the payload. The SQL injection runs on a failed login, before authentication, against an unauthenticated FTP session. The resolver does not need authentication to resolve %U; the value is set the moment USER arrives.
The trace log line at mod_sql.c:791 is the gate announcing the bypass. From the analysis repo's captured trace:
2026-04-29 06:35:11,297 [548] <sql:17>: text '', null, null); INSERT INTO users
VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --''
is already escaped, skipping escaping it again
The string the trace log labels "already escaped" arrived from the network in the previous millisecond.
The patch does not remove the heuristic. The patch sorts the callers.
Issue #2052 was opened on March 28, 2026, by LeftenantZero. The CVE was assigned April 24. Commit e6f72848 was committed on April 27, alongside the 1.3.9a release.
The patch is structural. It does not change is_escaped_text(). The function ships unchanged in 1.3.9a. The patch changes who calls it.
static int sql_resolved_append_text(pool *p, struct sql_resolved *resolved,
- const char *text, size_t text_len) {
+ const char *text, size_t text_len, int already_escaped) {
sql_resolved_append_text gets a fourth parameter. When already_escaped is TRUE, the function skips the heuristic and the escape entirely; the caller is asserting the value has been escaped already. When FALSE, the original logic, including is_escaped_text, runs as before.
+ /* Per Issue #2052, the following variable values can all be supplied
+ * remotely by the client. As such, they should be escaped preemptively.
+ */
case LOGFMT_META_ANON_PASS:
case LOGFMT_META_BASENAME:
- case LOGFMT_META_CLASS:
case LOGFMT_META_CMD_PARAMS:
case LOGFMT_META_COMMAND:
case LOGFMT_META_DIR_NAME:
case LOGFMT_META_DIR_PATH:
+ case LOGFMT_META_FILENAME:
+ case LOGFMT_META_IDENT_USER:
+ case LOGFMT_META_METHOD:
+ case LOGFMT_META_ORIGINAL_USER:
+ case LOGFMT_META_RESPONSE_STR:
+ case LOGFMT_META_REMOTE_HOST:
+ case LOGFMT_META_RENAME_FROM:
+ case LOGFMT_META_USER:
+ case LOGFMT_META_XFER_PATH: {
+ modret_t *mr;
+
+ mr = sql_dispatch(sql_make_cmd(p, 2, resolved->conn_name,
+ (const char *) val), "sql_escapestring");
+ /* ... */
+ already_escaped = TRUE;
+ break;
+ }
Fifteen variables, enumerated by name. Each value goes through sql_escapestring upfront, before the resolver buffer touches it, and the already_escaped=TRUE flag tells the buffer-append function to skip its heuristic on this input. The variables left in the other bucket of the switch, CLASS, ENV_VAR, GROUP, ISO8601, LOCAL_FQDN, LOCAL_IP, LOCAL_NAME, NOTE_VAR, PROTOCOL, REMOTE_IP, VERSION, VHOST_IP, XFER_FAILURE, XFER_SPEED, XFER_STATUS, XFER_TYPE, are server-side or config-supplied; the heuristic still runs on them.
The heuristic is preserved. Its job is what it always was: a backward-compat path for the trusted internal caller (mod_quotatab_sql) whose pre-escaped output must not be double-escaped. That caller still needs the heuristic, because the framework still has no way to tell its output apart from anyone else's output by inspection.
What changed is the routing. The April 2026 commit added a list of variables ProFTPD is now willing to admit are client-supplied, escaped them upfront, and threaded a flag through that says "do not ask the heuristic about these." The fix is the list.
Three names moved buckets
Read the diff again. LOGFMT_META_FILENAME, LOGFMT_META_REMOTE_HOST, and LOGFMT_META_XFER_PATH are deleted from the second bucket of the switch and added to the first.
Before the patch, the resolver classified those three as not-client-supplied. %f, the filename, was supposed to be a server-resolved absolute path. %h, the remote host, was supposed to be the daemon's reverse-DNS lookup. %F, the absolute transfer path, was supposed to be the same. None of them were considered attacker-influenced.
The April 2026 patch reclassified all three. The reclassification is the project admitting, in the same commit that closed the CVE, that the previous categorization was wrong. %h resolves from the client's IP, which is hard to forge, but UseReverseDNS can pull a hostname from an attacker-controlled DNS record. %f and %F resolve to a path the client supplied, after server-side normalization that does not strip leading characters that match the heuristic. The README of the analysis repo notes that %f, %F, and %D "always resolve to absolute paths beginning with /, so they cannot match the is_escaped_text() start-with-' requirement"; the patch authors disagreed about at least one of them, and moved XFER_PATH and FILENAME anyway.
The classification was the security boundary. The patch's switch statement, before April 2026, was the security boundary written in code: this set of variables is trusted, that set is not. The patch moved three variables across that boundary. The CVE record covers what those three names should have meant the entire time.
Internal-only-by-convention, held by a byte test
This is the internal-only-by-convention shape, applied at byte granularity. The framework defined a contract, "values produced by trusted internal callers are pre-escaped, wrapped in single quotes, with no internal quotes," and read that contract with security-relevant authority on every value the resolver touched. The contract was held by a byte pattern. The byte pattern was reproducible by any FTP client that could type USER '<x>'.
This pattern has been the failure mode of GitHub Enterprise's X-Stat header, which trusted babeld as the only writer of a semicolon-delimited string while accepting user push-options into the same string. It has been the failure mode of cPanel's session file, which trusted internal authorization codepaths as the only writers of successful_internal_auth_with_timestamp while accepting Basic-auth password values into a flat key=value disk format. Those previous exhibits had the contract held by a header name and a file format. ProFTPD's contract is held by three byte tests in a four-line C function.
The mechanism is the same. Provenance was the actual question. The framework had no provenance mechanism, so it asked a structural question that returned the right answer most of the time and the wrong answer on a specific input shape the attacker can produce.
The window
November 3, 2020 to April 27, 2026. Five years and roughly five months. The 2020 commit fixed mod_quotatab_sql's double-escape regression. The 2026 commit fixed the SQL injection the 2020 commit enabled.
The 2020 commit's body says the reason: "the introduction of the Jot API added proper escaping of resolved text. However, the mod_quotatab_sql module was already escaping some of its text in INSERT statements (but, inconsistently, not in SELECT statements), thus the Jot API refactoring caused a regression."
The right fix in 2020 would have been to remove the pre-escape from mod_quotatab_sql and let the unified resolver do its job. That would have demanded auditing every other caller for similar legacy pre-escapes, and shipping a migration for any admin config that depended on the old behavior. The cheaper fix was the heuristic. The cheaper fix shipped.
The 2026 patch is the audit, finally. The patch's switch statement is the list of every variable the project now admits the resolver should never have been trusting the bytes of. The list is fifteen names long. The heuristic stays.
PoC and reproduction harness: dinosn/proftpd-CVE-2026-42167-analysis. Original disclosure: ZeroPath Research. Upstream issue: proftpd/proftpd#2052. Patch: e6f72848.