mod_auth_openidc の脆弱性(CVE-2019-14857）について

先日 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 のバリデーションチェック部分のソースコードです。

``` C:mod_auth_openidc.c(v2.4.0)
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`はパースされた結果がセットされる構造体です。

``` apr_uri.h(抜粋)
   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形式」を空気を読んで（？）リダイレクトしてしまうケースがある


# 修正版のソースコード
それでは修正版のソースコードを見てみましょう。

``` C:mod_auth_openidc.c(v2.4.0.3)
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
```

`/` が３つです。`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

# まとめ
今回の問題をまとめると以下のとおりです。

1. URL のパース処理で「正しくないURL形式」にバリデーションチェックをOKとしてしまう
2. ブラウザで「正しくないURL形式」でも空気を読んでリダイレクトしてしまう

ブラウザが空気を読んでしまうのも如何なものかと思いますが、そもそもサーバー側が「正しくないURL形式」を応答するな、ということだと思います。
リダイレクト先の URL が正しいかどうか？というチェックをする際にURLのパース処理を行っている場合、そのパース処理は「正しくないURL形式」をどのように処理するか今一度確認することをおすすめします。

URLのパース処理にどこまで期待しているのか、オープンリダイレクト以外の脆弱性の考慮も必要です。`apr_uri_parse()`の場合は、改行コードが入っている `URL` を引数に与えても戻り値はエラーとなりません。そのために mod_auth_openidc では個別にHTTPヘッダーインジェクションのチェックを行っていることが見て取れます。(修正前ソースコードの3082行目)

このようにURLのパース処理で行うことを正しく把握して使用しないと思わぬ脆弱性を埋め込んでしまうことになります。