はじめに

@typesスコープを管理しているDefinitely Typedは、Microsoftから支援を受けているものの、Microsoftの脆弱性報奨金制度におけるセーフハーバーの対象ではありません。1
本記事は、公開されている情報を元に脆弱性の存在を推測し、実際に検証することなく潜在的な脆弱性として報告した問題に関して説明したものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。
Definitely Typedに脆弱性を発見した場合は、Definitely Typedのメンバーへ報告してください。

要約

Definitely Typedのプルリクエスト管理Botに脆弱性が存在し、悪意のあるプルリクエストをDefinitelyTyped/DefinitelyTypedリポジトリへマージすることが可能だった。
これにより、npm上の@typesスコープ配下に存在する任意のパッケージを改竄することができた。

Definitely Typedとは

Definitely Typedは、TypeScriptに対応していないパッケージの型定義を補完することを目的として多数の型定義用パッケージを公開しているグループであり、TypeScriptのメンテナを含む多数のユーザーにより管理されている。

@typesスコープ全体で計算すると月に37億回ほどダウンロードされており2、非常に多くのプロジェクトで使用されている。
具体例としては、VSCodeAngularTypeScript本体等が挙げられる。

調査理由

@lambdasawa氏と侵害された場合の影響が大きいシステムに関して議論していた際に、Definitely Typedが話題にあがった。
Definitely Typedに関して調べた所、非常に広い範囲で使用されていることがわかったため、侵害できないか調査を行うことにした。

初期調査

@lambdasawa氏からDefinitely Typedのファイル群がGitHub上で管理されていることを聞いていたため、当該のリポジトリ(DefinitelyTyped/DefinitelyTyped)を確認した。
すると、権限を持たない一般のGitHubユーザーが@typescript-botに対してコマンドを送信し、プルリクエストをマージしていることが確認できた。
Homebrewの脆弱性を報告した経験から、このBotにも同じような脆弱性があるのではないかと思い、ソースコード(DefinitelyTyped/dt-mergebot)を読み進めることにした。

プロジェクト構成図

dt-mergebotの調査を行う前にプロジェクトの構成図を確認していた所、以下の画像を見つけた。

dt-mergebotの構成図

どうやら、Definitely Typedにはリポジトリ単位のメンテナとは別に、パッケージ単位でのメンテナという概念が存在するらしい。
これは、@types/node等の単体の型定義パッケージのみを管理する権限を持っているユーザーのことで、パッケージ毎に指定することが出来る。
特定のパッケージに対してパッケージメンテナ権限を持っている場合、対象のパッケージに対する変更のみを含むプルリクエストに関しては、Definitely Typedのリポジトリメンテナの権限を使用せずにプルリクエストを承認することができる。3

以前報告したHomebrewの脆弱性でも紹介したとおり、プルリクエストに関するパースを適切に行うのは困難であり、脆弱性を作り込みやすい箇所となっている。
パッケージ単位でのメンテナとしての権限は、ユーザーが型定義を追加する際に簡単に入手できるため、細工したプルリクエストを用いて、パッケージ単位の権限管理を回避できないかを確かめることにした。

dt-mergebotの調査

dt-mergebotのソースコードを読み進めていくと、以下のような動作をしていることがわかった。

  1. プルリクエストが作成された後、変更されたファイルの一覧をAPIから取得
  2. 変更されているファイルが、どの型定義用パッケージに所属しているのかを確認
  3. 変更対象の型定義用パッケージにおいて指定されているパッケージメンテナに対して、レビュー依頼を送信
  4. パッケージメンテナが変更を承認した場合、プルリクエストの作成者にマージを行う権限が付与される
  5. プルリクエストの作成者が、Ready to mergeというコメントを送信すると、typescript-botがプルリクエストをマージする

APIからファイルの一覧を取得しているため、一見して問題なさそうに見えるこれらの処理だが、コードを読み進めていくと、以下に示すようなコードが見つかった。

const { nodes, totalCount } = prInfo.files!;
if (nodes!.length < totalCount) console.warn(`  *** Note: ${totalCount - nodes!.length} files were not seen by this query!`);

どうやら、API経由で取得したファイルのtotalCountと、実際に変更されているファイルの総数を確認し、取得できたファイル数がtotalCountより少なければconsole.warnを実行するらしい。
ファイルの取得方法を確認した所、GitHubのGraphQL エンドポイントに対して、以下のようなクエリを送信していた。

query PR($pr_number: Int!) {
    repository(owner: "DefinitelyTyped", name: "DefinitelyTyped") {
      id
      pullRequest(number: $pr_number) {
        [...]
        files(first: 100) {
          totalCount
          nodes {
            path
            additions
            deletions
          }
        }
        [...]
      }
   }
}

このクエリからわかるように、dt-mergebotはプルリクエスト上で変更されたファイルを取得する際に、最初の100件のみを取得していた。
前述の通り、取得できたファイル数がtotalCountより少ない場合はconsole.warnを実行し、コンソール上に警告を表示する。
しかしながら、console.warnには後続の処理を止める効果などはないため、101個以上のファイルに変更を加えた場合、101個目以降のファイルは権限チェックの対象とならず、正常に権限チェックが終了としたものとして扱われ、結果として権限管理の回避につながる。4

報告 & 修正

この記事の冒頭にも記したとおり、Definitely Typedは脆弱性診断行為に対する明確なポリシーを持っていない。
そのため、脆弱性のデモンストレーションを行わずに現時点でわかっていることをまとめて送信することにした。

攻撃手順としては、以下のような想定をした。

  1. 型定義ファイルを持たないnpm上のライブラリを探す5
  2. 当該のライブラリに対する型定義を作成する
  3. DefinitelyTyped/DefinitelyTypedに対して、その型定義を追加するプルリクエストを送信する6
  4. プルリクエストがマージされたら、その型定義を含むディレクトリに対して、100件のファイルを追加するプルリクエストを作成する
  5. 当該のプルリクエスト上で、改竄したいファイルに変更を加える
  6. typescript-botのチェックが実行されると、型定義のメンテナとしてプルリクエストを承認できるようになるため、当該のプルリクエストを承認する
  7. 当該のプルリクエスト上に、Ready to mergeというコメントを追加する
  8. 権限を持たない他の型定義ファイルを改竄することが出来る

これらの手順をメールにまとめ、Definitely Typedのメンバーに対して送信した。
その後、このコミットにおいて問題の修正が行われた。

脆弱性… 再び

ここまで解説した脆弱性の報告/修正が終わった後、Definitely Typedのメンバーに脆弱性に関する記事を公開していいかを確認した。
記事の公開が許可されたため、記事を書くためにコードを読んでいた所、リグレッションが発生していることが判明した。

このコミットにおいて、dt-mergebotは、プルリクエストに含まれる全てのファイルをGitHubのAPIから取得するようになった。
全てファイルを取得しているため、問題ないように思えるが、実はGitHubのAPIには落とし穴が存在する。

ドキュメントに記載されている通り、プルリクエストで変更されたファイル一覧を取得する際は、最大で3000件のファイルしか返さない。
これは、一度のリクエストで3000件しか返さないというわけではなく、適切にページネーションに対応し、全てのファイルを取得した場合でも、3000個目のファイルまでしか返さないということである。

大量の変更を含むプルリクエストの画像

つまり、上記のようなプルリクエストを送信した場合、実際にGraphQLから返されるのは以下のようなレスポンスとなる。

大量の変更を含むプルリクエストをGraphQLで取得した際の画像

加えて、GraphQL APIから返されるtotalCountも上限が3000となっている。
そのため、Definitely Typedに対して、3001個以上のファイルを変更したプルリクエストを送信する事により権限管理を回避し、任意の変更をDefinitelyTyped/DefinitelyTypedへと加えることが出来る状態だった。
これもメールを通して報告し、無事にこのコミットで500個以上のファイルが変更されている場合に疑わしいプルリクエストとマークするように変更することで解決された。

追加の脆弱性

ここまで記事を書き上げた後、公開前に念の為Definitely Typedの他の部分に問題がないかを確認していた際に、もう一つ脆弱性を見つけた。

dt-mergebotがプルリクエストを処理した後、変更されたパッケージをnpm上で公開するために使用されているtypes-publisherというツールがある。
このツール内において、対象のパッケージ内のファイルをコピーしているのだが、このコピー処理の際にシンボリックリンクに関する考慮がされておらず、意図しないファイルを公開させることができた。

しかしながら、当該の処理に到達する前に、以下のような処理が行われていたため、読み出すことが出来るファイルはTypeScriptとしてパースできるファイルのみに限られていた。

const src = createSourceFile(resolvedFilename, readFileAndThrowOnBOM(resolvedFilename, fs));
if (resolvedFilename.endsWith(".d.ts")) {
  types.set(resolvedFilename, src);
} else {
  tests.set(resolvedFilename, src);
}

こちらの脆弱性もメールを通して報告し、このコミットにおいて、シンボリックリンクが混入しないように変更された。

まとめ

今回の記事において解説した脆弱性は、TypeScriptのエコシステムに対して大きな影響を与えるものでした。
本脆弱性のように、非常に大きな影響を持つ脆弱性はサプライチェーンの中に多数存在します。
そのため、サプライチェーンの監査を可能な限り多くの人が行うことが非常に重要となっています。
本記事に関する質問/感想がある場合は、Twitter(@ryotkak)へメッセージを送信してください。

タイムライン

日付 (日本時間)出来事
2021/04/26脆弱性の発見
2021/04/27脆弱性の報告
2021/04/28脆弱性の修正
2021/05/07リグレッションの発生
2021/06/03記事の公開許可が出る
2021/06/04リグレッションが発生していることに気が付き、報告
2021/06/14リグレッションの修正
2021/06/22シンボリックリンクに関する脆弱性の発見
2021/07/07シンボリックリンクに関する脆弱性の修正
2021/08/08本記事の公開

  1. Japan Microsoft Security Response Centerに確認済み ↩︎

  2. 集計する際に使用したコードはこちら: https://gist.github.com/Ry0taK/d3ebb66b8b32f8eca694f362c21b8854 ↩︎

  3. 他にも、型定義として有効な変更であり、変更点に関するテストが追加されていることなどの条件がある。 ↩︎

  4. 例として、このプルリクエストcodemirrorreact-codemirrorを編集していたが、この脆弱性によりtypescript-botcodemirrorのみを編集対象として認識していた↩︎

  5. もしくは、自分でライブラリを作成しても良い。 ↩︎

  6. 型定義を追加する際は、当該の型定義に対して、自分をメンテナとして指定するすることが出来る。これはDefinitely Typed上で一般的に行われていることであり、ソーシャルエンジニアリング的な手法を必要としない。 ↩︎