はじめに
(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になると考えられる。
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/packages: cdnjsに掲載するライブラリの情報を格納
- cdnjs/cdnjs: ライブラリの実際のファイル群を格納
- cdnjs/logs: ライブラリの更新ログを格納
- cdnjs/SRIs: 各ライブラリのSRI(Subresource Integrity)を格納
- cdnjs/static-website: cdnjs.comのソースコード
- cdnjs/origin-worker: cdnjs.cloudflare.comのオリジン用Cloudflare Worker
- cdnjs/tools: cdnjsの管理用ツール
- cdnjs/bot-ansible: cdnjsの自動ライブラリ更新用システムのAnsible
これらのリポジトリから分かるように、cdnjsのインフラの大部分はこのGitHub Organizationに集約されている。
その中で、cdnjs/bot-ansibleとcdnjs/toolsに興味を惹かれた。
この2つのリポジトリのコードを読んだ所、cdnjs/bot-ansibleが定期的にcdnjs/toolsのautoupdate
コマンドをライブラリ更新用サーバー内で実行し、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の情報から、いくつかのスクリプトが定期的に実行されており、それらに対して書き込み権限があることがわかっていたため、パストラバーサルを介したファイルの上書きを重点的に確認することにした。
パストラバーサル
パストラバーサルを探すために、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/gzip
とarchive/tar
を使用して解凍を行っていた。
最初はremovePackageDir
においてパスのサニタイズを行っているのだと考えていたのだが、関数の内容を確認した所、単純にpackage/
という文字列をパスから削除するだけの関数だった。
これにより、npmへ公開した.tgz
ファイルからパストラバーサルを行い、サーバー上で定期的に実行されるスクリプトを上書きした上で任意のコードが実行できるということがわかった。
脆弱性のデモンストレーション
CloudflareはHackerOne上に脆弱性開示制度を持っているため、脆弱性が実際に攻撃可能であることを示さなければ、HackerOneのトリアージチームがCloudflare側にレポートを転送しない可能性が高い。
そのため、脆弱性が実際に攻撃出来ることを示すためにデモンストレーションを行うことにした。
攻撃手順としては、以下のとおりとなる。
- 細工したファイル名を含む
.tgz
ファイルをnpm上で公開する。 - cdnjsの自動更新サーバーがファイルを処理するのを待つ。
- 細工した
.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のセキュリティチームに確認後、デモンストレーションを行おうと考えた。
これに伴い、攻撃手順を以下のように変更した。
- cdnjsに登録されたGitリポジトリに、無害なファイル(ここでは
/proc/self/maps
を想定)へとリンクさせたシンボリックリンクを追加する。 - 当該のGitリポジトリ上で、新しいバージョンを公開する。
- cdnjsの自動更新サーバーがファイルを処理するのを待つ。
- 指定したファイルが読み取られ、公開される。
この時点で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 | 本記事の公開 |