本サイトは、快適にご利用いただくためにクッキー(Cookie)を使用しております。
Cookieの使用に同意いただける場合は「同意する」ボタンを押してください。
なお本サイトのCookie使用については、「個人情報保護方針」をご覧ください。
最近はMBSDでWebアプリケーションスキャナの開発をしている寺田です。
Webアプリケーションを開発していると、セキュリティの観点でURLをチェックしなければならないことがしばしばあります。本日の記事では、そのようなURLのチェックを如何に行うか、正規表現を使う場合の注意点や、バイパス方法などについて書きたいと思います。
本記事で想定するのは、ブラウザからパラメータとして来るURLをチェックしてリダイレクトやリンクのURL等として使ったり、ブラウザから来たOriginヘッダ等のURLをチェックしてアクセス制御をするケースです。その中でも、以下のようにサブドメイン部分(★の部分)を可変にする状況を主に想定します。
https://★.example.jp/…
よく使われてそうなチェック用の正規表現と、そのバイパスは以下のとおりです。
正規表現: ^https://.+\.example\.jp/
バイパス: https://evil/.example.jp/
上の例のように、異なるドメイン(evil)のURLを与えてチェックをすり抜けることが攻撃の目標です。
正規表現でありがちなミス
上記のように、チェックに使用している正規表現が不十分なためバイパスが可能であることは多くあります。以下に、その手の正規表現でよくあるミスを見てみたいと思います。
なお、正規表現は処理系によって使える構文がかなり違います。本記事ではJavaScriptの構文を使っています。
文頭のチェック漏れ
正規表現: https://[\w.-]+\.example\.jp/.*
バイパス: https://evil/https://www.example.jp
文頭を示す「^
」が抜けているパターンです。これだと先頭に任意の文字列を追加できてしまいます。「^
」が暗黙に付くJavaの正規表現に慣れている人が、別の処理系で正規表現を書くとやってしまいがちなミスかもしれません。
ドットのエスケープ漏れ
正規表現: ^https://[\w.-]+.example.jp/
バイパス: https://evil-example.jp/
ホスト名に含まれるリテラルの「.
」を「\.
」にエスケープしていないパターンで、意外と多くみられます。攻撃者が操作できる部分は限定されますが、攻撃者が取得可能なドメイン(上の例ではevil-example.jp)を持つURLがチェックをすりぬけてしまうので、攻撃としては成立します。
エスケープに関連して言うと、JavaScriptにおいて、正規表現リテラル(/pattern/
)ではなく、文字列リテラル(new RegExp("pattern")
)として正規表現を記述する時、「"\."
」ではなく「"\\."
」のようにバックスラッシュをエスケープする必要があります。これは、文字列リテラルとしての評価が最初に行われるからであり、"\." === "."
が真になることから理解できると思います(同じ理屈で「"\w"
」は「"\\w"
」にしなければならないので、その時点で気付くとは思いますが)。
この辺りは言語やクォートによって異なっていて、筆者がよく使うPHPでは"\." === "\\."
となるためどちらでもよいのですが、間違いを減らすためエスケープ可能な「\
」は常にエスケープするようにしています。一方、JavaScriptやRubyの正規表現リテラルでは「/\\./
」ではなく「/\./
」としなければなりません。
マルチラインフラグ
正規表現: ^https://[\w.-]+\.example\.jp/ (マルチラインフラグ付き)
バイパス: https://evil%0Ahttps://www.example.jp/
正規表現のマルチラインフラグが有効な時、「^
」は文頭ではなく行頭にマッチします。診断での検出頻度は低いですが、Rubyのようにデフォルトでマルチラインフラグが有効になっている処理系では注意が必要です。常に文頭/文末にマッチする「\A
」「\z
」が使える処理系では、それを使うのがよいでしょう。
ただし、JavaScriptのように「\A
」や「\z
」を使えない処理系もあります。その場合は、マルチラインフラグを無効にして「^
」「$
」を使うか、マルチラインフラグを有効にするならば「(?<![^])
」や「(?![^])
」等によって文頭・文末をあらわすことになります。通常のURLのチェックではマルチラインフラグは不要なので、無効にした方がよいでしょう(JavaScriptではデフォルトで無効になっています)。
※ [^]
はJavaScriptで使用可能なパターンです。任意の1文字をあらわす[\s\S]
と同じです。
なお、マルチラインフラグが有効である時の行頭「^
」の定義は処理系によって違っており、JavaScriptではLFに加えて、CR, U+2028, U+2029の後ろも行頭とみなされます。Javaのように行区切りとみなす文字がさらに多い処理系もあります。マルチラインフラグを有効にする場合は、使用している正規表現エンジンにおいて行区切りとみなされる文字を把握しておいた方がよいです。
末尾のチェック漏れ
正規表現: ^https://[\w.-]+\.example\.jp
バイパス: https://www.example.jp@evil
バイパス: https://www.example.jp.evil
診断ではたまに見ます。「($|/)
」等を使って末尾までチェックしましょう。
ちなみに、バイパスの1つ目に挙げた「https://a.example.jp@evil
」の「@
」より前はユーザ情報(ID/パスワード)とみなされるため、このURLのホストは「evil」となります。
正規表現のミス 諸々
その他、今回の想定ケースには必ずしも当てはまらないものもありますが、一般に正規表現を書く際に生じやすいと思われるミスを以下に挙げました(筆者の過去のブログ記事の内容を、若干変更して再掲しています)。
- 「
-
」を角括弧内でエスケープし忘れる。
例:[a-zA-Z.-_]
… ドットからアンダースコアまで許可されてしまう。 - 「
.
」に行末文字がマッチしないことを忘れる。
例:^(.*?)<
… 「\n<
」にはマッチしない。JavaScriptではCR, U+2028, U+2029も行末文字とみなされる(DotAllフラグがあれば「.
」は全ての文字にマッチする)。 - 「
$
」が文末の改行にマッチすることを忘れる。
例:AAA$
… 文末の「AAA
」を探すつもりだが「AAA\n
」にもマッチしてしまう(Perl、PHPのPCRE、Pythonのre、Ruby等のPerl互換の正規表現のみ)。 - 「
\s
」を安易に使う。
空白文字の定義は処理系により異なる。JavaScriptの場合は多くの文字がマッチする(MDNを参照)。 - 過度に複雑な正規表現を書く。
メールアドレスやURLのチェック等で。ReDoS(Regular Expression DoS)という脆弱性の原因になりうる。
URL固有の観点
正規表現の構文の話とは離れますが、URLに固有のミスについてもう少し見ていきます。
ユーザ情報
正規表現: ^https://([^/]+@)?[\w.-]+\.example\.jp/
バイパス: https://evil?@.example.jp/
既に簡単に触れましたが、https://username:password@www.example.jp/
のような構文で、URLにユーザ情報(HTTP認証のユーザ名とパスワード)を埋め込むことができます。診断でも、この構文のURLを許容しているサイトをしばしばみますが、ユーザ情報の箇所のチェックが緩いケースがあります。その場合「@
」より前に「?
」等を配することで、チェックがバイパスされてしまうかもしれません。
スキーム
正規表現: ^\w+://[\w.-]+\.example\.jp/
バイパス: javascript://www.example.jp/%0Aalert(1)
本題とはずれた話ではありますが、http/httpsの両方を許可する際に、上記のようにスキーム(プロトコル)を「\w+
」や「[a-z]+
」のように指定しているアプリケーションを見ることがあります。
この正規表現ではホストを「evil」にすることはできませんが、URLが「<a href=…>
」に入るような場合には「javascript:
」を使ってXSS攻撃ができることがあります。
次は、httpとhttpsのスキームを許可している例です。
正規表現: ^https?://[\w.-]+\.example\.jp$
バイパス: http://www.example.jp
httpのURLを許可してもセキュリティ上の問題にならない状況もありますし、問題になる状況もあります。後者の典型的な例は、httpsのリソースにおいてCORS(Cross Origin Resource Sharing)のOriginヘッダをチェックする処理です。
【要求】
Origin: http://www.example.jp
【応答】
Access-Control-Allow-Origin: http://www.example.jp
Access-Control-Allow-Credentials: true
CORSでhttpのオリジンを許可するということは、httpのリソースからのfetchを許すことです。通信経路上の攻撃者はhttpのリソースを改竄可能なので、httpsのリソースにおいてhttpのオリジンを許可すると、通信経路上の攻撃者がそのhttpsのリソースを窃取できてしまうことになります。
他にも、与えられたURLに?secret=(秘密のトークン)
のような何かしら秘密の情報を付け加えて、リンクやリダイレクトのURLとして使う場合にも、httpを許可することは問題になりえます。
Location: http://www.example.jp/?secret=(秘密のトークン)
※ HTTPSの使用を強制するHSTS(HTTP Strict Transport Security)によって緩和される問題ではあります。
相対URL
正規表現: ^(/|https://[\w.-]+\.example\.jp/)
バイパス: //evil
これも本記事のテーマからはそれますが、ドメインのチェックを行うようなアプリケーションでは、相対URL(例: /foo/bar.html
, bar.html
)も許していることがあります。
ご存じのとおり、URLの先頭が「/evil
」であればパス部分とみなされます。しかし先頭が「//evil
」であればプロトコル部分を省略した形式のURL、つまり「http(s)://evil
」と同じとみなされます。本記事の本題ではないので深入りしませんが、「//
」以外にもバリエーションがあり、それらでバイパスできてしまうこともあります。
サブドメイン(Denylistによる記号チェック)
正規表現: ^https://[^/]+\.example\.jp/
バイパス: https://evil?.example.jp/
実際に多く使われている正規表現だと思います。サブドメイン部分に「/
」を使えないようにしていますが、URLのホスト部分は「/
」だけでなく、「?
」「#
」「\
」によっても終わらせることができます。また、本記事では紹介しませんが、これらの記号以外でもホスト名を終わらせることができる場合もあります。
サブドメイン部分のチェックには、Denylist的な正規表現(「/
」等の許容しない文字を定義する)を使用しない方が安全です。
ドメインをチェックするには
それでは「https://★.example.jp/…
」をどうチェックすればいいでしょうか。正規表現以外の方法もあり会社としてはそれを推していますが、ここでは本記事のテーマどおり正規表現を前提にチェック方法を考えてみたいと思います。
正規表現によるチェック
筆者が正規表現を書くならば、サブドメインやポートが固定的な時は、
正規表現: ^https://(aaa|bbb|ccc)\.example\.jp/
もう少しフレキシブルにする、ただしIDN(国際化ドメイン名)を使わないならば、
正規表現: ^https://([a-z\d]|(?<=[a-z\d])-(?=[a-z\d])|(?<=[a-z\d])\.(?=[a-z\d]))+\.example\.jp(:\d+)?(/|$)
というような正規表現(ignoreCaseフラグ付き)を使うと思います。
少々補足すると、以下のように簡略化したチェックにしています。
- スキームはhttps固定。
- 相対URLには対応していない。
- ドメインのチェックが目的なので、ここではパス名以降をチェックしていない。
- 連続したマイナス記号を受け付けない(本来は一部の箇所を除いて使える)。
@
を使いユーザ情報を埋め込んだURLを受け付けない。- その他…
ネット検索すると、URLにマッチする非常に長大な正規表現が紹介されていたりします。対して筆者の正規表現は簡略化したものであり、全ての妥当なURLを受け入れるものではありませんが、サブドメインのチェック用途(つまりは自社ドメイン内の範囲のURLを対象とする)であれば、大抵はこれで充分ではないかと思います。
※ もし、もっと汎用的かつ厳密なチェックが必要であれば、正規表現ではなく正規化&チェック用の関数を書いた方がよいと思います。
URL処理用のライブラリ
正規表現以外の方法としては、URLをParseする汎用のライブラリ等を使って、URLからホスト名を取り出してドメインをチェックすることも考えられます。ただし、ライブラリのURL解釈と、ブラウザのURL解釈には違いがあります。
例えば、ブラウザは「https://evil\.example.jp
」のホストを「evil」だと解釈しますが、多くのプログラム言語のURL parserの解釈はそうではありません。
【Python】(urllib)
print(urlparse("https://evil\\.example.jp").netloc)
=> evil\.example.jp
【Java】(java.net.URL)
System.out.println(new URL("https://evil\\.example.jp").getHost());
=> evil\.example.jp
【PHP】
echo parse_url("https://evil\\.example.jp")["host"]);
=> evil\.example.jp
上記の違いのため、bool ok = new URL(input).getHost().endsWith(".example.jp")
(Java)のような単純な判定はできないということです。
※ 「\
」の解釈の違いは準拠する仕様の違いによるものだと思います。ブラウザはWHATWGのURL Standard、昔からあるParserの多くはRFC 3986をベースにしていると思われます。言語によってはWHATWGのURL StandardベースのURL parserライブラリも存在します。
また、異常な形式のURLの解釈はライブラリによってまちまちであり、以下のスライドの例のようにチェックを回避できてしまうこともあります。
A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages! -- Orange Tsai
Host/Split: Exploitable Antipatterns in Unicode Normalization -- Jonathan Birch
したがって、ライブラリを使う場合でも、正規表現等による形式チェックと組合わせるのがよいでしょう。
ReDoS
既に簡単に触れたように、問題がある正規表現を使用している場合、細工した入力(正規表現によるチェックの対象となる文字列)を与えてDoSを引き起こすReDoS(Regular expression Denial of Service)という攻撃の影響を受ける可能性があります。正規表現を書いた際には、それがReDoSに脆弱なのかをチェックするとよいです。チェック方法等は以下の記事が参考になります。
20日目: 正規表現が ReDoS 脆弱になる 3 つの経験則 | 立命館コンピュータクラブ
正規表現の脆弱性 (ReDoS) を JavaScript で学ぶ -- Takuo Kihira
これらの記事にもあるように、「\s+$
」等の非常に短い正規表現でもReDoSに脆弱になることがあります(ただしこの正規表現の処理時間を引き延ばすには、かなり長い入力を与える必要があります)。
筆者も、これらの記事を参考に、筆者が書いた上記の正規表現がReDoSに脆弱でないことをチェックサイトで確認しました。チェック結果は100%正確というわけではありませんが、あからさまに問題があるものの多くは発見できると思います。
正規表現自体のチェックに加えて考慮したいのは、入力の文字列長を制限することです。本当にダメな正規表現を使っている時は数十文字程度の入力でも攻撃が可能なので、保険的な対策にしかなりませんが、使う正規表現によっては効果はあります。長さの制限は、PHPのPCREにおける問題(マッチするはずの正規表現がマッチしない現象 -- T.Terada)のような問題の緩和策ともなります。URLに関しては、仕様上の長さの上限はありませんが、ブラウザやWebサーバは数KB程度の上限を設けているので、それを参考に上限を決めれば良いと思います(参考: SISTRIX)。
おすすめ記事