GitPythonに6件の脆弱性が一斉公開——拒否リスト方式の構造的限界

2026年8月7日、Python向けGitライブラリGitPythonに6件のセキュリティアドバイザリが15分のあいだに一斉公開された。任意コマンド実行3件、任意ファイル上書き1件、任意ファイル読み取り1件、作業ツリー外へのリポジトリ作成1件。個々のバグ以上に重いのは、危険オプションを拒否リストで弾くという防御設計そのものが繰り返し破られている事実だ。影響は3.1.57以下、修正は3.1.58。

GitPythonに6件の脆弱性が一斉公開——拒否リスト方式の構造的限界

2026年8月7日(日本時間8日未明)、Python から Git を操作する定番ライブラリ GitPython に、6件の GitHub Security Advisory がわずか15分のあいだに立て続けに公開された。内訳は任意コマンド実行が3件、任意ファイル上書きが1件、任意ファイル読み取りが1件、作業ツリー外への任意リポジトリ作成が1件。影響を受けるのは 3.1.57 以下のすべてのバージョンで、修正は 3.1.58 で提供されている。

数の多さも目を引くが、本当に重いのはその中身だ。6件のうち複数が「過去に修正したはずの穴が別の入口から再び開いていた」もしくは「同じ修正のときに隣接するメソッドが取り残されていた」という性格を持つ。つまりこれは単発のバグの束ではなく、GitPython が採用してきた防御方式そのものが問われている事案である。

背景:GitPythonは「gitコマンドの薄いラッパー」である

前提として押さえておきたいのは、GitPython が Git のC実装に対する言語バインディングではないという点だ。GitPython の主要な処理は、subprocess で外部の git コマンドを起動し、その標準出力を解釈するという構造になっている。Python 側で書いた Repo.clone_from(url, path, depth=1) のようなキーワード引数は、内部で --depth=1 といったコマンドライン引数の文字列へと変換されてから git に渡される。

この「Python の引数をコマンドライン引数へ変換する」層こそが、今回の6件のうち5件に共通する入口である(残る1件、submodule に関するものだけは .gitmodules のパース経路を突く別系統の問題で、これについては後述する)。

git のコマンドラインオプションには、その場でコマンドを実行させられるものがいくつも存在する。代表格が --upload-pack--receive-pack、そして --exec だ。これらは本来リモート側で走らせる git 本体のパスを指定するためのものだが、任意の文字列を渡せるので、実質的に「好きなコマンドを実行させるスイッチ」として機能してしまう。

そこで GitPython は、check_unsafe_options() という検査関数と allow_unsafe_options というフラグ(既定値は False)を用意した。危険なオプション名をリストに列挙しておき、呼び出し時にそれが含まれていたら UnsafeOptionError を投げて止める。いわゆる拒否リスト(denylist)方式である。

この方式は以前から補修を重ねてきた。設定値への改行注入で core.hooksPath を作られる問題は 3.1.49 で、Repo.archive()git.ls_remote() のキーワード引数から --exec--upload-pack が通る問題は 3.1.51 で、それぞれ塞がれている。今回公開された一連のアドバイザリは、それでもなおこの拒否リストが構造的に迂回できることを、6通りのやり方で示したものだ。

6件のアドバイザリが示したもの

ガードが「危険なトークンを見ないまま通す」

最も象徴的なのが GHSA-wvpp-8hx9-p66j (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)である。

GitPython には split_single_char_options という、1文字のオプションを分割して渡すかどうかを制御する引数がある。検査側の _option_candidates は、cmd.py:1048if len(key)==1 and split_single_char_options: という条件が真のときにしか、値に由来する候補を検査対象へ加えない。そのため split_single_char_options=False を指定したうえで {"n": "utouch <cmd>;git-upload-pack"} を渡すと、検査に回るのは -n だけになり、拒否リストにかからず素通りする。

ところが実際に argv を組み立てる transform_kwarg は、cmd.py:1631-nutouch <cmd>;git-upload-pack という結合されたトークンを出力する。git はこれを値を取らないショートフラグの塊とみなして分解し、-u<cmd> すなわち --upload-pack=<cmd> として解釈する。結果として、既定の allow_unsafe_options=False のままコマンドが実行される。

アドバイザリはこの構造をこう表現している——ガードは、その結合トークンを見せられていれば止められた。欠陥は、ガードがそれを一度も受け取らないことにある、と。これは GHSA-r9mr-m37c-5fr3 に対する修正コミット e8d0fbf7 の不完全修正バイパスにあたる。

設定ファイルの「オプション名」が検証されていなかった

GHSA-jm78-9fvv-mhgr (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)は、git-config の書き込み経路を突く。

git/config.py:897_assure_config_name_safe(name, label) は、括弧や引用符を検査する状態機械を label == "section" のときにしか適用しない。"option" ラベルに対しては [\r\n\x00] という正規表現しか掛からず、= # ; [ ] や空白は素通りする。そして config.py:702write_section は、オプション名を "\t%s = %s\n" の書式にそのまま埋め込む。

したがってオプション名として sshCommand = touch <cmd> # を渡すと、設定ファイルには \tsshCommand = touch <cmd> # = <value> という行が書かれる。git はこれを core.sshCommand = touch <cmd> と読む。末尾の # が本来の値をコメントアウトしてしまうためだ。core.sshCommand は SSH を伴う任意の git 操作で発火するため、ステージされたファイルなどの追加条件なしにコード実行へ至る。

残る4件:読み取り、上書き、リポジトリ作成、そして取り残された実装

  • GHSA-hh9p-6wh2-4mfc (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)は、IndexFile.remove()Head.checkout()**kwargs を無検査で転送する点を突く。--pathspec-from-file=<file>--pathspec-file-nul を組み合わせるとファイル全体が単一の pathspec として扱われ、一致しなかった旨のエラーメッセージがその内容を逐語的に含む。それが GitCommandError.stderr からそのまま返り、任意ファイルの中身がインバンドで漏れる。既存の GHSA-3f7w-8rr8-f37f はこの2箇所を「読み取りのみで開示はない」と評価していたが、その判断が覆された形だ。
  • GHSA-4gmw-gg2m-w46p (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H)は、index/base.pyfrom_tree reset merge_tree が treeish 文字列を位置引数のまま repo.git.read_tree() へ渡す点を突く。git read-tree --index-output=<file> は結果の index を任意のパスに書き出す機能を持ち、後に現れた指定が勝つ仕様のため、注入された --index-output がメソッド内部の一時パス指定を上書きする。書き込まれる中身は攻撃者が選べないが、パスは完全に制御できるので、任意のファイルを index のバイナリで破壊できる。GHSA-3f7w-8rr8-f37f の修正コミット 3af0c251checkout_indextag だけをガードし、read_tree を取り残していたことが原因である。
  • GHSA-9rj7-rf2p-w77r (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H)は、git/repo/base.py:1435Repo.init が素の git.init(**kwargs) であり、検査もフラグも持たないことを突く。git init --template=<dir> は指定ディレクトリの hooks/* を新リポジトリへコピーするため、次の commit などでフックが発火する。--template は clone 側では既に危険オプションとして登録済みだったが、Repo.init は独立した実装だったため取り残されていた。アプリが template= を転送していることと、攻撃者が実行可能な hooks ディレクトリを既知のパスへ事前に置けることの両方が必要になるため、攻撃条件の複雑さは高(AC:H)と評価されている。
  • GHSA-hmq2-w58f-27jc (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:L)は、.gitmodules[submodule "..."] に書かれた名前が検証されないまま osp.join(parent_repo.git_dir, "modules", name) に流れ込む点を突く。os.path.join../ を正規化しないため、作業ツリーの外に Git リポジトリを作成できる。本家の git は CVE-2018-11235 への対応として不審な submodule 名を拒否するが、GitPython にはその防御がなかった。CI で submodule の初期化が自動実行される構成では、悪意あるリポジトリを clone するだけで条件が揃う。

なお、報告者として zx (Jace) 氏(GitHub: @manus-use)の名前が4件のアドバイザリに明記されている。修正は主に PR #2204 で、submodule 名の件は PR #2202 で取り込まれた。

影響:直ちに危険ではないが、CI/CDでは条件が揃いやすい

冷静に押さえておきたいのは、GitPython を使っているだけで即座に攻撃可能になるわけではない、という点だ。6件のうち5件に共通する前提条件は「アプリケーション側が、呼び出し元(多くの場合は利用者)に由来する値を、GitPython のメソッドへ引数として転送していること」である。リポジトリのURLやパスをハードコードして使っているだけなら、この5件の攻撃モデルは成立しない。例外は submodule の件で、こちらは引数の転送ではなく、攻撃者が用意したリポジトリを clone して submodule を初期化すること自体が条件になる。

問題は、これらの前提が現実の運用ではさほど珍しくないことだ。

外部リポジトリを取り込む Web サービスの連携機能、ユーザーが指定したリポジトリをクローンする CI ランナー、設定ファイル由来の値でリポジトリ操作を行う MLOps のデータ取得処理。いずれも「利用者由来の文字列を引数へ流し込む」構造になりやすい。とりわけ submodule の件は、悪意あるリポジトリを clone して初期化するだけで成立するため、前提条件が最も緩い。

もう一つ見落とされがちなのが、GitPython が AI コーディングエージェントのリポジトリ操作でも定番ライブラリの一つになっている点である。エージェントが外部から受け取った文字列を検証せず引数へ渡す実装は、今回の攻撃モデルにそのまま当てはまる。今回のアドバイザリ自体は AI とは無関係の純粋なライブラリ設計の問題だが、外部入力を機械的に引数へ流す実装が増えるほど、この種の穴は踏まれやすくなる。

対応そのものは単純だ。GitPython を 3.1.58 へ更新すること。加えて、利用者由来の値を **kwargs としてそのまま転送している箇所があれば、許可する引数を明示的に列挙する形(許可リスト方式)へ切り替えておくのが望ましい。

そして今後を占ううえで最も示唆的なのは、GHSA-wvpp-8hx9-p66j のアドバイザリが提示した修正案である。曰く、split_single_char_options の値にかかわらず候補を生成するか、あるいは「再構成した名前だけの候補リストではなく、完全に変換された argv 全体に対して検査を掛ける」べきだ、と。検査する対象と実際に実行される対象がずれている限り、拒否リストは穴を埋めても埋めても別の経路が開く。これは GitPython 固有の話ではなく、外部コマンドをラップするあらゆるライブラリに共通する教訓と言える。

よくある質問

使っているGitPythonが危険かどうか、どう判断すればよいか

まずバージョンを確認する。3.1.57 以下であれば、6件すべての影響範囲に入る。ただし実際に攻撃可能かは、アプリケーション側が呼び出し元由来の値を GitPython のメソッドへ引数として渡しているかどうかで決まる。リポジトリのURLやパスをコード内に固定して使っているだけなら、6件のうち5件は成立しない。ただし submodule の件だけは例外で、clone する対象のリポジトリが信頼できないものであれば該当する。

修正するにはどうすればよいか

GitPython を 3.1.58 へ更新する。6件すべてがこのバージョンで修正されている。あわせて、利用者から受け取った辞書を **kwargs としてそのまま転送している箇所があれば、受け付ける引数を明示的に列挙する実装へ変更しておくと、同種の問題の再発を防ぎやすい。

なぜ6件も同時に公開されたのか

個別に発見された無関係なバグが偶然重なったわけではない。アドバイザリの記述を読むと、複数の件が過去の修正コミットを名指しで参照し、その修正が別の経路で回避できること、あるいは修正時に隣接するメソッドが取り残されていたことを指摘している。危険オプションを拒否リストで弾くという防御方式そのものを体系的に検証した結果、まとまった数の穴が同時に見つかったという性格の報告である。

拒否リスト方式の何が問題なのか

検査している対象と、実際に実行される対象がずれている点にある。GitPython の検査関数は、キーワード引数から再構成した「オプション名らしきもの」のリストを見ているが、実際に git へ渡されるのは変換後の argv である。両者が一致しない経路が一つでも残っていれば、危険なオプションは検査をすり抜けて git に届く。危険なものを列挙して弾く方式は、列挙漏れが一つあれば破られるという非対称性を常に抱えている。

まとめ

2026年8月7日に公開された GitPython の6件のアドバイザリは、影響バージョンが 3.1.57 以下、修正が 3.1.58 という点では対応の分かりやすい事案である。更新すれば済む。

だが記録に残しておく価値があるのは、その6件が浮かび上がらせた構造のほうだ。危険なオプションを拒否リストで弾くという設計は、検査の対象と実行の対象が一致していなければ機能しない。GitPython の場合、キーワード引数から再構成した名前の一覧と、実際に組み立てられる argv とのあいだにずれがあり、そこを通って --upload-pack--template--index-output も検査をすり抜けた。過去の修正が参照され、それが不完全であったと繰り返し指摘されている点が、この方式の限界を何より雄弁に語っている。

外部コマンドをラップするライブラリを書く側にとっても、それを使う側にとっても、「何を禁止するか」ではなく「何を許可するか」を起点に設計するという原則を、あらためて思い起こさせる事案だった。

参考ソース

関連記事