先日 mod_auth_openidc の脆弱性を報告して修正にも関わり CVE-2019-14857 として割当られたので、発見した脆弱性や対処したことについて書いてみました。
- https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-14857
- https://access.redhat.com/security/cve/cve-2019-14857
CVE-2019-14857
CVE-2019-14857 は mod_auth_openidc に存在したオープンリダイレクトの脆弱性です。
もともと mod_auth_mellon にオープンリダイレクトの脆弱性が公表されており、
mod_auth_openidcではどうなっているのだろう?とソースコードを確認したところ同じ脆弱性があることを発見しました。
当時の最新版バージョンで試して攻撃が成立したので mod_auth_opendic コミュニティに報告したり、pull request を送ったりして最終的にバージョン2.4.0.3 としてリリースされました。後述しますが、危うい修正であり将来はより安全な方向で修正される予定です。
今回の脆弱性は私が新しい攻撃方法を見つけたわけではなく、mod_auth_mellon で成立した攻撃方法が mod_auth_openidc でも成立したというものになります。
mod_auth_openidc のリダイレクト先URLのチェック
オープンリダイレクトは、任意のURLへリダイレクトできてしまう脆弱性です。
mod_auth_openidc にはログアウトURLへアクセスしたときにログアウト完了後に遷移する URL を Queryパラメーターとして指定できます。
例えば、以下の URL にアクセスすると mod_auth_openidc のセッションを破棄した後、https://rp.example.co.jp/logout.html
へリダイレクトされます。
https://rp.example.co.jp/example/redirect_uri?logout=https://rp.example.co.jp/logout.html
ここでQueryパラメーターのhttps://rp.example.co.jp/logout.html
の部分を変えたときに、
任意の URLへ遷移できてしまってはオープンリダイレクトの脆弱性が存在することになります。
当然ですが、mod_auth_openidc には Queryパラメーターで与えられた URL に遷移して良いか URL のバリデーションのチェックを行っています。
今回はこの URL のバリデーションのチェックをすり抜けてしまうことが出来たという脆弱性です。
修正前のソースコード
それでは mod_auth_openidc のソースコードを見てみましょう。脆弱性対応前(v2.4.0) の URL のバリデーションチェック部分のソースコードです。
3030 static int oidc_handle_logout(request_rec *r, oidc_cfg *c,
3031 oidc_session_t *session) {
〜
3055 apr_uri_t uri;
3056
3057 if (apr_uri_parse(r->pool, url, &uri) != APR_SUCCESS) {
3058 const char *error_description = apr_psprintf(r->pool,
3059 "Logout URL malformed: %s", url);
3060 oidc_error(r, "%s", error_description);
3061 return oidc_util_html_send_error(r, c->error_template,
3062 "Malformed URL", error_description,
3063 HTTP_INTERNAL_SERVER_ERROR);
3064
3065 }
3066
3067 const char *c_host = oidc_get_current_url_host(r);
3068 if ((uri.hostname != NULL)
3069 && ((strstr(c_host, uri.hostname) == NULL)
3070 || (strstr(uri.hostname, c_host) == NULL))) {
3071 error_description =
3072 apr_psprintf(r->pool,
3073 "logout value \"%s\" does not match the hostname of the current request \"%s\"",
3074 apr_uri_unparse(r->pool, &uri, 0), c_host);
3075 oidc_error(r, "%s", error_description);
3076 return oidc_util_html_send_error(r, c->error_template,
3077 "Invalid Request", error_description,
3078 HTTP_INTERNAL_SERVER_ERROR);
3079 }
3080
3081 /* validate the URL to prevent HTTP header splitting */
3082 if (((strstr(url, "\n") != NULL) || strstr(url, "\r") != NULL)) {
3083 error_description =
3084 apr_psprintf(r->pool,
3085 "logout value \"%s\" contains illegal \"\n\" or \"\r\" character(s)",
3086 url);
3087 oidc_error(r, "%s", error_description);
3088 return oidc_util_html_send_error(r, c->error_template,
3089 "Invalid Request", error_description,
3090 HTTP_INTERNAL_SERVER_ERROR);
3091 }
3057行目にてapr_uri_parse()
を使い URL をパースしています。変数url
には、Queryパラメーターで指定されたURLが入っています。uri
はパースされた結果がセットされる構造体です。
85 struct apr_uri_t {
87 char *scheme;
89 char *hostinfo;
91 char *user;
93 char *password;
95 char *hostname;
97 char *port_str;
99 char *path;
101 char *query;
103 char *fragment;
URLのパースに成功すると3068行目からのif文で パースされた結果の uri.hostname
の値をチェックしています。
許可されていないホスト名であれば 3076行目で return
となり、エラーが応答されることになります。
リダイレクト先として指定された URL のホスト名をチェックしており、任意のサーバーに遷移されないようになっています。
一見するとホスト名をチェックしており、問題無いようにも見えますね。
脆弱性の原因
それでは脆弱性となったアクセス方法を見てみましょう。mod_auth_mellon で情報が公開されていますが、以下のようなアクセスを行うと任意のURLへリダイレクト可能です。
https://rp.example.co.jp/example/redirect_uri?logout=https:%5c%5cphishing.example.com/logout.html
今回の脆弱性は、URL のパース関数 apr_uri_parse()
がキモです。
上記のURLへアクセスするとパース関数が解析する変数 url
は https:\\phishing.example.com/logout.html
となります。
スキームとホスト名の間が:\\
であり、「正しくないURL形式」ですが、これを apr_uri_parse()
が処理を行うと関数の戻り値は「成功」となります。「エラー」を返しません。
戻り値が「成功」となるため処理は継続されて、ホスト名のチェックのコードが動きます。
しかし、パース関数に与えた変数 url
は、「正しくないURL形式」でホスト名を認識出来ていないため、 パースの結果のuri.hostname
の値は NULL
となっています。
3068行目を見るとuri.hostname
が NULL
の場合ホスト名チェックが動きません。
その結果、URLのバリデーションチェックは「OK」となりエラーとならずに処理されて、最終的にブラウザに返るレスポンスヘッダーは以下のようになります。
Location: https:\\phishing.example.com/logout.html
このレスポンスを、ブラウザによってはphishing.example.com
にリダイレクトしてしまいます。
話をまとめると、以下のとおりです。
-
apr_uri_parse()
は、引数のURLに「正しくないURL形式」を渡しても戻り値がエラーとならない - その結果URLのバリデーションチェックをすり抜けてしまう
- ブラウザは「正しくないURL形式」を空気を読んで(?)リダイレクトしてしまうケースがある
修正版のソースコード
それでは修正版のソースコードを見てみましょう。
3040 if ((uri.hostname != NULL)
3041 && ((strstr(c_host, uri.hostname) == NULL)
3042 || (strstr(uri.hostname, c_host) == NULL))) {
3043 *err_str = apr_pstrdup(r->pool, "Invalid Request");
3044 *err_desc =
3045 apr_psprintf(r->pool,
3046 "logout value \"%s\" does not match the hostname of the current request \"%s\"",
3047 apr_uri_unparse(r->pool, &uri, 0), c_host);
3048 oidc_error(r, "%s: %s", *err_str, *err_desc);
3049 return FALSE;
3050 } else if ((uri.hostname == NULL) && (strstr(url, "/") != url)) {
3051 *err_str = apr_pstrdup(r->pool, "Malformed URL");
3052 *err_desc =
3053 apr_psprintf(r->pool,
3054 "No hostname was parsed and it does not seem to be relative, i.e starting with '/': %s",
3055 url);
3056 oidc_error(r, "%s: %s", *err_str, *err_desc);
3057 return FALSE;
3058 } else if ((uri.hostname == NULL) && (strstr(url, "//") == url)) {
3059 *err_str = apr_pstrdup(r->pool, "Malformed URL");
3060 *err_desc =
3061 apr_psprintf(r->pool,
3062 "No hostname was parsed and starting with '//': %s",
3063 url);
3064 oidc_error(r, "%s: %s", *err_str, *err_desc);
3065 return FALSE;
3066 }
3050行目で uri.hostname
の値がNULL
で、Queryパラメーターが /
で始まらなければエラーとしています。先程紹介した攻撃は、この3050行目で防ぐことが出来るようになっています。
/
から始まる URL を許可しているのはQueryパラメーターに「/ から始まる絶対パスのURL」を指定した場合を考慮しています。
- 「/から始まる絶対パスのURL」の例
https://rp.example.co.jp/example/redirect_uri?logout=/logout.html
3050行目の処理の追加により、「相対パスのURL」はエラーとなってしまいます。修正後より今までOKであった記述がNGになってしまいますが、これは致し方ないとmod_auth_opendicコミュニティでは判断したようです。
//で始まるURL
3058行目で uri.hostname
の値が NULL
で、かつ Queryパラメーターが //
で始まるアクセスをエラーとしています。これは何を意味するかを順を追って説明します。
まずURLして、以下の書き方が出来るというのは皆様ご存知でしょうか。
<a href="//www.osstech.co.jp/index.html">OSSTechへ</a>
このリンクは、[今アクセスしているスキーム]://www.osstech.co.jp/index.html
へ遷移するリンクです。(昔はこのような形式を意識したことがなかったので、この記述方法を知った時は驚きました。)
それでは apr_uri_parse()
の引数urlに //phishing.example.com/logout.html
を渡した場合どうなるのでしょうか。結果としては uri.hostname
の値は phishing.example.com
になります。
apr_uri_parse()
は「正しいURL形式」と認識してくれます。そのため修正前、修正後のどちらのコードもホスト名のチェック機能が働きます。
3058行目は、次のようなアクセスを防ぐために存在します。
https://rp.example.co.jp/example/redirect_uri?logout=///phishing.example.com/logout.html
/
が3つです。apr_uri_parse()
の引数urlに ///phishing.example.com/logout.html
を渡した場合は、uri.hostname
の値は NULL
になります。「正しくないURL形式」であるためです。
そしてブラウザに Location: ///phishing.example.com/logout.html
が返ると、これまた空気を読んでphishing.example.com
に遷移してしまいます。
ということで、3058行目のコードが存在するのです。
危ういバリデーションチェック
コードを見てもらうとこのバリデーションチェックは、拒否する条件を列挙したブラックリスト方式となっています。これでは新たな攻撃手法が見つかった場合に追加していく必要があります。この点は mod_auth_mellon コミュニティも認識しており、近い将来ホワイトリスト方式のチェックに変更する予定があるとのことです。
(2020/06/11 追記)
ホワイトリスト方式のチェックに対応したv2.4.3 がリリースされました。
https://github.com/zmartzone/mod_auth_openidc/releases/tag/v2.4.3
まとめ
今回の問題をまとめると以下のとおりです。
- URL のパース処理で「正しくないURL形式」にバリデーションチェックをOKとしてしまう
- ブラウザで「正しくないURL形式」でも空気を読んでリダイレクトしてしまう
ブラウザが空気を読んでしまうのも如何なものかと思いますが、そもそもサーバー側が「正しくないURL形式」を応答するな、ということだと思います。
リダイレクト先の URL が正しいかどうか?というチェックをする際にURLのパース処理を行っている場合、そのパース処理は「正しくないURL形式」をどのように処理するか今一度確認することをおすすめします。
URLのパース処理にどこまで期待しているのか、オープンリダイレクト以外の脆弱性の考慮も必要です。apr_uri_parse()
の場合は、改行コードが入っている URL
を引数に与えても戻り値はエラーとなりません。そのために mod_auth_openidc では個別にHTTPヘッダーインジェクションのチェックを行っていることが見て取れます。(修正前ソースコードの3082行目)
このようにURLのパース処理で行うことを正しく把握して使用しないと思わぬ脆弱性を埋め込んでしまうことになります。