はじめに

(English version is also available.)

PyPIは、セキュリティページ自体は公開しているものの、脆弱性診断行為に対する明確なポリシーを設けていません。1
本記事は、公開されている情報を元に脆弱性の存在を推測し、実際に検証することなく潜在的な脆弱性として報告した問題に関して説明したものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。

PyPIに脆弱性を発見した場合は、Reporting a security issueページを参考に、[email protected]へ報告してください。

要約

PyPIのソースコードを管理しているリポジトリのGitHub Actions上において、悪意あるプルリクエストが任意のコマンドを実行する事が可能な脆弱性が存在した。
これにより、当該のリポジトリに対して書き込み権限を得ることができ、結果としてpypi.orgにおける任意コード実行へと繋がる可能性があった。

PyPIとは

PyPIは、Pythonのパッケージマネージャーであるpipによって使用されているパッケージレジストリであり、pip install [パッケージ名]といったコマンドを実行した際に参照される。
FlaskTensorFlowなど、多数のプロジェクトのインストール手順内において間接的に使用されている。

Flaskのインストール手順の画像

調査理由

Max Justicz氏のブログを読んでいる際に、PyPIに対して脆弱性を報告した旨が記載されていることに気がついた。
記事内で言及されているアドバイザリを読んでいた際に、PyPIのソースコードがGitHub上で公開されていることに気がついたため、ソースコードを読むことにした。

ソースコードの調査

現行のPyPIのソースコードは、pypa/warehouseというリポジトリで管理されている。

コードを軽く流し読みした所、PyramidというPython用のWebフレームワークが使用されていることがわかった。
フレームワークに関する仕様を読んだ後、コードを読み進めていくと、以下の2つの脆弱性が見つかったため報告した。

任意のプロジェクトドキュメントの削除

PyPIは、過去にプロジェクト毎に管理できるドキュメント機能を実装していた。
この機能を実装してからしばらくしても使用率があまり上がらなかったため削除されたのだが、削除時点で作成されていたドキュメントに関してはそのままとなっていた。
そのため、これらのドキュメントを削除する機能が必要となり、このプルリクエストで実装された。

この機能は内部的に、以下のようなコードを用いてドキュメントを削除している。

    def remove_by_prefix(self, prefix):
        if self.prefix:
            prefix = os.path.join(self.prefix, prefix)
        keys_to_delete = []
        keys = self.s3_client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix)
        for key in keys.get("Contents", []):
            keys_to_delete.append({"Key": key["Key"]})
            if len(keys_to_delete) > 99:
                self.s3_client.delete_objects(
                    Bucket=self.bucket_name, Delete={"Objects": keys_to_delete}
                )
                keys_to_delete = []
        if len(keys_to_delete) > 0:
            self.s3_client.delete_objects(
                Bucket=self.bucket_name, Delete={"Objects": keys_to_delete}
            )

このコードから分かるように、list_objects_v2prefixパラメータを用いて削除対象のオブジェクトを取得している。
prefixパラメータには、ユーザーが所有するプロジェクト名がそのまま挿入されるような仕様となっていた。

つまり、exampといったようなプロジェクトのドキュメント削除処理を実行した場合、exampだけでなく、exampleexampleasdf等の、exampで始まる全てのプロジェクトのドキュメントが削除される状態だった。

任意のプロジェクトにおける権限の削除

PyPIには、プロジェクト単位で管理可能な権限システムがある。
このシステムにおいて、プロジェクトのオーナーは権限の付与/剥奪が出来たのだが、この剥奪処理に脆弱性が存在した。

この機能が対象の権限を削除する際に、以下のようなコードを用いてデータベースから情報を取得していた。

role = (
    request.db.query(Role)
    .join(User)
    .filter(Role.id == request.POST["role_id"])
    .one()
)

このコードからわかるように、権限情報を取得する際にプロジェクトIDの指定が行われていない。
これにより、悪意のあるユーザーが他のプロジェクトのrole_idを推測し、削除用のエンドポイントにリクエストを送信することで、他のプロジェクトのユーザーから権限を剥奪することが出来た。2

任意コード実行

前述の通り、ソースコードの調査中に発見した脆弱性を2つ報告した。
しかしながら、これらの脆弱性はそこまで影響があるものではなく、せいぜい嫌がらせ程度にしか使えない。
せっかく脆弱性を報告するのなら、もう少し影響がある物を報告したいと考え、任意コード実行が可能な脆弱性を探すことにした。

その後、パッケージのアップロード機能やプロジェクト管理機能等のコードを読んだのだが、任意コード実行に繋げることが可能な脆弱性は見つからなかった。

そんな中、Unintended Deployments to PyPI Serversという記事を見つけた。
この記事をよく読んでみると、どうやらpypa/warehouseリポジトリのmainブランチにPushされたコードは自動でpypi.orgにデプロイされるらしい。
つまり、このリポジトリに対して書き込み権限を奪取できた場合、pypi.org上での任意コード実行へと繋げることが出来る。
そのため、リポジトリに対する書き込み権限をデフォルトで持っているGitHub Actionsのワークフローファイルを確認した所、以下のような脆弱性を見つけた。

ワークフローファイルの調査

pypa/warehouseには、combine-prs.ymlという名前のワークフローが存在する。

これは、バージョンを管理用のBotであるDependabotにプルリクエストを一括でマージする機能がないため、GitHub Actionsを使用して同様の機能を作成しようという趣旨のワークフローであり、デフォルトでdependabotで始まるブランチ名からのプルリクエストを集め、それらを一つのプルリクエストにまとめるという機能を持っている。

このワークフローにおいて、プルリクエストの作成者が検証されていなかったため、悪意あるユーザーがdependabotで始まるブランチ名を持つプルリクエストを作成した場合、当該のプルリクエストを処理させることが可能となっていた。

ただし、このワークフローはプルリクエストを一つにまとめるところまでしか行わない。
つまり、一つにまとめられたプルリクエストに関しては、人間によるレビューが行われ、不審な内容が含まれていれば弾かれてしまう。

そのため、これ単体では明確な脆弱性とは言えない状態だった。
そこで、コードを読み進めていると、もう一つ脆弱性が存在することに気がついた。

この行において、combine-prs.ymlは対象のブランチ一覧を以下のコードを用いてログに出力していた。

run: |
  echo "${{steps.fetch-branch-names.outputs.result}}"

単純なechoコマンドであり、一見問題なさそうに見えるが、GitHub Actionsの仕様上これは安全ではない。

Keeping your GitHub Actions and workflows secure: Untrusted inputという記事でも紹介されている通り、${{ }}という構文は、それぞれの処理が実行される前に置き換えられる。

つまり、この構文はコンテキストを考慮しないため、steps.fetch-branch-names.outputs.result";curl https://example.com;#と言ったような文字列が含まれていた場合、curl https://example.comが実行されることになる。

このワークフローは、actions/checkoutを使用してリポジトリをクローンしていたため、.git/configに書き込み権限を持つGitHubのアクセストークンが含まれている状態だった。
そのため、cat .git/configのようなコマンドを実行することにより、pypa/warehouseに対する書き込み権限を持つGitHubアクセストークンを漏洩させることが出来る。

前述の通り、mainブランチに対してPushを行った場合、自動でpypi.orgに対してデプロイが行われる。
これらの条件を用いることにより、以下の手順を用いてpypi.org上で任意のコードを実行することが可能だった。

  1. pypa/warehouseをフォークする
  2. フォークしたリポジトリ内において、dependabot;cat$IFS$(echo$IFS'LmdpdA=='|base64$IFS'-d')/config|base64;sleep$IFS'10000';#というブランチを作成する3
  3. 作成したブランチ上で無害な変更を加える
  4. マージされにくそうな名前でプルリクエストを作成する (例: WIP)
  5. combine-prs.ymlが実行されるのを待つ
  6. pypa/warehouseに対するGitHubアクセストークンが漏洩するため、そのトークンを用いてmainブランチに変更を加える
  7. 加えた変更がpypi.orgへデプロイされる

GitHub Actions内における任意コード実行

この脆弱性もPythonのセキュリティチームに報告し、無事にこのコミットで修正された。

(2021/07/31 21:55 JST 追記)
@mrtc0氏から以下の指摘を受けたため確認した所、上記で記載した攻撃手順ではなく、別の攻撃手順を用いる必要があることが判明した。

combine-prs.yml 119行目において、以下のようなコードが存在する。

script: |
  const prString = `${{ steps.fetch-branch-names.outputs.prs-string }}`;

前述の通り、${{ }}構文はコンテキストを考慮しない。
そのため、steps.fetch-branch-names.outputs.prs-stringに対して`;console.log("test")//といったような文字列が含まれていた場合、console.log("test")が実行されることになる。
steps.fetch-branch-names.outputs.prs-stringは、プルリクエストのタイトルを含んでいるため、以下のような手順を用いることによりsecrets.GITHUB_TOKENを窃取し、pypi.org上で任意のコードを実行することができた。

  1. pypa/warehouseをフォークする
  2. pypa/warehouse上に存在するブランチの内、dependabotで始まるものを選ぶ
  3. フォークしたリポジトリの当該のブランチ上に無害な変更を加える
  4. `;github.auth().then(auth=>console.log(auth.token.split("")))// という名前でプルリクエストを作成する。
  5. combine-prs.ymlが実行されるのを待つ
  6. pypa/warehouseに対するGitHubアクセストークンが漏洩するため、そのトークンを用いてmainブランチに変更を加える
  7. 加えた変更がpypi.orgへデプロイされる

まとめ

本記事で説明した脆弱性は、Pythonのエコシステムに対して潜在的に非常に大きな影響を与えるものでした。
以前から何度か言及しているとおり、一部のサプライチェーンは非常に脆弱な状態となっています。
しかしながら、サプライチェーン攻撃に関する研究を行っている人は限られており、ほとんどのサプライチェーンが適切に保護されていない状況です。
そのため、当該のサプライチェーンに依存しているユーザーが、そのサプライチェーンにおけるセキュリティ面での向上に積極的に寄与する必要があると考えられます。

本記事に関する質問/感想はTwitter(@ryotkak)へメッセージを送信してください。

タイムライン

日付 (UTC)出来事
2021/07/25ドキュメントの削除に関する脆弱性の発見、報告
2021/07/26ドキュメントの削除に関する脆弱性の修正
2021/07/27権限の削除に関する脆弱性の発見、報告
2021/07/27combine-prsにおける脆弱性の発見、報告
2021/07/27権限の削除に関する脆弱性の修正
2021/07/27combine-prsにおける脆弱性の修正
2021/07/29アドバイザリの公開
2021/07/30本記事の公開

  1. ただし、セキュリティポリシーを改善する計画は存在する: https://github.com/pypa/warehouse/issues/7970 ↩︎

  2. 余談だが、この脆弱性を報告する際、role_idは連番なので推測可能と説明した。しかしながら、実際はUUIDだったらしい。 ↩︎

  3. このブランチ名は、Bash内で実行された場合にcat .git/config | base64; sleep 10000を実行する。 ↩︎