You can read about these vulnerabilities in English at https://ec0.io/post/hacking-cloudflare-pages-part-2/

免責事項

Cloudflareは、HackerOne上で脆弱性報奨金制度(Bug Bounty)を実施しており、脆弱性の診断行為を許可しています。
本記事は、当該制度を通して報告された脆弱性をCloudflareセキュリティチームの許可を得た上で公開しているものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。
また、Cloudflareは脆弱性調査において他の研究者との協力を許可しており、脆弱性調査を目的とした他の研究者との脆弱性情報の共有が許可されています。
Cloudflareが提供する製品に脆弱性を発見した場合は、Cloudflareの脆弱性報奨金制度へ報告してください。

なお、本記事が執筆されたのは2022年であり、公開時点での内容と一部異なる可能性が存在します。

要約

James Hebden氏Sean Yeoh氏の二名と協力し、Cloudflare Pagesに複数の脆弱性を発見した。
これらの脆弱性により、Cloudflare Pagesのビルド環境でroot権限を得ることや、Cloudflare PagesのFunctions機能を用いてデプロイされたスクリプトの窃取、Cloudflare Pages上で構築された他人のページを改竄することなどが可能だった。

Cloudflare Pagesとは

Cloudflare Pagesは、Cloudflareが提供するJAMstackプラットフォームであり、主に静的なサイトをホストするために使用される。
本脆弱性を報告した時点でベータ版だった機能を利用することでCloudflare Workersと連携し、サーバーサイドのコードを実行することもできる。

調査理由

セキュリティ関連の情報を集めている際にCloudflare Pages, part 1: The fellowship of the secretという記事を見かけた。
この記事の内容が非常に面白かったため、自分もCloudflare Pagesに脆弱性を見つけたいと思い、調査をすることにした。

調査環境

Cloudflare Pagesは、ユーザーの指定したコマンドを使用して静的サイトを生成する機能を提供している。
上記のブログで解説されているように、このコマンドが実行される環境はKubernetes内に存在しており、更にビルドを実行するのはかなり権限の絞られたbuildbotユーザーである。
そのため、調査をする際に役に立つ情報1が得られない状況から調査を開始する必要がある。

調査開始

前述の記事のようなコンテナエスケープか、それに類する脆弱性を探したいと考えていたため、まず初めに現在の限られた状況から抜け出す方法を探すことにした。

Cloudflare Pagesのビルド環境で実行されているプロセス一覧 上記のプロセス一覧からわかるように、ユーザーが指定したコマンドを実行するプロセスは、/opt/pages/build_tool/main.py (以下build_tool)がsudoによって権限を落としたプロセスとなっている。
build_tool自体はroot権限で実行されていたため、build_toolの脆弱性を利用して権限昇格ができないかを確かめようとしたが、現在の権限ではbuild_toolのファイルを読み取る事ができなかった。

そこで、まずはbuild_toolの内容を読み取ることを目標とした。

任意のファイル読み取り

ユーザーが指定したコマンドによって生成されたファイルは、コマンド実行後に何らかの方法によって読み取られ、Cloudflareのネットワーク上にデプロイされる。

後続の処理はrootによって実行されているプロセスで行われているため、ファイルの読み取りはrootによって行われているのではないかと推測した。
そこで、ビルド済みのファイルの代わりにシンボリックリンクを置いておくことで、システム上の任意のファイルを読み出せるのでは、と考えた。

試しにindex.htmlをシンボリックリンクに置き換え、/etc/shadowの内容を読み取ろうとしたが、デプロイされたウェブサイトにはindex.htmlは含まれておらず、単純にシンボリックリンクを置くだけでは効果がなさそうであるということが伺えた。

そこで、Cloudflare Pagesの機能に関して詳しく調べていたところ、Redirectsという機能を見つけた。
この機能は、ウェブサイト内に_redirectsというファイルを含めておくことにより、リダイレクトルールをカスタマイズできるというもので、このファイルはユーザーが指定したコマンドが実行されたあとにパースされているようだった。

Cloudflare PagesのドキュメントにおけるRedirectsの説明

通常のファイル群とは違いウェブサイト上に公開されるわけではないため、ファイルをパースする際の処理に差異があるのではないかと思い、このファイルをシンボリックリンクに置き換えたところ、デプロイ実行後にbuildbotユーザーの権限からは読み取れない/etc/shadowの内容をダッシュボード上から読み取ることができた。

Cloudflareのダッシュボード上に表示されている/etc/shadow

コラボレーション

任意のファイルをビルド環境から読み出す方法を見つけた時点で21時近くになっており、脆弱性調査に使える時間は約1日ほどしか残っていなかった。2
一人で権限昇格を見つけ、更にそこから脆弱性を探すのは現実的ではないように思えたため、記事冒頭で言及した記事の著者2人にコラボレーションを持ちかけることにした。

Twitterで記事の著者2人にコラボレーションを持ちかける様子

すぐにJames氏から返信があり、3人で協力して脆弱性を探すことになった。

James氏からのコラボレーションを快諾する返事

build_toolコード読解

James氏からの返信があった後、Sean氏が来るまで2人で権限昇格の方法を探すことにした。

前述の任意のファイル読み取りの脆弱性を使い、build_toolのコードをダウンロードした後、コードを読んで脆弱性を探したのだが、James氏やSean氏が以前見つけたような単純なコマンドインジェクションは存在しなさそうに見えた。

しばらく二人で雑談しながらコードを眺めていた際に、以下のコードに目が止まった。

        version = [env_var['value'] for env_var in env_vars if env_var['key'] == 'PAGES_WRANGLER_VERSION'][0]
        print_line(f'Overriding wrangler version to {version}...', logs)

        subprocess.run(['npm', 'install', f'wrangler@{version}'], cwd=WRANGLER_DIR, check=True, capture_output=True)
        
        print_line('wrangler version override complete!', logs)

このコードは、ユーザーが指定したPAGES_WRANGLER_VERSIONという環境変数を元に、wranglerのバージョンをnpm installを用いて変更している。
PAGES_WRANGLER_VERSION@を含む文字列を入れるなど、何らかの方法でwrangler以外のパッケージをインストールさせることができるのではないかとひらめいたため、npmのコードを読むことにした。

npmを経由した権限昇格

npmのコードを読んだ結果、npm installpackage.json等と同様に、バージョンを指定する箇所にURLを指定することで、npmのレジストリからではなく指定されたURLからtarballをダウンロードするということがわかった。3

例えば、npm install wrangler@https://example.com/example.tgzというコマンドを実行した場合、wranglerのhttps://example.com/example.tgzというバージョンのインストールを試みる代わりに、https://example.com/example.tgzからtarballをダウンロードし、それをwranglerとして扱う。

後続の処理で、以下のようにインストールしたwranglerを実行している箇所があり、この処理はroot権限で実行されているbuild_tool上で実行されるものであったため、細工したwranglerをインストールして実行させることでroot権限の奪取が可能となっていた。

            cmd = [
                './node_modules/.bin/wrangler2',
                'pages',
                'functions',
                'build',
                "--outfile",
                constants.OUTPUT_WORKER_PATH,
                "--output-config-path",
                constants.USER_WORKER_DERIVED_CONFIG_PATH,
                '--build-output-directory',
                output_dir,
                functions_dir
            ]

            with subprocess.Popen(cmd, **plinko_args) as proc:

これを利用することで、ビルド環境上でroot権限を得て追加調査を行うことができるようになった。

Discord上でnpmを経由した権限昇格に成功して喜んでいる様子

次の日

root権限を得ることに成功した時点で日付を跨いでしまっていたため、一旦寝て、次の日に備えることにした。

翌朝目覚めると、Sean氏が参加していたため3人で調査を再開した。
基本方針として、「他のユーザーのデータにアクセスできる脆弱性を見つける」という目標を立て、脆弱性を調査することにした。

ビルド環境の隅々まで調査し、コンテナエスケープができないかを確認していったが、コンテナエスケープに使えそうな脆弱性は見つからなかった。
そのため方針を変え、ビルド環境からCloudflareのネットワークに対して、ビルドしたサイトをデプロイする際の処理に脆弱性がないかを確認することにした。

デプロイ処理

デプロイ処理に関して調べるためにbuild_toolを読み進めていくと、wrkrという名前の実行可能ファイルが関係しているということが判明した。

このプログラムはRustで書かれており、以下のような形でbuild_toolから実行される。

    # Configure api proxy through the maestro
    os.environ['CF_API_HOST'] = config['MAESTRO_HOST']
    os.environ['CF_API_TOKEN'] = config['JWT']
    os.environ['CF_ACCOUNT_ID'] = config['CF_ACCOUNT_ID']
    [...]

    # WRKR (ASSET UPLOADER)
    upload_args = {
        'account_tag': config['CF_ACCOUNT_ID'],
        'asset_namespace': config['ASSET_NAMESPACE'],
        'asset_dir': asset_dir,
        'asset_manifest_namespace': config['MANIFEST_NAMESPACE'],
        'asset_manifest_key': config['MANIFEST_KEY'],
    }

    cmd = ['./wrkr']

    [...]
    lines = filter_logs(util.run_cmd(cmd, cwd=constants.WRKR_DIR))

上記のコードからわかる通り、wrkrは、CF_API_HOSTという環境変数を設定することで、接続先を切り替えることができる。
元々の接続先であるapi.pages.cloudflare.comの代わりに、自前で建てたリバースプロキシを指定することで、通信内容の確認を試みた。

その結果として、以下のような流れでデプロイされていることがわかった。

デプロイの流れを表した図

内部API

上記のリバースプロキシを用いて、api.pages.cloudflare.com上にあるAPIエンドポイントを調べた結果、以下のようなエンドポイントが使われていることがわかった:

  1. /client/v4/accounts/d6fa5e8917ff81a61c1f92fc98b9f85d/storage/kv/namespaces/db62b722715546c9af0cedbd574c9a47/bulk
  2. /client/v4/accounts/d6fa5e8917ff81a61c1f92fc98b9f85d/storage/kv/namespaces/db62b722715546c9af0cedbd574c9a47/keys
  3. /client/v4/accounts/d6fa5e8917ff81a61c1f92fc98b9f85d/storage/kv/namespaces/332a39fcd8a845d7909d2d5d753604d8/values/builds/5486590/logs

これらのパスからわかるように、このAPIはapi.cloudflare.comに対してリクエストを転送し、そのレスポンスを返している。
そのため、このAPIへのリクエスト時に付与されているJWTを用いてapi.cloudflare.comに対してリクエストを送信してみたが、無効なトークンとして扱われてしまい、レスポンスを受け取ることができなかった。
また、ここで使用されているもの以外のエンドポイントに関してリクエストを試みたが、こちらも失敗に終わった。どうやら、このAPIプロキシは使用できるAPIエンドポイントを制限しているようだった。

このような調査を行っている最中に、現在調査を行っている3人共同じアカウントID(d6fa5e8917ff81a61c1f92fc98b9f85d)が割り振られていることに気がついた。

Discord上でアカウントIDが同一であることに気がつく様子

パストラバーサル

詳しく確認したところ、前述の通り使用されているアカウントは全ユーザーで共通だったのだが、使用されているKVの名前空間2つの内、1つはデプロイのたびに変わるようになっていた。
そのため、他のユーザーの名前空間にアクセスできないか試みたのだが、JWTによる識別が行われているようで、失敗に終わった。
また、全デプロイで共通な名前空間に関しては、デプロイ毎に固有なデプロイIDを用いたキーベースのアクセス制限が行われており、こちらも他のユーザーのデータにアクセスすることはできなかった。

その後、Sean氏のアイディアでパストラバーサルによるAPI制限の回避を試みることにした。

Sean氏がDiscord上でパストラバーサルを提案している画像

いくつかのパターンを試したが、パストラバーサルを行うことはできず、パストラバーサルは無理そうだ、という雰囲気になっていた。
これ以上のアイディアはなかったことと、ここで諦めたくないという思いからAPIをいじり回している際に、唐突に他のユーザーの名前空間一覧が表示された。

パストラバーサルに成功し、Discord上で報告してる様子

どうやら、全デプロイで共通な名前空間に対するリクエスト関しては厳格なパスのチェックが行われていたのだが、デプロイ毎に作成される名前空間に対するリクエストに関しては、パスのチェックが甘かったようで、パスの末尾に..%2F..%2F..%2Fといったような文字列を付けることで、アクセスされることが想定されていないapi.cloudflare.com上の任意のAPIに対してリクエストを送ることができた。

影響

影響を調査するために様々なAPIを叩いて確認したところ、このAPIプロキシによって使用されているAPIキーの権限はCloudflare WorkersのKV関連に制限されているようだった。
それでも、今までデプロイされた全てのPagesの静的ファイル、Pages上で使用されているCloudflare Workersのコード、ビルド時のログなどが取得可能だった他、他人のファイルをアップロードされた直後に書き換えることで、デプロイされる内容の改竄などができたはずである。

まとめ

今回の記事では、複数の研究者と協力してCloudflare Pagesに脆弱性を探した一連の流れを解説しました。
記事の冒頭で触れたように、既に複数の脆弱性がCloudflare Pages上で発見され修正されている状態でしたが、それでも尚深刻度が高い脆弱性を見つけることができたことから、既にテストされているサービスにおいても脆弱性が存在する可能性があるということをおわかりいただけると思います。

本記事に関する質問/感想はTwitter(@ryotkak)までお願いいたします。

謝辞

この調査に協力してくださったJames Hebden氏Sean Yeoh氏に改めて感謝申し上げます。ありがとうございました。

タイムライン

日付 (日本時間)出来事
2022/05/14任意のファイル読み取り発見
2022/05/14協力依頼を送信
2022/05/15権限昇格を発見
2022/05/15Pagesの内部APIにおけるパストラバーサルを発見
2022/05/15Cloudflareにこれら3つの脆弱性を報告
2022/05/17内部APIにおけるパストラバーサルが修正される
2022/06/07ビルド環境における任意ファイル読み取りが修正される
2022/06/08ビルド環境における権限昇格が修正される
2023/12/22開示許可が出る
2023/12/23本記事の公開

  1. ビルド環境内で動いているプロセスのバイナリやコードなど ↩︎

  2. 調査を開始したのが土曜日であり、休日中に調査を終わらせたかったため。 ↩︎

  3. https://github.com/npm/npm-package-arg/blob/2dd33f52a772c091f26169c97cefaa399a7233cc/lib/npa.js#L77-L85 ↩︎