はじめに
(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 [パッケージ名]
といったコマンドを実行した際に参照される。
FlaskやTensorFlowなど、多数のプロジェクトのインストール手順内において間接的に使用されている。
調査理由
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_v2
のprefix
パラメータを用いて削除対象のオブジェクトを取得している。prefix
パラメータには、ユーザーが所有するプロジェクト名がそのまま挿入されるような仕様となっていた。
つまり、examp
といったようなプロジェクトのドキュメント削除処理を実行した場合、examp
だけでなく、example
やexampleasdf
等の、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
上で任意のコードを実行することが可能だった。
- pypa/warehouseをフォークする
- フォークしたリポジトリ内において、
dependabot;cat$IFS$(echo$IFS'LmdpdA=='|base64$IFS'-d')/config|base64;sleep$IFS'10000';#
というブランチを作成する3 - 作成したブランチ上で無害な変更を加える
- マージされにくそうな名前でプルリクエストを作成する (例:
WIP
) combine-prs.yml
が実行されるのを待つ- pypa/warehouseに対するGitHubアクセストークンが漏洩するため、そのトークンを用いて
main
ブランチに変更を加える - 加えた変更が
pypi.org
へデプロイされる
この脆弱性もPythonのセキュリティチームに報告し、無事にこのコミットで修正された。
(2021/07/31 21:55 JST 追記)
@mrtc0氏から以下の指摘を受けたため確認した所、上記で記載した攻撃手順ではなく、別の攻撃手順を用いる必要があることが判明した。
すいません、質問です。該当 Workflow の https://t.co/zQUi6bw1rC の部分で context.repo.owner には Workflow を実行したリポジトリの所有者(pypa)が入るので、commit が見つからずに Workflow は中止されると思うのですが、そうではないのでしょうか...?
— Kohei MORITA (@mrtc0) July 31, 2021
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
上で任意のコードを実行することができた。
- pypa/warehouseをフォークする
pypa/warehouse
上に存在するブランチの内、dependabot
で始まるものを選ぶ- フォークしたリポジトリの当該のブランチ上に無害な変更を加える
`;github.auth().then(auth=>console.log(auth.token.split("")))//
という名前でプルリクエストを作成する。combine-prs.yml
が実行されるのを待つ- pypa/warehouseに対するGitHubアクセストークンが漏洩するため、そのトークンを用いて
main
ブランチに変更を加える - 加えた変更が
pypi.org
へデプロイされる
まとめ
本記事で説明した脆弱性は、Pythonのエコシステムに対して潜在的に非常に大きな影響を与えるものでした。
以前から何度か言及しているとおり、一部のサプライチェーンは非常に脆弱な状態となっています。
しかしながら、サプライチェーン攻撃に関する研究を行っている人は限られており、ほとんどのサプライチェーンが適切に保護されていない状況です。
そのため、当該のサプライチェーンに依存しているユーザーが、そのサプライチェーンにおけるセキュリティ面での向上に積極的に寄与する必要があると考えられます。
本記事に関する質問/感想はTwitter(@ryotkak)へメッセージを送信してください。
タイムライン
日付 (UTC) | 出来事 |
---|---|
2021/07/25 | ドキュメントの削除に関する脆弱性の発見、報告 |
2021/07/26 | ドキュメントの削除に関する脆弱性の修正 |
2021/07/27 | 権限の削除に関する脆弱性の発見、報告 |
2021/07/27 | combine-prsにおける脆弱性の発見、報告 |
2021/07/27 | 権限の削除に関する脆弱性の修正 |
2021/07/27 | combine-prsにおける脆弱性の修正 |
2021/07/29 | アドバイザリの公開 |
2021/07/30 | 本記事の公開 |
ただし、セキュリティポリシーを改善する計画は存在する: https://github.com/pypa/warehouse/issues/7970 ↩︎
余談だが、この脆弱性を報告する際、
role_id
は連番なので推測可能と説明した。しかしながら、実際はUUIDだったらしい。 ↩︎このブランチ名は、Bash内で実行された場合に
cat .git/config | base64; sleep 10000
を実行する。 ↩︎