この章の読み方
Chapter 1では、SSRFを単に「危険なURLが入力される問題」としてではなく、利用者から与えられた情報をきっかけに、サーバー側で通信が発生する問題として見てきました。
そこで重要だったのは、利用者が入力した文字列そのものと、サーバーが最終的に行う通信を分けて考えることでした。
Chapter 2では、そこからもう一歩進みます。
SSRF対策を難しくする理由の一つは、防御側がある時点で判断した内容と、最終的に実行される通信が、必ずしも同じ条件のままではないことです。
入力を確認した。
宛先を確認した。
許可してよいと判断した。
それでも、その判断だけで後続の通信すべてを説明できるとは限りません。
この章では、その理由を6つの観点から整理します。
ただし、この6つはSSRFを完全に分類するための唯一の体系ではありません。互いに重なる部分もあります。本章では、SSRF防御が難しくなる代表的なずれを整理するための分析モデルとして使用します。
SSRF対策を考えるとき、最初に思いつきやすいのは入力の検査です。
利用者が指定したURLやホスト名を確認し、危険な宛先でなければ許可する。
この考え方自体が間違っているわけではありません。
問題は、入力を確認した時点と、実際に通信が行われる時点との間に、複数の処理が存在することです。
入力された文字列は解析されます。
名前は解決されることがあります。
通信先の候補が選ばれます。
別のコンポーネントへ処理が渡される場合もあります。
通信の結果として、さらに別の通信が発生する場合もあります。
そして、その処理がどの環境・どの権限・どの信頼関係の中で実行されるかによって、同じ入力でも意味が変わることがあります。
したがってSSRF防御では、
「何を入力されたか」
だけではなく、
「どの時点で、何を根拠に許可したのか」
そして、
「その後、実際には何が通信を行ったのか」
を分けて考える必要があります。
この章では、防御側が「この通信は許可できる」と判断する地点を、ひとまず判断点として考えます。
一方、実際にネットワーク通信が発生する地点を実行点として考えます。
単純な処理では、この二つは近く見えるかもしれません。
しかし実際のアプリケーションでは、判断と実行の間に、
- URL解析
- 名前解決
- 接続先候補の選択
- redirect
- ライブラリへの委譲
- proxyや別コンポーネント
- 実行環境固有の名前解決やネットワーク制御
などが関与する可能性があります。
そのため、重要なのは、
「一度確認したから安全」
と考えるのではなく、
その判断が、後続の通信に対してどこまで有効なのか
を考えることです。
以下では、この問題を6つの観点に分けます。
- 1. Interpretation Gap
- 2. Resolution / Time Gap
- 3. Lifecycle / Hop Gap
- 4. Execution-Path Gap
- 5. Environment / Reachability Gap
- 6. Privilege / Trust Context Gap
これらは独立した6種類の脆弱性ではありません。
同じシステムで複数が重なることもあります。
最初の観点は、同じ入力が、処理するコンポーネントによって同じ意味として扱われるとは限らないという問題です。
URLを例にすると、一つの処理系だけでも、
- 文字列を分解する
- schemeを取り出す
- hostを取り出す
- 正規化する
- 構文上の問題を扱う
- アプリケーションとして許可するか判断する
といった役割は同じではありません。
たとえば、Pythonの urllib.parse はURLを解析・分解するためのインターフェースを提供しますが、その処理自体が「この通信先はセキュリティ上許可してよい」という判断を行うわけではありません。
また、URL仕様上のvalidation errorも、アプリケーションのaccept/reject判断と同じ意味ではありません。
ここで重要なのは、
「解析できた」ことと「安全だと判断できた」ことを同一視しない
ことです。
さらに、URIの正規化ではscheme、host、その他の構成要素が同じ規則で扱われるとは限りません。
そのため、防御側で入力を確認するときには、
どのコンポーネントが、どの表現を、どの規則で解釈しているのか
という視点が必要になります。
ただし、本章では「実際のシステムで二つのparserが異なる解釈をした」という特定の実行結果までは主張しません。
ここで扱うのは、コンポーネント間で解釈差が生じ得る構造上の理由です。
次に考えるのは、判断した時点の宛先と、通信時に選ばれる宛先が常に同じとは限らないという問題です。
ホスト名が使われる場合、アプリケーションが扱っているのは文字列としての名前だけではありません。
通信する段階では、その名前から接続先候補を得て、実際の接続先を選ぶ処理が関わります。
Pythonの create_connection のように、名前から複数の候補を取得し、複数の接続候補を試すことが文書化されているAPIもあります。
urllib3でも、特定のバージョン・特定の直接接続経路では、システムから得たアドレス候補に対してソケット接続を試す処理を確認できます。
ここから分かるのは、
「ホスト名を確認した」という判断と、「どのアドレスへ接続するか」という処理は、概念上同じものではない
ということです。
時間も関係します。
DNSにはTTLがありますが、「TTLが切れた瞬間にすべてのresolverが必ずその情報を捨てる」と単純化することはできません。
後続の標準や実装条件によって、stale dataの扱いなどには追加の条件があります。
したがって、本章で言えるのは、
判断時点と接続時点の間に、名前解決・候補選択・時間という別の状態が存在し得る
というところまでです。
「必ず宛先が変わる」「必ず再解決される」といった主張はしません。
通信は、一回で終わるとは限りません。
あるリクエストの結果として、後続のリクエストが発生することがあります。
代表的な例の一つがredirectです。
クライアントがredirectを追跡する場合、その後にはredirect先に対する新しいリクエストが発生します。
つまり、
最初の通信先について行った判断が、その後に発生する通信にもそのまま適用できるとは限らない
という問題が出てきます。
ここで注意したいのは、すべてのクライアントがredirectを自動的に追跡するわけではないことです。
また、redirectによってHTTP methodが常に変化するわけでもありません。
したがって、
「redirectがあるから必ずこうなる」
ではなく、
後続リクエストが発生する処理では、その各段階について判断が維持されているかを考える必要がある
と捉える方が正確です。
実際、WordPressの特定のソース経路では、redirect先について後続リクエストの前に確認処理が行われる分岐を確認できます。
これは重要な例です。
なぜなら、単に「最初に確認する」だけではなく、
通信ライフサイクルの途中でも判断が必要になる場合がある
ことを示しているからです。
ただし、この例から「WordPressなら常に安全」と結論づけることはできません。
確認できたのは、特定のコード経路における構造です。
入力を確認する処理と、実際に通信を行う処理が、同じコンポーネントに存在するとは限りません。
アプリケーションが判断したあと、
- HTTPライブラリ
- 別の抽象化層
- renderer
- fetcher
- proxy
- worker
などへ処理を渡す構造があります。
WordPressの固定されたソース経路では、REST側の処理からHTTP層、Requests、cURLへと処理が渡され、確認と通信実行の責任が複数のコンポーネントに分かれていることを追跡できます。
これは、
判断点と実行点が、コード上で別の場所になり得る
ことの具体例です。
一方で、この事実だけから、
「途中で判断が失われた」
「別ホストで実行された」
「脆弱性が成立している」
と結論づけることはできません。
確認できているのは、同一プロセス内の特定経路で、役割が複数のコンポーネントに分かれているということです。
rendererのように、外部リソース取得を別のfetcherへ委譲できるインターフェースもあります。
ただし、そのfetcherへ到達するまでにアプリケーション側でどのような判断が行われ、それがどこまで保持されるかについては、この情報だけでは判断できません。
したがって、その点を確立済みの事実として扱うことはしません。
同じ名前でも、どの環境から参照するかによって意味が変わる場合があります。
Kubernetesでは、service discoveryにおいて、修飾されていないservice名の意味が、問い合わせを行うPodのnamespaceに依存することがあります。
つまり、文字列として同じ名前でも、
実行環境が違えば、解決される意味が同じとは限らない
ということです。
これは、入力文字列だけを見て通信先を完全に理解することが難しい理由の一つです。
ネットワーク制御についても同様です。
KubernetesのNetworkPolicyは、ポリシーオブジェクトが存在するだけで必ず通信制御が実施されるわけではありません。
それを実際にサポートし、適用するネットワーク実装が必要です。
ここでも、
設定が存在することと、実際の通信が制御されていることを分けて考える必要があります。
ただし、本章では特定のクラスタ、ホスト、service、宛先について「実際に到達可能だった」とは主張しません。
具体的なreachabilityは環境依存であり、別途確認が必要な事項です。
通信の意味は、宛先だけでは決まりません。
同じ通信でも、
- どのidentityで実行されるか
- どのcredentialが利用可能か
- どのpolicyが適用されるか
- どの権限が与えられているか
- どの信頼関係の中で実行されるか
によって、その結果は変わり得ます。
Boto3の文書では、credential providerの探索機構が説明されています。
AWSの文書では、request signingによるauthenticationと、その後のpolicy evaluationやauthorizationが別の段階として扱われています。
ここから重要なのは、
credentialを見つけられること、authenticationできること、authorizationされることは同じではない
という点です。
Chapter 1で確認した、
reachability ≠ privilege ≠ authorization ≠ trust
という区別は、ここでも維持する必要があります。
また、NISTのZero Trust Architectureでも、ネットワーク上の位置だけを根拠に暗黙の信頼を与えるという考え方は採られていません。
したがってSSRFの影響を考えるときには、
「その宛先へ届くか」
だけではなく、
その通信が、どのidentity・権限・信頼文脈の中で実行されるのか
を分けて考える必要があります。
ただし、本章では特定環境のcredential、identity、permission結果を観測したわけではありません。
それらを一般化して「サーバー側通信には常にcredentialが付く」と考えることもできません。
ここまで見てきた6つの観点は、互いに独立した問題としてだけ存在するわけではありません。
入力を確認したあとに名前解決が行われる。
その後、別のコンポーネントへ処理が渡される。
さらにredirectによって次の通信が発生する。
その通信は、特定の実行環境とidentityのもとで行われる。
このように、複数の条件が重なることがあります。
そのためSSRF防御を考えるとき、
一か所に一つのチェックを置けば、すべての通信について判断が固定される
とは限りません。
ただし、これは「入力検証は無意味だ」という意味でもありません。
重要なのは、各対策が、
- 何を判断しているのか
- どの時点の状態を見ているのか
- その判断がどの処理まで有効なのか
- 後続の通信で再評価が必要なのか
を理解することです。
本章で示した6つの観点は、その確認漏れを考えるための分析モデルです。
網羅的な分類ではありません。
すべてのシステムに同じ形で現れるわけでもありません。
それでも、
判断した内容と実際の通信を別々に追う
という考え方は、SSRF防御を設計するときの重要な出発点になります。
ここまでのChapter 2では、
なぜSSRF対策が一つの入力チェックだけでは完結しにくいのか
を見てきました。
判断と実通信の間には、
- 解釈
- 時間と名前解決
- 通信ライフサイクル
- 実行経路
- 実行環境
- identity・権限・信頼
といった複数の観点があります。
では、実際のシステムでは、これらのどこに防御を置けばよいのでしょうか。
入力時に何を確認するのか。
名前解決や接続時には何を確認するのか。
redirectや二次的な通信をどう扱うのか。
コンポーネント間の責任をどう分けるのか。
実行環境やidentityをどこまで制限するのか。
これらは、概念だけでは決まりません。
実際のアプリケーション構造、ライブラリ、ネットワーク、実行環境に合わせて設計する必要があります。
Chapter 3では、Chapter 2で整理した「判断と実通信のずれ」を前提に、実際のシステムで防御をどこに置き、どのように組み合わせるかを扱います。