English version is available here: https://blog.ryotak.me/post/homebrew-security-incident-en/
(公式インシデント報告はこちらから読むことができます: https://brew.sh/2021/04/21/security-incident-disclosure/)

はじめに

HomebrewプロジェクトはHackerOne上で脆弱性開示制度(Vulnerability Disclosure Program)を設けており、脆弱性の診断行為が許可されています。
本記事は、当該制度に参加し、Homebrewプロジェクトのスタッフから許可を得た上で実施した脆弱性診断行為について解説したものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。
Homebrewに脆弱性を発見した場合は、Homebrewプロジェクトの脆弱性開示制度へ報告してください。

要約

HomebrewのCaskを管理しているリポジトリにおいて、プルリクエストの自動レビュー機能に使用されているライブラリにバグが存在し、結果として自動レビュー機能に悪意のないプルリクエストだと誤判定させることが可能だった。
これにより、悪意のあるコードをHomebrew/homebrew-caskへ自動的にマージし、Homebrewを利用しているユーザーのコンピューター上で任意のRubyコードを実行することができた。

調査理由

とある日の午後、次の予定までの時間が微妙に空いていた1ため、HackerOne上で面白そうなプログラムを探すことにした。
自分が利用しているサービス/ソフトウェアの脆弱性を優先して探したいと思ったため、手元のPCの中を漁っていると、brewコマンドに目が行った。
以前、HackerOne上のプログラム一覧を眺めているときに、Homebrewというプログラムを見かけたことを思い出し、せっかくなので脆弱性を探すことにした。

HackerOne上のHomebrewプログラムの画像

調査対象の選定

調査する対象を選定するために、脆弱性開示制度のポリシーページを確認すると、brewコマンド本体の他にHomebrew/homebrew-*にマッチするGitHubリポジトリに関する脆弱性報告を受け付けていることに気がついた。
複雑なRubyのコードを読むのが苦手なので、ひとまずHomebrew/homebrew-*における脆弱性を探すことにした。

HackerOne上のHomebrewプログラムにおけるスコープセクション

初期調査

GitHubリポジトリにおける脆弱性は、主に以下の2つが多いように思われる。

  1. 当該のリポジトリに対する書き込み権限を持ったトークンの漏洩
  2. 当該のリポジトリが利用しているCIスクリプトにおける脆弱性

そのため、これら2つの脆弱性に絞り、脆弱性を確認していくことにした。
1つ目に関して確認するために、Homebrewに所属しているユーザーが所有しているリポジトリをすべて取得し、それらの中からトークンと思わしき文字列を抽出していった。
しかしながら、この方法での脆弱性が見つかるのは非常に稀であり、当然有効なトークンは見つからなかった。2

そのため、2つ目に関連した脆弱性が無いかどうか確認することにした。

CIスクリプトの調査

Homebrewは、CIスクリプトを実行するためにGitHub Actionsを使用している。3
そのため、各リポジトリの.github/workflows/配下のファイルを調べていくことにした。

いくつかのリポジトリを確認した後、Homebrew/homebrew-caskに置かれていたreview.ymlと、automerge.ymlに非常に強い関心を持った。
どうやら、review.ymlがユーザーが送信したプルリクエストの内容を確認し、そのプルリクエストが非常に単純な変更(例: バージョンアップなど)のみを加える場合、自動でプルリクエストを承認し、その後automerge.ymlが承認されたプルリクエストを自動でマージしているようだった。

review.ymlの抜粋画像 automerge.ymlの抜粋画像

review.ymlの調査

review.ymlが利用しているRubyスクリプト4は変更点をdiffファイルとして取得し、それをgit_diffというGemを用いてパースする。
その後、以下の条件を満たしている場合にのみプルリクエストを承認していた。

  1. 変更中のファイルが1つのみ
  2. ファイルを移動/新規作成/削除していない
  3. 変更中のファイルパスが\ACasks/[^/]+\.rb\Zにマッチする
  4. 削除した行数と追加した行数が同数
  5. 削除/追加した行の全てが/\A[+-]\s*version "([^"]+)"\Z/\A[+-]\s*sha256 "[0-9a-f]{64}"\Zのどちらかにマッチする
  6. バージョンが変更される場合、フォーマットが以前と同様 (例: 1.2.3 => 2.3.4)

…等5

条件を精査したが、非常に厳密な条件だったため脆弱性は見つからず、この条件下で任意のコードを挿入するのは不可能であると結論付けた。
その後、しばらく他のスクリプトの確認を行っていたのだが、どういうわけかこのスクリプトの事が頭から離れなかった。
そのため、このスクリプトを徹底的に調べることにし、diffファイルのパースを行っているライブラリを調べ始めた。

git_diffの調査

git_diffのリポジトリを眺めている際に、変更された行数のパースが間違っているという旨のIssueを発見した。
このIssueを見て、どうにかしてgit_diffを混乱させ、上記の条件を満たすように偽装できないかと考え始めた。

詳しくコードを読んでいくと、どうやらgit_diffは以下のような処理を行いdiffファイルをパースしているようだった。

  1. ファイルの内容を改行区切りで分割する
  2. それぞれの行に対して、^diff --git(?: a/(\S+))?(?: b/(\S+))?がマッチするかどうかを確認し、マッチすればファイル情報として判定し、現在処理中のファイル情報を正規表現にマッチしたものに置き換える。
  3. 2でマッチしなければ、以下の正規表現にマッチするかを確認、マッチしていれば変更元/変更先のファイルパスを内容に応じて書き換える。 diffファイルの移動元/移動先ファイルパスにマッチする正規表現
  4. 3でマッチしなければ、ファイル自体の変更として扱い、+で始まっていれば追加、-で始まっていれば削除、それ以外は元々のファイル内容として扱う。
  5. 上記を繰り返し、全ての行を処理し次第終了する。

一見問題ないように見えるこの処理だが、3の移動元/移動先ファイルパスにマッチするかどうかの判定を複数回走らせることが可能となっていた。

GitHubが返すdiffファイルは、以下の構造の変更データをファイルごとに作成し、それを単一ファイルに纏めたものとなっている。

diff --git a/変更前のファイルパス b/変更後のファイルパス
index 親コミットハッシュ..現コミットハッシュ ファイルモード
--- a/変更前のファイルパス
+++ b/変更後のファイルパス
@@ 行情報 @@
以下変更詳細 (例: `+asdf`、`-zxcv`等)

行追加に関しては、追加された行の前に+を追加することにより表現される。

つまり、++ "?b/(.*)にマッチする行であれば、ファイル内容の変更ではなく変更中のファイルパス情報として認識されてしまう状態だった。
ここで、先程の条件を確認すると、変更中のファイルパスに対する条件は\ACasks/[^/]+\.rb\Zだけとなっている。
先述の通りファイルパス情報変更は複数回行えるため、以下のような変更を加えることにより上記の条件を回避し、変更された行が0行の無害なプルリクエストと判定させることができる可能性が高かった。6

++ "b/#{ここに任意のコード}"
++ b/Casks/cask.rb

デモンストレーションの準備

HackerOne等のバグバウンティプラットフォームは、基本的にPoCや脆弱性のデモンストレーションをレポート内で送信する必要がある。
そのため、この脆弱性が実際に悪用可能かどうかを確かめることにした。 

実際に使用されているCaskを許可なく変更するのはあまりよろしくないため、homebrew-cask内において、テスト用のCaskを探すことにした。
しかしながら、テスト用のCaskが見つからなかったため、仕方なくHomebrewの脆弱性開示制度を担当している方にメールを送信した。

その後、担当している方からこれだけのためにテスト用のCaskを追加する無理なので、代わりに既存のCaskに無害な変更を加えてくれという旨の返信が帰ってきた。
そのため、適当なCaskを選び、無害な変更を加えることにした。

脆弱性のデモンストレーション

この日の前日にGitHubのAPI Tokenをうっかり載せてしまったプルリクエストを見かけていたため、このプルリクエストが変更しようとしていたiterm2.rbに対して変更を加えることにした。

変更を加える際に気がついたのだが、++ "b/"はRubyの文法として間違っていないが、++ b/Casks/iterm2.rbは変数を定義しないとエラーになってしまう。
そのため、Homebrew/homebrew-caskをフォークし、以下の2行をCasks/iterm2.rbへ追記した。7

++ "b/#{puts 'Going to report it - RyotaK (https://hackeorne.com/ryotak)';b = 1;Casks = 1;iterm2 = {};iterm2.define_singleton_method(:rb) do 1 end}"
++ b/Casks/iterm2.rb

一行目でbCasksiterm2iterm2.rbを定義することにより、二行目でエラーが発生しなくなるため、有効なRubyスクリプトとして実行することができるようになる。
そして、この変更を加えることにより、GitHubは以下のようなDiffを返す。

diff --git a/Casks/iterm2.rb b/Casks/iterm2.rb
index 3c376126bb1cf9..ba6f4299c1824e 100644
--- a/Casks/iterm2.rb
+++ b/Casks/iterm2.rb
@@ -8,6 +8,8 @@
     sha256 "e7403dcc5b08956a1483b5defea3b75fb81c3de4345da6000e3ad4a6188b47df"
   end
 
+++ "b/#{puts 'Going to report it - RyotaK (https://hackeorne.com/ryotak)';b = 1;Casks = 1;iterm2 = {};iterm2.define_singleton_method(:rb) do 1 end}"
+++ b/Casks/iterm2.rb
   url "https://iterm2.com/downloads/stable/iTerm2-#{version.dots_to_underscores}.zip"
   name "iTerm2"
   desc "Terminal emulator as alternative to Apple's Terminal app"

前述の通り、git_diff+++ "?b/(.*)にマッチする変更を行追加ではなく変更中のファイル情報として扱うため、このDiffはCasks/iterm2.rbに対して0行の変更を加えるものとして扱われてしまう。

この変更を加えた上でプルリクエストを作成8し、脆弱性のデモンストレーションを開始した。

問題発生

その後、しばらく待ってもプルリクエストがマージされず、なぜかと思いCIの実行ログを確認すると、Required status checks for pull request 104191 are not successful.というログが出力されていた。
失敗しているチェックを洗い出した所、brew styleを実行してコードの構文チェックを行っており、この部分で実行されるRubocopがコードが汚いと怒っていることがわかった。

GitHub Actions上でRubocopが失敗している画像

Rubocopは# rubocop:disable allというコメントを行末尾に追加することにより、特定の行に対しての警告を無効化することができる。
一行目に関してはこれを追記するだけで済んだのだが、二行目は+++ "?b/(.*)のグルーピングされている部分にマッチする文字列がCasks/iterm2.rbである必要があったため、このコメントを追加することによる解決はできなかった。  

いくつかの試行錯誤の後、以下のような変更を加えることにより最後の行を変更せずにRubocopを黙らせることが可能であることがわかった。

++ "b/#{puts 'Going to report it - RyotaK (https://hackerone.com/ryotak)';b = 1;Casks = 1;iterm2 = {};iterm2.define_singleton_method(:rb) do 1 end; }" # rubocop:disable all
++ "b/" if # rubocop:disable all
++ b/Casks/iterm2.rb

二行目でifを追加し、次の行をif式として評価させることによりOperator / used in void context.という警告が出ないように調整している。

これにより、プルリクエストに対して実行されているチェックが全て成功するようになり、無事にBrewTestBotによってプルリクエストがマージされた。
BrewTestBotが自動的にマージした際の画像

問題発生、再び

無事にプルリクエストがマージされたため、brew install iterm2 --caskを実行し、Going to report it - RyotaK (https://hackerone.com/ryotak)が出力されることを確認し、PoCとして画像を送信した。

brew install iterm2 –caskが変更後のコードを参照している事を示す画像

その後、送信したレポートに対する返信を待っている間に、以下のようなリプライがTwitter上で送られてきた。

一瞬理解できなかったのだが、よく見るとbrew cleanupを実行した際にもGoing to report it - RyotaK (https://hackerone.com/ryotak)が表示されてしまっているようだった。
慌てて手元で試すと、確かにbrew cleanup実行時にもGoing to report it - RyotaK (https://hackerone.com/ryotak)が表示されてしまっていた。

この時は非常に慌てていたため気が付かなかったのだが、後から調査した結果、brew cleanup以外にもbrew search等で変更したCaskが実行されてしまうことがわかった。
どうやら、一部のコマンドを実行した際に全てのCaskを評価する設計になっていたため、対象のCaskをインストールしていなかったとしても、変更後のコードが実行されてしまう状態になっていたようだった。

加えた変更がログを出力するのみだったこと、及びすぐに変更が巻き戻されたことにより大した影響はなかったが、このようなことが起きることを想定していなかったため、非常に焦った。

まとめ

今回の記事では、HomebrewのOfficial Tapに存在した脆弱性について解説しました。
この脆弱性が悪用されていた場合、発覚するまでの間にbrewで特定の操作を行ったユーザー全員のコンピュータが侵害されることに繋がったと考えると、中央集権的なエコシステムに関する監査は必要不可欠だと感じました。
PyPIやnpm registry等に対する脆弱性診断も行いたいとは思っているのですが、残念ながら脆弱性の診断行為を許可する旨が明示的に記載されていないため、現在の所行うことができません。

本記事に関する質問はTwitter(@ryotkak)へメッセージを投げてください。

タイムライン

日付 (日本時間)出来事
2021/04/17脆弱性の発見
2021/04/17メンテナに対して連絡
2021/04/18メンテナからの返信を受信
2021/04/18 17時頃デモンストレーションを開始
2021/04/18 17時頃レポートを送信
2021/04/18 18時頃プルリクエストをマージさせることに成功
2021/04/18 19時頃プルリクエストが巻き戻される
2021/04/18 20時頃一次対応が完了
2021/04/19二次対応が完了
2021/04/21インシデントの開示

  1. 具体的に言うと、改善前のDead by Daylightのマッチ待ち時間ぐらい ↩︎

  2. また、Homebrewでは2018年にGitHub API Tokenの漏洩に関連したインシデントが発生していたため、所属メンバーの意識も高かったものと思われる。 ↩︎

  3. 直近3つの記事が全てGitHub Actionsに関連したものであることに関しては非常に申し訳ないと思っている。 (が、GitHub Actionsは攻撃対象領域として非常に優秀なのだ。) ↩︎

  4. https://github.com/Homebrew/actions/blob/bac0cf0eef64950c5fa7b60134da80f5f52d87ab/.github/workflows/review-cask-pr.yml ↩︎

  5. 発見した脆弱性に重要でない条件は省略したが、他にも多数の条件が存在した。 詳しくは https://github.com/Homebrew/actions/blob/bac0cf0eef64950c5fa7b60134da80f5f52d87ab/.github/workflows/review-cask-pr.yml を確認してほしい。 ↩︎

  6. 二行目が文字列リテラルになっていないのは意図的であり、git_diffのバグにより文字列リテラルを閉じた際のダブルクオーテーションもファイルパスとして扱われてしまうためである。 ↩︎

  7. この時点では、各Caskファイルはbrew installを実行した際にしか実行されないと思っていたため、メンテナと連絡が取れている状況を鑑み、すぐに取り消せば実際に変更したコードを実行するユーザーは発生しないと考えていた。 ↩︎

  8. https://github.com/Homebrew/homebrew-cask/pull/104191 ↩︎