はじめに

(English version is also available.)

cdnjsの運営元であるCloudflareは、HackerOne上で脆弱性開示制度(Vulnerability Disclosure Program)を設けており、脆弱性の診断行為を許可しています。
本記事は、当該制度を通して報告された脆弱性をCloudflareセキュリティチームの許可を得た上で公開しているものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。
Cloudflareが提供する製品に脆弱性を発見した場合は、Cloudflareの脆弱性開示制度へ報告してください。

要約

cdnjsのライブラリ更新用サーバーに任意のコードを実行することが可能な脆弱性が存在し、結果としてcdnjsを完全に侵害することが出来る状態だった。
これにより、インターネット上のウェブサイトの内12.7%1を改竄することが可能となっていた。

cdnjsとは

cdnjsは、Cloudflareによって運営されている無料のJavaScript/CSSライブラリ用CDNであり、記事公開時点でインターネット上のウェブサイトの12.7%に使用されている。
これは、Google Hosted Librariesの12.8%2に次いで2番目に広く使われているライブラリ用CDNであり、現在の使用率の推移を鑑みるに、近いうちに最も使われているJavaScriptライブラリ用CDNになると考えられる。

cdnjsのインターネット上での使用率

2021年7月15日時点でのW3Techsにおけるcdnjsの使用率グラフ

調査理由

前回の「Homebrewにおける任意コード実行」に関する調査を行う数週間前に、サプライチェーン攻撃に関する調査を行っていた。
多数のアプリケーションが依存するシステムであり、脆弱性調査を許可しているという条件で絞り込んだ際に、cdnjsが対象に入ったため調査を行うことにした。

初期調査

cdnjsのウェブサイトを眺めている際に、以下のような記述を発見した。

Couldn’t find the library you’re looking for?
You can make a request to have it added on our GitHub repository.

GitHubのリポジトリ上でライブラリの情報を管理している事がわかったため、当該のGitHub Organizationのリポジトリを確認した。

結果として、以下のような構成になっていることがわかった。

これらのリポジトリから分かるように、cdnjsのインフラの大部分はこのGitHub Organizationに集約されている。
その中で、cdnjs/bot-ansiblecdnjs/toolsに興味を惹かれた。
この2つのリポジトリのコードを読んだ所、cdnjs/bot-ansibleが定期的にcdnjs/toolsautoupdateコマンドをライブラリ更新用サーバー内で実行し、cdnjs/packagesにおいて指定されているGitリポジトリ/npmパッケージを用いて更新を確認していることがわかった。

自動更新機能の調査

この自動更新機能は、ユーザーが管理するGitリポジトリ/npmパッケージをダウンロードし、対象のファイルをコピーすることによってライブラリを更新していた。
npmレジストリは、各ライブラリを.tgzファイルとして圧縮した上でダウンロードができるようにしている。
この自動更新用のツールがGoで記述されていることから、Goのcompress/gzip及びarchive/tarを用いて解凍しているのではないかと推測した。
Goのarchive/tarはアーカイブ内に含まれるファイルパスをサニタイズせずに返す3ため、もし仮にarchive/tarから返されたファイル名を元にしてディスク上に書き込んでいる場合、../../../../../../../../../tmp/testのようなファイル名を持つファイルを.tgzに含めることにより、任意のファイルを上書きできる。4
cdnjs/bot-ansibleの情報から、いくつかのスクリプトが定期的に実行されており、それらに対して書き込み権限があることがわかっていたため、パストラバーサルを介したファイルの上書きを重点的に確認することにした。

パストラバーサルを行うために細工したtgzファイルの画像

パストラバーサル

パストラバーサルを探すために、autoupdateコマンドのmain関数を読み始めた。

func main() {
        [...]	
		switch *pckg.Autoupdate.Source {
		case "npm":
			{
				util.Debugf(ctx, "running npm update")
				newVersionsToCommit, allVersions = updateNpm(ctx, pckg)
			}
		case "git":
			{
				util.Debugf(ctx, "running git update")
				newVersionsToCommit, allVersions = updateGit(ctx, pckg)
			}
		[...]
}

上記のコードから分かるように、npmをベースとした自動更新が指定されていた場合は、updateNpm関数へとパッケージ情報を渡している。

func updateNpm(ctx context.Context, pckg *packages.Package) ([]newVersionToCommit, []version) {
		[...]
		newVersionsToCommit = doUpdateNpm(ctx, pckg, newNpmVersions)
        [...]
}

そして、新しいライブラリバージョンの情報と共にdoUpdateNpm関数を呼び出す。

func doUpdateNpm(ctx context.Context, pckg *packages.Package, versions []npm.Version) []newVersionToCommit {
    [...]
	for _, version := range versions {
        [...]
		tarballDir := npm.DownloadTar(ctx, version.Tarball)
		filesToCopy := pckg.NpmFilesFrom(tarballDir)
        [...]
}

次に、npm.DownloadTar関数へ新しいバージョンの.tgzファイルのURLを渡す。

func DownloadTar(ctx context.Context, url string) string {
	dest, err := ioutil.TempDir("", "npmtarball")
	util.Check(err)

	util.Debugf(ctx, "download %s in %s", url, dest)

	resp, err := http.Get(url)
	util.Check(err)

	defer resp.Body.Close()

	util.Check(Untar(dest, resp.Body))
	return dest
}

最後に、http.Getを使用して取得した.tgzファイルを、Untar関数へと渡す。

func Untar(dst string, r io.Reader) error {
	gzr, err := gzip.NewReader(r)
	if err != nil {
		return err
	}
	defer gzr.Close()
	tr := tar.NewReader(gzr)
	for {
		header, err := tr.Next()
        [...]
		// the target location where the dir/file should be created
		target := filepath.Join(dst, removePackageDir(header.Name))
        [...]
		// check the file type
		switch header.Typeflag {
        [...]
		// if it's a file create it
		case tar.TypeReg:
			{
                [...]
				f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
                [...]
				// copy over contents
				if _, err := io.Copy(f, tr); err != nil {
					return err
				}
			}
		}
	}
}

予想通り、Untar関数内においてcompress/gziparchive/tarを使用して解凍を行っていた。
最初はremovePackageDirにおいてパスのサニタイズを行っているのだと考えていたのだが、関数の内容を確認した所、単純にpackage/という文字列をパスから削除するだけの関数だった。
これにより、npmへ公開した.tgzファイルからパストラバーサルを行い、サーバー上で定期的に実行されるスクリプトを上書きした上で任意のコードが実行できるということがわかった。

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

CloudflareはHackerOne上に脆弱性開示制度を持っているため、脆弱性が実際に攻撃可能であることを示さなければ、HackerOneのトリアージチームがCloudflare側にレポートを転送しない可能性が高い。
そのため、脆弱性が実際に攻撃出来ることを示すためにデモンストレーションを行うことにした。

攻撃手順としては、以下のとおりとなる。

  1. 細工したファイル名を含む.tgzファイルをnpm上で公開する。
  2. cdnjsの自動更新サーバーがファイルを処理するのを待つ。
  3. 細工した.tgzファイルの中身が定期実行されているスクリプトファイルへと書き込まれ、任意のコードが実行される。

…と、ここまで考えた所でGitリポジトリをベースとした自動更新機能が気になってきた。
そのため、脆弱性のデモンストレーションを行う前にコードを流し読みした所、以下のコードのように、Gitリポジトリからファイルをコピーする際に、シンボリックリンクの存在が考慮されていないように見受けられた。

func MoveFile(sourcePath, destPath string) error {
	inputFile, err := os.Open(sourcePath)
	if err != nil {
		return fmt.Errorf("Couldn't open source file: %s", err)
	}
	outputFile, err := os.Create(destPath)
	if err != nil {
		inputFile.Close()
		return fmt.Errorf("Couldn't open dest file: %s", err)
	}
	defer outputFile.Close()
	_, err = io.Copy(outputFile, inputFile)
	inputFile.Close()
	if err != nil {
		return fmt.Errorf("Writing to output file failed: %s", err)
	}
	// The copy was successful, so now delete the original file
	err = os.Remove(sourcePath)
	if err != nil {
		return fmt.Errorf("Failed removing original file: %s", err)
	}
	return nil
}

Gitはシンボリックリンクを扱うことが可能なため、Gitリポジトリにシンボリックリンクを含め、cdnjsに処理させることによりcdnjsシステム上の任意のファイルを読み取れる可能性がある。

ファイルを上書きして任意のコードを実行するように書き換えた場合、自動更新機能が適切に動作しなくなってしまう可能性があったため、シンボリックリンクによる任意ファイル読み取りを先に検証/報告し、パストラバーサルに関してはそのレポート内でCloudflareのセキュリティチームに確認後、デモンストレーションを行おうと考えた。

これに伴い、攻撃手順を以下のように変更した。

  1. cdnjsに登録されたGitリポジトリに、無害なファイル(ここでは/proc/self/mapsを想定)へとリンクさせたシンボリックリンクを追加する。
  2. 当該のGitリポジトリ上で、新しいバージョンを公開する。
  3. cdnjsの自動更新サーバーがファイルを処理するのを待つ。
  4. 指定したファイルが読み取られ、公開される。

この時点で20時頃だったのだが、シンボリックリンクを作成するだけであれば直ぐに済ませられると考え、シンボリックリンクを作成/プッシュしてから夕飯を食べることにした。5

ln -s /proc/self/maps test.js

インシデント

夕飯を済ませ、PCの前に戻ってきた所、cdnjsがシンボリックリンクを含むバージョンを公開していることが確認できた。

その後、レポートを送信するためにファイル内容を確認して驚愕した。
なんと、GITHUB_REPO_API_KEYや、WORKERS_KV_API_TOKENといった明らかに機微な情報が表示されていたのである。
一瞬何が起こったのか理解できず、コマンドのログを確かめた所、誤って/proc/self/mapsではなく/proc/self/environへのリンクを貼っていたことが確認できた。6
先述の通り、cdnjsのGitHub Organizationが侵害された場合、cdnjsの大部分を侵害することが可能となる。
すぐにでも対応する必要があったため、レポートには現在の状況がわかるリンクとトークン類を全て取り消して欲しいという旨のみを記載し、送信した。

この時点ではとても焦っており確認していなかったが、実はレポートを送信する前にこれらのトークンは無効化されていた。
これは後からわかったことだが、GITHUB_REPO_API_KEY(GitHubのAPIキー)が含まれていたことにより、即座にGitHubが自動で通知を行い、それを受け取ったCloudflareはインシデントレスポンスを開始していたらしい。
cdnjsが細工されたパッケージを処理してから数分も立たない内に各種認証情報の無効化を行っていたらしく、流石Cloudflareだな、という印象を受けた。

影響調査

その後、詳細な影響範囲の調査を行った。
GITHUB_REPO_API_KEYはcdnjsのGitHub Organizationに所属しているrobocdnjsというアカウントのアクセストークンであり、cdnjsの各リポジトリに対する書き込み権限を持っていた。
つまり、cdnjs上でホストされている任意のライブラリの改竄や、ウェブサイトの改竄などをこのトークンを用いて行うことができた。

また、WORKERS_KV_API_TOKENは、cdnjsに使用されているCloudflare WorkersのKVに対する書き込み権限を持っており、KVにキャッシュされたライブラリ情報を改竄することが可能だった。
これらの権限を組み合わせることにより、cdnjsのオリジンデータからKVキャッシュ、更にはcdnjsのウェブサイトといったcdnjsのコア部分を完全に侵害することができたと考えられる。

まとめ

今回の記事では、cdnjsに存在した脆弱性について解説しました。
脆弱性自体は特殊なスキル無しで悪用可能なものでしたが、それによって潜在的に生じる影響が非常に大きいものでした。
世の中にはこの脆弱性のような、悪用が簡単なのにも関わらず、影響が非常に大きい脆弱性がサプライチェーンの中にあると考えると、非常に恐ろしいものだと感じます。
本記事に関する質問/感想はTwitter(@ryotkak)へメッセージを送信してください。

タイムライン

日付 (日本時間)出来事
2021/04/06 19時頃脆弱性の発見、検証開始
2021/04/06 20時頃デモンストレーション用のファイルを公開
2021/04/06 20時半頃cdnjsがファイルを処理
同時刻GitHubがアラートをCloudflareへ送信
同時刻Cloudflare内部でインシデントレスポンスが始まる
数分以内各種認証情報の取り消しが完了する
2021/04/06 20時40分頃初期レポートを送信
2021/04/06 21時頃詳細なレポートを送信
2021/04/07~二次対応が完了
2021/06/03完全な対応が完了
2021/07/16本記事の公開

  1. W3Techsより7月15日の情報を引用。SRI/キャッシュの存在により、即座に改竄可能だったウェブサイトはこの数字よりも少なかった。 ↩︎

  2. W3Techsより7月15日時点の情報を引用。 ↩︎

  3. https://github.com/golang/go/issues/25849 ↩︎

  4. このようなアーカイブファイルは、evilarcといったようなツールを用いることにより作成することが出来る。 ↩︎

  5. 記憶が定かではないが、この日の夕食は冷凍餃子だったと思う。 ↩︎

  6. 仕事で疲れていたのと、空腹だった事が重なり、ろくに確認もせずに補完されたコマンドを実行してしまっていた。 ↩︎