5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudFront + AWS WAF のパストラバーサル対策 ― パス条件のルールは `../` ですり抜けられるのか検証した

5
Posted at

はじめに

パストラバーサルとは、URL のパスに ../(1つ上の階層に戻る)を含めて、本来アクセスできない場所に到達しようとする攻撃手法です。

CloudFront + AWS WAF の構成で、こうした不正なアクセスを防ぐ方法の1つに、WAF でパスを条件に許可・拒否するルールがあります。たとえば「/static/ で始まるパスだけ通し、それ以外は Block する」といった設定です。

本記事では、パストラバーサル攻撃そのもの(サーバー上のファイルを読み取られるかどうか)ではなく、WAF のパス条件によるアクセス制御が ../ でバイパスされるかという観点で検証します。

ただし、この対策が効くのは、WAF と CloudFront が同じパスを見ている場合だけです。CloudFront は ../ を整理(正規化)してから振り分け先を決めることが公式ドキュメントに書かれていますが、WAF が整理前と整理後のどちらのパスで判定するかは書かれていません。もし WAF だけが整理前のパスを見ているなら、/static/../ を付けるだけでルールをすり抜けられてしまいます。

そこで、最小構成の環境を作って実際に確かめました。結論は次のとおりです。

  • AWS WAF(テキスト変換なし)は、整理前のパスで判定していた
  • CloudFront は、整理後のパスで振り分け先(ビヘイビア)を決めていた
  • そのため、/static/../default.html や /static/%2e%2e/default.html で、WAF の許可ルールをすり抜けられた
  • ルールにテキスト変換 URL_DECODE → NORMALIZE_PATH を付けると、すり抜けを防げた

1. 前提:パスの正規化と処理の順番

../ とパスの正規化

/static/../default.html は、static に入ってから1つ戻るので、最終的には /default.html を指します。このように .. などを計算して、最終的なパスに書き直すことを正規化と呼びます。ルールは RFC 3986(URL の標準仕様)で決められています。

/static/../default.html  →  正規化  →  /default.html

CloudFront でのリクエストの処理順

  1. AWS WAF がリクエストを検査する
  2. CloudFront がパスを見て、どのビヘイビア(パスごとの振り分けルール)を使うか決める
  3. オリジン(S3 や ALB など)にリクエストを送る

WAF が整理前、CloudFront が整理後のパスを見ていると、次のことが起こります。

リクエスト:/static/../default.html

WAF        :「/static/ で始まっている」→ 許可ルールに一致して通す
CloudFront :正規化すると /default.html → /static/* ではないので Default へ
結果       :WAF で止めるはずのリクエストが、Default のオリジンに届く

公式ドキュメントに書いてあること・ないこと

項目 公式の記載 出典
CloudFront は RFC 3986 に沿ってパスを正規化してから、ビヘイビアを照合する あり CloudFront 開発者ガイド「キャッシュ動作の設定」
ビヘイビアの照合後、オリジンには未加工(raw)のパスを送る あり CloudFront 開発者ガイド「キャッシュ動作の設定」
%2e%2e(.. をパーセントエンコードしたもの)も正規化するか 記載なし ―
WAF はビヘイビアや CloudFront Functions より先に実行される あり AWS ブログ「Amazon CloudFront リクエストのライフサイクル」
WAF が整理前と整理後のどちらのパスで判定するか 記載なし ―

ドキュメントだけでは判断できない2点を、実験で確かめることにしました。

2. 検証の考え方

確かめたいこと

/static/../default.html を送ったとき、

  • WAF は、整理前と整理後のどちらのパスで判定するのか
  • CloudFront は、整理前と整理後のどちらのパスで振り分けるのか

仮説ごとに、観測される結果を先に決めておく

WAF に「/static/ で始まれば Allow(ルール1)、それ以外は Block(ルール2)」というルールを設定しておくと、仮説ごとに結果が変わります。

仮説 WAF の判定 CloudFront の振り分け 観測される結果
A:WAF は整理前、CloudFront は整理後 ルール1で Allow Default Default から応答、ログは rule1
B:WAF は整理後 ルール2で Block (届かない) WAF の Block 応答、ログは rule2
C:どちらも整理前 ルール1で Allow /static/* S3 からエラー応答、ログは rule1

仮説ごとに結果が違うので、結果を見れば仮説を1つに絞れます。これが、この方法で検証できる理由です。結果を後から都合よく解釈しないように、判定基準は検証の前に決めておきました。

結果を見分けるための工夫

仮説を見分けるには、「Default に届いた」「WAF が止めた」「S3 に届いた」の3つを、確実に区別できる必要があります。そのために、次の工夫をしました。

工夫 別のやり方だとダメな理由
Default に CloudFront Functions を置き、DEFAULT reached uri=<受け取ったURI> を返す Default を S3 にすると、届いても S3 の 403 が返り、WAF の Block と区別できない
WAF の Block 時は 503 / "Blocked by rule2" を返す 初期値の 403 のままだと、S3 のエラーと区別できない
WAF ログの terminatingRuleId(判定を決めたルール)で判定する ログの uri は記録用の値で、判定に使ったパスとは限らない(5章で実際に確認)
curl の x-amz-cf-id とログの requestId を突き合わせる 別のリクエストのログを見て判断するのを防ぐ
先に基準のリクエスト①②でルールが効くことを確かめる ルールが効いていなければ、③④の結果に意味がない
本番を再現せず、最小構成にする 要素が多いと、結果に影響する原因を切り分けられない

3. 検証環境と手順

【構成図】
architecture.png

リソース 設定
S3 static/test.html(本文 "S3-A static")。OAC 経由でのみ読み取り可
CloudFront /static/* S3 へ
CloudFront Default(*) CloudFront Functions(viewer request)が固定レスポンスを返す
WAF ルール1(優先度10) URI パスが /static/ で始まる → Allow(テキスト変換なし)
WAF ルール2(優先度20) IPv4 全体の IP セット → Block(503 / "Blocked by rule2")
WAF ログ CloudWatch Logs に出力

リージョンは us-east-1 で、Terraform で構築しました。キャッシュは無効にして、前の結果が返ってこないようにしています。

パス 目的
① /static/test.html 基準:Allow されるはず
② /default.html 基準:Block されるはず
③ /static/../default.html 本命
④ /static/%2e%2e/default.html .. をパーセントエンコードしたもの

curl には --path-as-is と -4 を付けます。--path-as-is がないと、curl が送信前に .. を整理してしまいます(6章)。-4 は、IP セットを IPv4 だけで作ったためです。

4. 結果(対策前)

curl と WAF ログは、実際の出力から主要な行を抜き出しています。リクエスト ID は伏せていますが、curl の x-amz-cf-id と WAF ログの requestId が一致することを確認しています。

基準①②

$ curl -4 -sv --path-as-is "$D/static/test.html"
> GET /static/test.html HTTP/1.1
< HTTP/1.1 200 OK
< Server: AmazonS3
S3-A static

$ curl -4 -sv --path-as-is "$D/default.html"
> GET /default.html HTTP/1.1
< HTTP/1.1 503 Service Temporarily Unavailable
< Server: CloudFront
Blocked by rule2

ルールが想定どおり効いていることを確認できました。

③ /static/../default.html

$ curl -4 -sv --path-as-is "$D/static/../default.html"
> GET /static/../default.html HTTP/2
< HTTP/2 200
< server: CloudFront
< x-cache: FunctionGeneratedResponse from cloudfront
< x-amz-cf-id: <ID-1>

$ curl -4 -s --path-as-is "$D/static/../default.html"; echo
DEFAULT reached uri=/static/../default.html
{
  "terminatingRuleId": "rule1-allow-static",
  "action": "ALLOW",
  "httpRequest": {
    "uri": "/static/../default.html",
    "requestId": "<ID-1>"
  }
}

④ /static/%2e%2e/default.html

$ curl -4 -sv --path-as-is "$D/static/%2e%2e/default.html"
> GET /static/%2e%2e/default.html HTTP/1.1
< HTTP/1.1 200 OK
< X-Cache: FunctionGeneratedResponse from cloudfront
< X-Amz-Cf-Id: <ID-2>
DEFAULT reached uri=/static/%2e%2e/default.html
{
  "terminatingRuleId": "rule1-allow-static",
  "action": "ALLOW",
  "httpRequest": {
    "uri": "/static/%2e%2e/default.html",
    "requestId": "<ID-2>"
  }
}

わかったこと

③④とも、2章の仮説Aに当てはまりました。

  • WAF は整理前のパスで判定していた:ルール1(/static/ で始まる)に一致して Allow された
  • CloudFront は整理後のパスで振り分けていた:/static/* ではなく、Default の Functions が応答した
  • CloudFront は %2e%2e も .. として扱った(公式に記載なし)。一方、テキスト変換なしの WAF は %2e%2e をデコードせずに判定していた
  • Functions が受け取った URI は整理前のままだった。「オリジンには raw のパスを送る」という公式の記載と合っている

つまり、パスを条件にした WAF ルールだけでは、パストラバーサルを防げないことがわかりました。

5. 対策:テキスト変換を追加する

ルール1に、次の2つのテキスト変換(WAF が判定の前に文字列を変換する機能)を追加しました。

優先度 変換 役割
0 URL_DECODE %2e などのパーセントエンコードを元に戻す
1 NORMALIZE_PATH .. や // を整理する

順番が大事です。先に URL_DECODE で %2e%2e を .. に戻しておかないと、NORMALIZE_PATH が .. だと気づくことができません。

また、公式ドキュメントでは NORMALIZE_PATH の動作を「入力の先頭にない複数のスラッシュ、ディレクトリの自己参照、およびディレクトリのバックリファレンスを削除することで、入力文字列を正規化します」と説明しています(AWS WAF でのテキスト変換の使用)。今回の /static/../ は .. がパスの途中にあるため正規化の対象になりましたが、正規化の対象には条件があります。パスの先頭に .. がある場合など、別のパターンに当てはめるときは、別途確認が必要です。

なお、テキスト変換を1つ追加するごとに、WAF のルールの処理量を表す WCU(Web ACL Capacity Unit)が 10 増えます。Web ACL には WCU の上限があるので、ルールが多い環境では注意が必要です。

                  - text_transformation {
                      - priority = 0 -> null
                      - type     = "NONE" -> null
                    }

                  + text_transformation {
                      + priority = 0
                      + type     = "URL_DECODE"
                    }
                  + text_transformation {
                      + priority = 1
                      + type     = "NORMALIZE_PATH"
                    }

Plan: 0 to add, 1 to change, 0 to destroy.

再検証の結果

対策前 対策後
① /static/test.html 200 200
② /default.html 503 503
③ /static/../default.html 200 DEFAULT reached 503 Blocked by rule2
④ /static/%2e%2e/default.html 200 DEFAULT reached 503 Blocked by rule2
$ curl -4 -sv --path-as-is "$D/static/../default.html"
> GET /static/../default.html HTTP/2
< HTTP/2 503
< x-amz-cf-id: <ID-3>
Blocked by rule2
{
  "terminatingRuleId": "rule2-block-all",
  "action": "BLOCK",
  "responseCodeSent": 503,
  "httpRequest": {
    "uri": "/static/../default.html",
    "requestId": "<ID-3>"
  }
}

(④も同様に rule2-block-all で Block されました)

③④はルール1に当てはまらなくなり、ルール2で Block されました。①は 200 のままなので、正常なリクエストまで止めてしまう副作用(誤検知)もありませんでした。

なお、ログの uri は対策後も整理前の書き方のまま記録されていました。テキスト変換は判定に使う値を変えるだけで、ログに残るパスは変わりません。2章で「uri ではなく terminatingRuleId で判定する」と決めておいたのは、このためです。

6. 検証でハマったところ

curl は送る前に .. を整理する

$ curl -4 -sv "$D/static/../default.html" 2>&1 | grep "> GET"
> GET /default.html HTTP/2
$ curl -4 -sv --path-as-is "$D/static/../default.html" 2>&1 | grep "> GET"
> GET /static/../default.html HTTP/2

ブラウザも同じように整理します。「ブラウザや普通の curl で試したら止まった」は、確認したことになりません。攻撃者は書いたとおりに送れるツールを使うので、--path-as-is を付け、> GET の行で .. がそのまま送られたかを毎回確認する必要があります。

検証する環境によっては、途中で通信が遮断される

検証の途中で、../ を含むリクエストが Connection reset by peer で切断され、WAF ログにも記録が残らないことがありました。つまり、リクエストが AWS まで届いていませんでした。別のネットワーク(AWS CloudShell など)から送ると、正常に届きました。

$ curl -4 -sv --path-as-is "$D/static/../default.html"
> GET /static/../default.html HTTP/1.1
* Request completely sent off
* Recv failure: Connection reset by peer

VPN やプロキシなど、通信を検査する機器が途中にあると、../ を含む通信が止められることがあります。手元から試して止まっても、AWS 側で止まっているとは限りません。結果を判断するときは、WAF ログにそのリクエストが記録されているかを必ず確認してください。

7. まとめ

CloudFront + AWS WAF で取れるパストラバーサル対策

  • パスを条件に許可・拒否する WAF ルールには、テキスト変換 URL_DECODE → NORMALIZE_PATH をこの順番で付ける
  • テキスト変換がないと、WAF は整理前のパスで判定するため、../ や %2e%2e ですり抜けられる
  • 動作確認は、--path-as-is を付けた curl で行い、WAF ログに記録されていることまで確認する

今回は確認していないこと

  • 前方一致(STARTS_WITH)以外の条件(正規表現、AWS マネージドルールなど)
  • パスの先頭に .. がある場合など、NORMALIZE_PATH の正規化対象の条件に当てはまらないパターン
  • 二重エンコード(%252e)やバックスラッシュ(..\、NORMALIZE_PATH_WIN)
  • ALB や API Gateway に付けた WAF での挙動
  • オリジン(ALB やアプリケーション)が .. を含むパスをどう解釈するか

今回の結果は、2026年10月時点で、上記の構成で確認したものです。

今後の展望

  • 既存の WAF ルールで、パス条件にテキスト変換が付いているかを確認する
  • 上の「確認していないこと」を順に検証する
  • パス条件の WAF ルールを書くときのチェックリストにまとめて共有する

学んだこと

今回の検証は、CloudFront や WAF についてほとんど知識がない状態から始めました。それでも、リクエストがどの順番で処理されるのか、ログのどの項目を見れば判定できるのかを1つずつ理解していくことで、環境の構築から検証、対策の確認まで進めることができました。

また、検証の出発点になったのは、公式ドキュメントに「書いていないこと」に疑問を持ったことでした。CloudFront がパスを正規化することは書かれていても、WAF がどちらのパスを見るかは書かれていないからこそ、手を動かして確かめる意味がありました。これからドキュメントを読むときは、書いてあることだけでなく、書いていないことにも目を向けていきたいと思います。

参考

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?