61
59

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Digital Identity技術勉強会 #iddanceAdvent Calendar 2024

Day 15

OAuth 2.0の認可エンドポイントにおける脆弱な実装例と対策について考える

Last updated at Posted at 2024-12-30

はじめに

今日のWebアプリケーション開発において、認証/認可の責務をOAuth 2.0 (以下OAuthとします) およびOpenID Connect 1.0 (以下OpenID Connectとします) に準拠したIdPへ任せる構成が採用されることは多々あります。しかし、標準仕様に則っていない実装や未定義部分の脆弱な実装により、IdP自体が様々な攻撃に対して脆弱になることも少なくありません。

そこで、本記事では認可サーバにおける脆弱な実装例とその対策に関する考察を紹介します。ただし、分量が多くなるため、対象のエンドポイントは認可エンドポイント、対象の認可グラントは認可コードグラントのみに絞ります

本記事の位置付け

一部の脆弱な実装例と攻撃、それに対する対策と考察を共有するための記事という位置付けです。

OAuthは拡張仕様が多数存在し、網羅的に全ての事項に触れるのは些か難しいです。そのため、本記事の内容を全て対処しても安全とは言い切れないということを念頭に置いて、お読みいただければ幸いです。

また、認可コードグラントで攻撃者は認可コードを窃取するだけでは直接の攻撃に繋がりません。なぜなら、OAuth ClientがConfidential Clientだった場合は後続のトークンリクエストでクライアント認証を突破する必要があり、OAuth Clientのタイプに関係なくPKCEが利用されている場合は、トークンリクエストに失敗するためです。

それでも、脅威として理解しておくことは為になると思うので、今回はスコープを絞って執筆することとします。他のエンドポイントや認可グラントに関しては、今後の記事で扱うトピックとして検討しています。

対象読者

以下のような方々を想定しています。

  • OAuthの認可コードグラントのフローを理解しているが、具体的な実装に疎い方
  • 認可サーバを実装してみたものの、攻撃手法や対策について興味のある方

OAuthの基本的な仕組みに関しては、本記事でも簡単に説明します。詳細に関しては、関連RFC等の一次情報を適宜参照してください。参考文献は末尾にまとめてあります。

本記事の構成

まず、認可エンドポイントの概要と実装例を提示します。一般的な内容なので、前提知識のある方はスキップしていただいて構いません。紹介するコード例では、理解を助けるためのコメントを付記します。

次に、認可エンドポイントの実装における脆弱な実装例、その説明、そして対策に関する考察を順番に紹介します。攻撃手法についても言及しますが、これはあくまで教育目的であり、攻撃を助長する意図は一切ありません。ここで提示した攻撃手法を許可されていないサーバー等に対して実行することは、違法となる可能性があり、絶対にしないでください。

また、説明のために、以下のドメインを使用します。これらは全て架空のドメインであり、実際にはアクセスできません。

目的 ドメイン
認可サーバ idp.task4233.dev
クライアントサーバ client.task4233.dev
リソースサーバ resource.task4233.dev
攻撃者のサーバ attacker.com

認可コードグラント

RFC 6749で定義されるOAuthの認可コードグラントでは、認可サーバの実装として、認可エンドポイントとトークンエンドポイントの2つが必要です。リクエストは大きく分けて認可リクエスト (Authorization Request) およびトークンリクエスト (Access Token Request) の2つに分けられます。

全体のシーケンス図は以下の通りです。

PlantUMLのソースコード
@startuml
@startuml
title 認可コードグラントにおけるシーケンス図

autonumber

actor RO as "リソースオーナー"
participant UA as "User-Agent"
participant C as "クライアント"
participant AS as "認可サーバ"
participant RS as "リソースサーバ"

RO -> C : クライアントにリクエスト
C -> UA : 認可リクエスト
UA -> AS : (略)
AS -> UA : 認証と承認
UA -> RO : (略)
RO -> UA : 認証情報を入力・承認
UA -> AS : (略)
AS -> UA : 認可コード(とstate)
UA -> C : (略)

C -> C : stateの検証(存在する場合)
C -> AS : アクセストークンリクエスト
AS -> C : アクセストークン

C -> RS : アクセストークンを用いた保護されたリソースへのリクエスト
RS -> C : 保護されたリソース

@enduml

まず、認可リクエスト (No.2~No.9) では、クライアントが認可サーバの認可エンドポイントに対してリクエストを行い、レスポンスの一部として認可コードを受け取ります。認可コードは、アクセストークンと交換するための短命のトークンです。その過程で、認可サーバはリソースオーナーに対して認証と認可の同意を要求します。ここで行われる認証については、OAuth自体が認可のフレームワークなので、この仕様については定義されていません。

次に、トークンリクエスト (No.10~No.12) では、クライアントは認可サーバのトークンエンドポイントに対して、取得した認可コードと共にリクエストを行い、レスポンスの一部としてアクセストークンを受け取ります。アクセストークンは、エンドユーザの代わりに保護されたリソースへアクセスするための許可を示すトークンです。

そして、トークンリクエストの前段で、認可リクエスト前にクライアント側で生成された state パラメータが認可レスポンスに含まれている場合、その検証を行います。state パラメータは、クライアントが生成するランダムな値であり、クライアント側で認可リクエストの前後のセッションが同一であることを確認するために利用されます。このパラメータは後述するCSRFの対策になります。

最後に、クライアントはリソースサーバの保護されたリソースに対して、取得したアクセストークンと共にリクエストを行い(No.13~No.14)、保護されたリソースのレスポンスを受け取ります。この際、リソースサーバは受け取ったアクセストークンの正当性を確認し、正当であればリクエストを受け入れ、そうでなければ拒否します。

今回注目するのは認可エンドポイントのみなので、No.9までのフローに限定します。

認可エンドポイントの実装例

このフローにおける認可サーバの実装例は以下の通りです。いくつかの処理は、後ほど脆弱な実装例を紹介するために敢えて関数やメソッドにしています。

認可エンドポイントのハンドラ実装例
// 認可エンドポイントのハンドラ実装
func (s *AuthorizationServer) Authorize(w http.ResponseWriter, r *http.Request) {
    // リクエストパラメータのパースと他の情報を必要としないバリデーション
	req := parseAuthorizeRequest(r)
	err := req.Validate()
	if err != nil {
		handleError(w, r, invalidRequestErr(err))
		return
	}
	
	var authSession *model.AuthSession
	var client *model.Client
	txErr := func() error {
		// トランザクションの開始
		tx, err := s.db.BeginTx(ctx)
		if err != nil {
			return err
		}
		defer tx.Rollback()
		
		// OAuth Clientの取得
		client, err = s.clientRepo.Get(ctx, tx, req.cliendID)
		if err != nil {
			return err
		}
		
		// OAuth Clientの情報を必要とするバリデーション
		err = s.validateAuthorizeRequestWithClient(req, client)
		if err != nil {
		  return newInvalidRequestErr(err)
		}
		
		// Authorization Code Grantのためのセッション作成と永続化
		authSession = model.NewAuthSession(req)
		err = h.authSessionRepo.Insert(ctx, tx, authSession)
		if err := nil {
			return err
		}
		
		// トランザクションのコミット
		err = tx.Commit()
		if err != nil {
			return err
		}
		
		return nil
	}()
	if txErr != nil {
		handleError(w, r, txErr)
		return
	}
	
	// CookieにauthSessionの情報をセット
	// このセッションの持ち方は飽くまで一例
	http.SetCookie(w, createAuthSessionCookie(authSession))
	
	// ユーザに返却する認可の同意を求める画面の構築
	err = constructApprovePage(w, client, req)
	if err != nil {
		handleError(w, r, err)
		return
	}
}

また、ユーザが認可を同意した後の情報をハンドルするエンドポイントは以下の通りです。 仕様には具体的に名前が書かれていないので、 Approve エンドポイントと名付けています。

ユーザの認可同意を処理するハンドラ実装
// ユーザの認可同意を処理するハンドラ実装
func (s *AuthorizationServer) Approve(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

    // CookieからauthSessionのための情報を取得
	sessionID, err := r.Cookie(authSessionKey)
	if err != nil {
		handleError(w, r, invalidRequestErr(err))
		return
	}

    // リクエストパラメータのパースと他の情報を必要としないバリデーション
	req := parseApproveRequest(r)
	err = req.validate()
	if err != nil {
		handleError(w, r, invalidRequestErr(err))
		return
	}

	now := time.Now()

	var authCode *model.AuthCode
	var authSession *model.AuthSession
	var user *model.User
	errResp := func() *errorResponse {
        // トランザクションの開始
		tx, err := s.db.BeginTx(ctx)
		if err != nil {
		    return err
		}
		defer tx.Rollback()

        // OAuth Clientの取得
		authSession, err = s.authSessionRepo.Get(ctx, tx, sessionID.Value)
		if err != nil {
            return err
		}

        // userの取得
		user, err = s.userRepo.Get(ctx, tx, req.ID)
		if err != nil {
            return err
		}

        // リクエストに含まれるユーザクレデンシャルの認証
		errResp := s.authenticate(req, user, authSession.State)
		if errResp != nil {
			return errResp
		}

        // 認可コードの作成と永続化
		authCode = model.NewAuthCode(authSession, user)
		err = s.authCodeRepo.Insert(ctx, tx, authCode)
		if err != nil {
            return err
		}

        // トランザクションのコミット
		err = tx.Commit()
        if err != nil {
            return err
		}

		return nil
	}()
	if errResp != nil {
		generateErrorResponseURLEncoded(w, r, authSession.RedirectURI, errResp)
		return
	}

    // リダイレクト用のパラメータ構築
	u := url.Values{}
	if authSession.State != "" {
		u.Set("state", authSession.State)
	}
	u.Set("code", authCode.Code)
	
    // リダイレクト
    http.Redirect(w, r, constructRedirectURI(authSession.RedirectURI, u), http.StatusFound)
}

脆弱な実装例と攻撃手法

認可エンドポイントにおける脆弱な実装例と攻撃手法について紹介した上で、その対策に関する考察について紹介します。

本記事で扱う脅威と扱わない脅威

脆弱な実装例を提示したいため、認可サーバの認可エンドポイントのアプリケーション実装によって生じる脅威を扱います

逆に、以下のようなアプリケーション実装以外に起因する脅威は扱いません。

  • 暗号化されていないHTTP通信のMITMによる、リクエストボディに含まれるクレデンシャルの漏洩
  • DNSやARPのなりすまし (Spoofing) に起因するフィッシング、セッションのなりすまし
  • ユーザの誤操作・知識不足による、想定外のユーザの認可同意
  • 認可サーバ以外からのクレデンシャル漏洩によるクライアントのなりすまし
  • 認可リクエストを送る前のクライアントページにおけるクリックジャッキングやXSS等の脆弱性を用いられる攻撃、スクリプトの埋め込みによるリソースオーナーのなりすまし1
  • http:// に大量のリクエストを投げ、 https:// へ大量にリダイレクトさせることによるDoS

攻撃者のモデル

各攻撃における攻撃者のモデルとして、OAuth Security Best Current Practice (以下OAuth BCPとします) で紹介されているAttacker Modelから抜粋して以下の通り仮定します。

  • ネットワークの制御
    • 攻撃者は、ブラウザやサーバーを含む任意の数のネットワークエンドポイントを設定・操作できます
    • ただし、認可サーバーや正当なクライアントサーバーのインフラストラクチャを直接制御することはできません
  • 認可サーバーの利用
    • 攻撃者は、一般の利用者と同様に認可サーバーが提供する機能を利用できます
    • しかし、認可サーバーの内部データや設定に直接アクセスすることはできません
  • クライアントの操作
    • 攻撃者は、認可サーバーに登録された自身で制御するOAuth Client を操作できます
    • ただし、他の正当なクライアントを操作することはできません
  • ユーザーのリダイレクト
    • 攻撃者は、いつでもユーザーを誘き寄せ、攻撃者が選んだ任意のURIにブラウザを移動させることができます
    • しかし、ユーザーのブラウザを直接操作することはできません
  • ソースコードの閲覧
    • 攻撃者は、認可サーバのソースコードを直接閲覧することはできません
    • ただし、リクエストとレスポンスからロジックを推測する能力は持っていると仮定します

RedirectURIの検証不備

認可コードグラントのフローにおいて、RedirectURIの検証は完全一致を利用することがベストです。しかし、OAuth関連の脆弱性やレポートを調査していると、そうでない様々な実装例が攻撃の起点となっているケースが多々ありました。

そこで、ここではサブセクションを用いて脆弱な実装例を紹介し、最後に総括して対策を紹介します。

実装例の抜粋

実装例として、正規表現を利用する場合は、以下の実装例を利用します。

func (s *AuthorizationServer) validateAuthorizeRequestWithClient(req *authorizeRequest, client model.Client) error {
    for _, c := range client.RedirectURIs {
        // 正規表現のケース
        // NOTE: 遅いので、実際の実装では、*Regexpをキャッシュしておくと良さそう
        re := regexp.MustCompile(c)
        if re.MatchString(req.redirectURI) {
            return nil
        }
    }
    
    return errors.New("invalid redirect_uri")
}

前方一致を利用する場合は、以下の実装例を利用します。

func (s *AuthorizationServer) validateAuthorizeRequestWithClient(req *authorizeRequest, client model.Client) error {
    for _, c := range client.RedirectURIs {
        // 前方一致のケース
        if strings.HasPrefix(req.redirectURI, c) {
            return nil
        }
    }
    
    return errors.New("invalid redirect_uri")
}

任意のサブドメインを許容する正規表現における検証のバイパス

正規表現が利用できる場合、OAuth ClientのRedirectURIsとしてサブドメインをまとめて登録するケースがありそうです。

例えば、サブドメインを許容する正規表現 ['https://*.task4233.dev/*'] が登録されていると仮定しましょう。この場合、https://attacker.com/.task4233.dev/ のようなRedirectURIがリクエストされた場合、検証が通ってバイパスが成功します。そして、このURIのドメインは attacker.com なので、攻撃者が管理するサーバへリダイレクトされます。

任意のパスを許容する正規表現における検証のバイパス

サブドメインを許容せず、任意のパスを許容する正規表現 ['https://client.task4233.dev/*'] が登録されていると仮定しましょう。

この場合でも、いくつかのバイパス手法が存在します。

まず、 攻撃者が clientatask4233.devclientbtask4233.dev のようなドメインを取得して、Redirect URIに設定してリクエストするケースです。正規表現では、 . がエスケープされていない場合、. は任意の1文字にマッチするメタ文字として解釈されます。そのため、上記のドメインも検証が通ってバイパスが成功し、同様にして攻撃者が管理するサーバへリダイレクトされます。このケースは、CVE-2024-52289として報告されていました。

次に、URLエンコードされた文字種である %08 (backspace)や %09 (tab)、 %2F (/) 等を含むRedirect URIを設定してリクエストするケース。これは上記の関数に渡される前後の実装にも依存しますが、解釈されるパス・ドメインが変更される可能性があります。その結果、想定していないパスや攻撃者が管理するサーバへのリダイレクトに悪用される可能性があります。実際に、Bug BountyでバイパスからAccount Take Over(ATO)まで持っていったケースもありました。

任意のパスを許容する文字列の前方一致における検証のバイパス

サブドメインを許容せず、 任意のパスを許容する前方一致、 ['https://client.task4233.dev/'] が登録されていると仮定しましょう。

この場合も、いくつかのバイパス手法が存在します。

まず、任意のパスを許容する正規表現における検証のバイパスでも言及した、URLエンコードされた文字種を利用するケース。これも正規表現と同様に、前方一致では防ぐことが出来ません。

次に、同じクエリパラメータを複数送ることで、Webアプリケーションの挙動を変えるようなHTTP Parameter Pollution(HPP)を利用するケース。これは、OAuth 2.0 Redirect URI Validation Falls Short, Literallyで紹介された攻撃手法です。本質としては、 redirect_uri の末尾に code クエリパラメータを紛れ込ませることで、 code を任意の値に設定する攻撃手法です。

具体的に、以下のような redirect_uri を含む認可リクエストを考えます。

curl -G "https://idp.task4233.dev/authorize" \
    -d "response_type=code" \
    -d "client_id=${CLIENT_ID}" \
	-d "scope='profile%3Aread'" \
    -d "state=$(uuidgen)" \
    -d "redirect_uri='https://client.task4233.dev/auth/callback%3Fcode%3D${EMBED_AUTH_CODE}%26"

この場合、 redirect_uri は前方一致に成功するので、検証は当然成功します。しかし、認可リクエストが成功して認可コードを含むレスポンスを構成する際に、次のような処理が行われていたと仮定します。

func constructRedirectURI(redirectURI string, u url.Values) string {
    return redirectURI+"?"+u.Encode()
}

この場合、Goの実装ではクエリパラメータが複数来る場合、1つ目のパラメータが利用されます。その結果、以下の通り code を任意の値に設定することが出来ます。

PoC
package main

import (
	"net/url"
	"testing"
)

func TestConstructedRedirectURI(t *testing.T) {
	const (
		vulnRedirectURI = "https://client.task4233.dev/auth/callback%3Fcode%3Dcompromised_auth_code%26"
		actualCode      = "actual_code"
		actualState     = "actual_state"

		compromisedCode = "compromised_code"
	)

	type args struct {
		redirectURI string
		code        string
		state       string
	}
	type want struct {
		code  string
		state string
	}
	cases := map[string]struct {
		args
		want
	}{
		"ok: with state": {
			args: args{redirectURI: vulnRedirectURI, code: actualCode, state: actualState},
			want: want{code: actualCode, state: actualState},
		},
	}

	for name, tt := range cases {
		t.Run(name, func(t *testing.T) {
			uv := url.Values{}
			uv.Set("code", actualCode)
			uv.Set("state", actualState)
			redirectURI := constructRedirectURI(tt.args.redirectURI, uv)

			// リダイレクトされる時にunescapeされると仮定
			unescapedRedirectURI, err := url.QueryUnescape(redirectURI)
			if err != nil {
				t.Fatalf("unexpected failure in url.QueryUnescape: %s", err.Error())
			}
			u, err := url.Parse(unescapedRedirectURI)
			if err != nil {
				t.Fatalf("unexpected failure in url.Parse: %s", err.Error())
			}

			// codeの検証
			if want, got := tt.want.code, u.Query().Get("code"); want != got {
				t.Errorf("compromised: code, want: %s, got: %s", want, got)
			}
		})
	}
}

func constructRedirectURI(redirectURI string, u url.Values) string {
	return redirectURI + "?" + u.Encode()
}

これを実行すると、以下の通りcodeが上書きされることが分かります。

$ go test opp_test.go
(snip)
=== RUN   TestConstructedRedirectURI
=== RUN   TestConstructedRedirectURI/ok:_with_state
    prog_test.go:59: compromised: code, want: actual_code, got: compromised_auth_code
=== RUN   TestConstructedRedirectURI/ok:_without_state
--- FAIL: TestConstructedRedirectURI (0.00s)
    --- FAIL: TestConstructedRedirectURI/ok:_with_state (0.00s)
    --- PASS: TestConstructedRedirectURI/ok:_without_state (0.00s)
FAIL

これにより、攻撃者は自身の認可コードを、被害者に対して意図せず利用させることが出来、ユーザと攻撃者の間に意図しないリンクをさせることが出来ます。

エラーレスポンスを悪用した攻撃者のサイトへのリダイレクト

エラーレスポンスにおいてもRedirectURIに関する脅威が存在します。

認可エンドポイントでエラーが発生した際のエラーレスポンスに関して、RFC 6749のSection 4.1.2.1では、次のように定めています。

  • 以下の理由でリクエストが失敗した場合、認可サーバはリソースオーナーにエラーを通知すべきである(SHOULD)
    • 欠落したRedirectURI、無効なRedirectURI、RedirectURIの不一致
    • 存在しないClientID、不正なClientID
  • 不正なRedirectURIに対してUser-Agentを自動的にリダイレクトさせてはならない(MUST NOT)
  • 以下の理由でリクエストが失敗した場合、認可サーバは application/x-www-form-urlencoded フォーマットを用いて、RedirectURIのクエリコンポーネントにパラメータを付与してクライアントに返却する
    • リソースオーナーによる認可同意の拒否
    • 欠落したRedirectURI、無効なRedirectURI

ここで、エラーレスポンス生成における以下のような実装について考えます。こちらの実装では、OAuth ClientのRedirectURIのバリデーションの前に、clientID に基づくOAuth Clientのデータフェッチが行われていることが分かります。そして、 handleError 関数の中では、エラーレスポンスの作成とリダイレクトを行っています。

// 認可エンドポイントのハンドラ実装
func (s *AuthorizationServer) Authorize(w http.ResponseWriter, r *http.Request) {
    // (略)
	var client *model.Client
	txErr := func() error {
        // (略)

		// OAuth Clientの取得
		client, err = s.clientRepo.Get(ctx, tx, req.cliendID)
		if err != nil {
			return err
		}
		
		// OAuth Clientの情報を必要とするバリデーション
		err = s.validateAuthorizeRequestWithClient(req, client)
		if err != nil {
		  return newInvalidRequestErr(err)
		}

  		// (略)
	}()
	if txErr != nil {
		handleError(w, r, txErr)
		return
	}
 	// (略)
}

func handleError(w http.ResponseWriter, r *http.Request, authErr *errAuth) {
	// エラーレスポンスの作成
	u := url.Values{}
	u.Set("error", authErr.Error)
	u.Set("error_description", authErr.ErrorDescription)
	if errResp.State != "" {
		u.Set("state", errResp.State)
	}

	// リダイレクト
	redirectURI := r.Query().Get("redirect_uri") // このRedirectURIはリクエストに含まれるので、任意の値をInjectできる
	http.Redirect(w, r, redirectURI+"?"+u.Encode(), http.StatusFound)
}

ここで注目したいのが、RedirectURIの検証がhandleError関数内で行われていないことと、OAuth ClientのフェッチがRedirectURIのバリデーションの前に行われていることです。したがって、攻撃者が不正なClientIDを認可リクエストに付与した場合、RedirectURIが検証される前にリダイレクトが発生します。

例えば、以下のように不正なclient_idと攻撃者のページに対するRedirectURIを設定するようなリクエストを送ることで、認可サーバが https://attacker.com/vulnpage にリダイレクトします。これにより、攻撃者はフィッシングサイトにユーザを誘導することが可能です。

curl -G "https://idp.task4233.dev/authorize" \
    -d "response_type=code" \
    -d "client_id=hoge" \
    -d "state=$(uuidgen)" \
    -d "redirect_uri='https://attacker.com/vulnpage"

実際に、こちらの記事によると、リソースオーナーが認可同意を拒否すると攻撃者のURIにリダイレクトされるケースがありました。

併せて、記事内では対策として、次の方法が書かれています。

  • 対策
    • エラーレスポンスをIdPのドメインにリダイレクトすること
  • 緩和策
    • ユーザが現在のアプリケーションから離れることを明確に警告すること
    • ユーザを自動的にリダイレクトする前に長い遅延を設けること
    • リダイレクトの前にユーザへリンクを直接クリックさせること

これらの対策について、仮にRedirectURIがOAuth Clientに登録されているRedirectURIsの要件を満たしている場合、そのURIへリダイレクトするのは問題ないのだろうかと思いました。

対策として言及されているIdPのドメインへのリダイレクトは安全なのですが、エラーが発生した際にIdP側のエラー画面が表示されるとUX的には微妙かなと思っています。なぜなら、表示されるエラーはIdP側のエラーであり、ユーザはその後の操作を続けられなくなる可能性が高いと考えているためです。

そもそも、通常のフローでエラーが発生するのはクライアント側の実装が悪いので、そこまでカバーする必要がないと言われたらそれはそうなのですが、認可リクエストでエラーが発生した際に毎回IdP側で完結させるのは、実際どうなのだろうなと思いました。

この辺りも知見のある方がいれば教えてください。

任意のRedirectURIsの設定を悪用したReDoS

認可サーバが、RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol (以下DCRとします) に対応している場合を考えます。この場合、攻撃者は任意の文字列をOAuth ClientのRedirectURIsとして設定できます。

この機能は便利ですが、攻撃者が悪意のある正規表現を登録することで、Regular Expression Denial of Service (ReDoS) 攻撃に脆弱になる可能性があります。その結果、認可サーバーが応答を停止し、サービス停止状態に陥る可能性があります。

Goの標準パッケージであるregexpでは、入力のサイズに対して線形時間で実行することが保証されているため、一般的なReDoSには脆弱ではありません。

今回は、ReDoSに脆弱である以下のようなJavaScriptでの実装を見てましょう。

function validateAuthorizeRequestWithClient(req, client) {
    // client.RedirectURIsが配列として与えられていると仮定
    for (let i = 0; i < client.RedirectURIs.length; i++) {
        const pattern = client.RedirectURIs[i];
        // 正規表現を使ってチェック
        const regex = new RegExp(pattern);
        if (regex.test(req.redirectURI)) {
            return null; // 一致する場合は成功
        }
    }
    
    // 一致しない場合はエラーメッセージを返す
    return new Error("invalid redirect_uri");
}

例えば、RedirectURIsに正規表現 ['^https:\/\/attacker.com\/((([a-zA-Z0-9\/])+)+)+$'] が設定されていると仮定しましょう。この場合、認可リクエストに https://attacker.com/auth/callbackaaaaaaaaaaaaaaaaaaaaa%25 というリクエストを送ると、大量のバックトラックが発生します。

ReDoSに関しては、以下の記事が分かりやすかったので、添付しておきます。

検証不備を悪用したXSSのSourceとしての利用

そもそも検証を行っていない場合、XSSのSourceとして利用される場合もありました。
例えば、 javascript:// で始まるURIスキーム、 data://, のようなdata URIスキームを設定されている場合で、以下の通り報告された例がありました。これにより、RedirectURIに直接JavaScriptコードを埋め込むことができます。

対策

RedirectURIsの検証に関しては、完全一致をするのが一番簡単な対策かと思います。そうすることで、HPP含め上記で説明した殆どの脅威の対策となります。

しかし、実装や運用形態によってはパターンマッチを利用したい場合があるのも理解できますが、それにより関連攻撃が野放しにされているのが観測されていることも理解した上で、トレードオフを考慮すべきだと思います。

例外として、ネイティブアプリケーションにおけるループバックアドレスは任意のポート番号を許容する必要があります。以下のドキュメントでは、ファイアウォールの構成やネットワークインタフェースの変更によってアプリケーションの動作不能を防ぐために、 localhost ではなく 127.0.0.1 のループバックアドレスが推奨されていると書かれています。

また、DCRを悪用したReDoS等の攻撃は、実装依存な部分も多いので、実装的に問題がないか確認することが肝要かと思います。また、緩和策としてタイムアウトを設けることも出来ます。

最後に、実装例として、OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語るで紹介されていた仮想コードが非常に参考になったので、良ければご参照ください。

Redirect URIに対するCross-Site Request Forgery(CSRF)を悪用したユーザ間のセッション紐付け

一般に、CSRF攻撃は、攻撃者がユーザのブラウザを利用して、ユーザが意図しないリクエストを悪意を持って送信させる攻撃です。これにより、送金やパスワード変更といった、ユーザの認証情報を利用した不正な操作を行わせることが目的です。

しかし、OAuthにおけるCSRF攻撃の1つとして、Redirect URIに対して行われるものがあります。

OAuthにおけるCSRFの攻撃フローの一例

攻撃フローは次のとおりです。

PlantUMLのソースコード
@startuml
title Redirect URIに対するCSRFを悪用したユーザ間のセッション紐付けを行うシーケンス図

autonumber

actor 攻撃者 as Attacker
participant "User-Agent\n(攻撃者)" as AUA
actor 被害者 as Victim
participant "User-Agent\n(被害者)" as CUA
participant クライアント as C
participant 認可サーバ as AS
participant リソースサーバ as RS

Attacker -> C: 自身の認可リクエスト
C -> AUA: 攻撃者の認可リクエスト
AUA -> AS: (略)
AS -> AUA: 認証と承認
AUA -> Attacker: (略)
Attacker -> AUA: 認証情報を入力・承認
AUA -> AS: (略)
AS -> AUA: 認可コード(とstate)
note right of AUA: ここで追加処理を停止する
Attacker -> Victim: 被害者にリダイレクトリクエストを実行させる
Victim -> CUA: 被害者がリダイレクトリクエストを実行
CUA -> C: (略)
C -> C: stateの検証 (正しく検証されていれば失敗)
C -> AS: 被害者によるアクセストークンリクエスト
AS -> C: アクセストークン
C -> RS: 攻撃者の保護されたリソースへのリクエスト
RS -> C: レスポンス

@enduml

ここで、クライアント上の被害者のセッションと、そのトークンでアクセスできる攻撃者のリソースが紐づいてしまいます。ここで、仮に認可された操作がアップロード等の処理だったと仮定すると、プライベートなデータを被害者が意図せずに、攻撃者に対して漏洩してしまう可能性があります。

また、OAuthを3rd partyのログインに利用している場合、クライアント上の被害者のアカウントが外部IdPの攻撃者のアカウントと紐付けられてしまうので、攻撃者が別デバイス上で被害者としてクライアントにログインできるようになる脅威もあります。

OAuthにおけるCSRF対策

対策としては、OAuth BCPのSection 4.7.1で言及されている通り、クライアント側で出来る検証が2つ、サーバ側で出来る検証が1つあります。

仕組み state nonce PKCE
元来の防ぎたい攻撃 login CSRFによる不正なsession linkage OpenID Connectにおけるリプレイ攻撃 Public Clientにおける認可コードの横取り攻撃
検証するアクター クライアント クライアント 認可サーバ
検証される場所 認可レスポンスを受け取った後 IDトークンを含むトークンレスポンスを受け取った後 アクセストークンリクエストの前/アクセストークンリクエストの処理中

対策1: stateパラメータの活用

まず、クライアント側で出来る1つ目の検証として、冒頭で紹介した state パラメータがあります。

この目的は、認可エンドポイント処理前後のセッションが同じことを保証することです。クライアントは認可リクエストの前に暗号学的に安全な乱数生成器を使用して作成した state パラメータを認可リクエストに含めます。その後、トークンリクエストの前に、認可レスポンスに含まれる state 値が一致するかを検証します。これにより、今回のように被害者が攻撃者の認可コードを用いてトークンリクエストをする前に、処理を防止することが出来ます。

前述した攻撃フローにおいては、下記の通りNo.14で検証に失敗するので、CSRFを防ぐことが出来ます。

PlantUMLのソースコード
@startuml
title stateパラメータでCSRFを防ぐ場合のシーケンス図

autonumber

actor 攻撃者 as Attacker
participant "User-Agent\n(攻撃者)" as AUA
actor 被害者 as Victim
participant "User-Agent\n(被害者)" as CUA
participant クライアント as C
participant 認可サーバ as AS
participant リソースサーバ as RS

Attacker -> C: 自身の認可リクエスト
group #add8ff stateパラメータによる管理範囲

C -> C: stateパラメータの生成
C -> AUA: 攻撃者の認可リクエスト
AUA -> AS: 攻撃者の認可リクエスト with state
AS -> AS: stateパラメータの保存
AS -> AUA: 認証と承認
AUA -> Attacker: (略)
Attacker -> AUA: 認証情報を入力・承認
AUA -> AS: (略)
AS -> AUA: 認可コードとstate
note right of AUA: ここで追加処理を停止する
Attacker -> Victim: 被害者にリダイレクトリクエストを実行させる
Victim -> CUA: 被害者がリダイレクトリクエストを実行
CUA -> C: (略)
C -> C: stateの検証
note right of C: ここで検証に失敗するため、後続の処理を防げる
end
C -> AS: 被害者によるアクセストークンリクエスト
AS -> C: アクセストークン
C -> RS: 攻撃者の保護されたリソースへのリクエスト
RS -> C: レスポンス

@enduml

しかし、こちらの記事によると、他のIdPと連携する場合において、連携対象のIdPが攻撃者に侵害されている場合もあるようです。この場合の対策は、記事で言及されている通り、 state をリクエスト先のIdPとも紐づけて検証することだそうです。

対策2: OpenID Connectのnonceパラメータの利用

次に、OpenID Connectに対応できるのであれば、クライアント側で出来る2つ目の検証として nonce パラメータが利用できます(ref)。

この元来の目的は、リプレイ攻撃を防ぐことですが、 state と同様にCSRF対策にもなります。クライアントは認可リクエストの前に暗号学的に安全な乱数生成器を使用して作成した nonce パラメータを認可リクエストに含めます。その後、トークンレスポンスに含まれるIDトークンの nonce claimが一致するかを検証します。

前述した攻撃フローにおいては、下記の通りNo.17で失敗するので、CSRFを防ぐことが出来ます。

PlantUMLのソースコード
@startuml
title nonceパラメータでCSRFを防ぐ場合のシーケンス図

autonumber

actor 攻撃者 as Attacker
participant "User-Agent\n(攻撃者)" as AUA
actor 被害者 as Victim
participant "User-Agent\n(被害者)" as CUA
participant クライアント as C
participant 認可サーバ as AS
participant リソースサーバ as RS

Attacker -> C: 自身の認可リクエスト
group #add8ff nonceパラメータによる管理範囲

C -> C: nonceパラメータの生成
C -> AUA: 攻撃者の認可リクエスト
AUA -> AS: (略)
AS -> AUA: 認証と承認
AUA -> Attacker: (略)
Attacker -> AUA: 認証情報を入力・承認
AUA -> AS: (略)
AS -> AUA: 認可コード(とstate)
note right of AUA: ここで追加処理を停止する
Attacker -> Victim: 被害者にリダイレクトリクエストを実行させる
Victim -> CUA: 被害者がリダイレクトリクエストを実行
CUA -> C: (略)
C -> C: stateの検証(存在する場合)
C -> AS: 被害者によるアクセストークンリクエスト
AS -> C: アクセストークン, IDトークン
C -> C: IDトークンに含まれるnonceパラメータの検証
note right of C: ここで検証に失敗するため、後続の処理を防げる
end
C -> RS: 攻撃者の保護されたリソースへのリクエスト
RS -> C: レスポンス

@enduml

余談ですが、攻撃者は scope=openid を省略することでnonceパラメータの検証をバイパスできる場合があります。そのため、 nonce 検証を実装しているから state パラメータの検証は不要という考えは危険だと考えます。

対策3: PKCEの利用

最後に、サーバ側で出来る検証として、RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients、通称PKCEがあります。

PKCEでは、次の3つのパラメータが利用されます。

  • code_verifier
    • [A-Z], [a-z], [0-9], '-', '.', '_', '~' から成る43~128文字のランダムな文字列
  • code_challenge_method
    • plainまたはS256
  • code_challenge
    • code_challenge_methodplain の場合
      • code_challenge = code_verifier
    • code_challenge_methodS256 の場合
      • code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

全体のフローとしては、以下の通りです。

  1. 認可リクエストで code_challenge および code_challenge_method を認可サーバへ送る(No.3)
  2. トークンリクエストでcode_verifier を認可サーバへ送る(No.13)
  3. トークンリクエストの処理中に、 受け取った code_verifier から code_challenge_method に応じて認可サーバ側で code_challenge を再計算して、保持していた code_challengeと一致するか検証する(No.14)
PlantUMLのソースコード

この元来の目的は、Public Clientにおける認可コードの横取り攻撃を防ぐことです。しかし、この手法は上記の2手法と異なりサーバ側で検証するため、Public Clientに限らず効果的な手法であり、Draft(12) - The OAuth 2.1 Authorization FrameworkのSection 1.8では必須と言及されています。

前述した攻撃フローにおいては、下記の通りNo.14とNo.15の間でセッションに紐づく code_verifier が見つからずにエラーが発生します。仮にエラーが発生しなかったとしてもNo.16で失敗します。したがって、いずれのケースにおいても、CSRFを防ぐことが出来ます。

PlantUMLのソースコード
@startuml
title PKCEでCSRFを防ぐ場合のシーケンス図

autonumber

actor 攻撃者 as Attacker
participant "User-Agent\n(攻撃者)" as AUA
actor 被害者 as Victim
participant "User-Agent\n(被害者)" as CUA
participant クライアント as C
participant 認可サーバ as AS
participant リソースサーバ as RS

Attacker -> C: 自身の認可リクエスト
group #add8ff nonceパラメータによる管理範囲

note right of C: code_challenge_method = S256 を仮定しています
note right of C: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`
C -> C: 攻撃者のcode_verifierとcode_challengeの生成
C -> AUA: 攻撃者の認可リクエスト
AUA -> AS: 攻撃者の認可リクエスト
AS -> AS: code_challenge, code_challenge_methodの保存
AS -> AUA: 認証と承認
AUA -> Attacker: (略)
Attacker -> AUA: 認証情報を入力・承認
AUA -> AS: (略)
AS -> AUA: 認可コード(とstate)
note right of AUA: ここで追加処理を停止する
Attacker -> Victim: 被害者にリダイレクトリクエストを実行させる
Victim -> CUA: 被害者がリダイレクトリクエストを実行
CUA -> C: (略)
C -> C: stateの検証(存在する場合)
note right of C: ここで、セッションに紐づくcode_verifierが存在しないのでエラーが発生する
C -> AS: 被害者によるアクセストークンリクエスト without code_verifier
AS -> AS: code_challengeとBASE64URL-ENCODE(SHA256(ASCII(code_verifier)))が一致するか検証
note right of AS: ここで検証に失敗するため、後続の処理を防げる
end
AS -> C: アクセストークン, IDトークン
C -> C: IDトークンに含まれるnonceパラメータの検証
C -> RS: 攻撃者の保護されたリソースへのリクエスト
RS -> C: レスポンス

@enduml

ただし、PKCEの検証においてPKCE downgrade attackと呼ばれる検証をバイパスする手法もあるので注意が必要です。こちらの脆弱性は、CVE-2024-22258で報告されています。

総括

私は、認可サーバがクライアントに検証を強制できる効力があり、サーバ側で検証される点でPKCEが他の2手法よりも強力で好ましいと思います。

認可コードの推測

RFC 6749のSection 10.5では、認可コードに次のような前提を置いています。

  • 短命で1回限り有効なコード
  • 認可サーバは認可コードが容易に推測または予測できないように生成されることを保証しなければならない(MUST)

しかし、実装によっては、これらを満たさないケースも存在しているので、一応紹介します。

例えば、認可コードの生成部分が以下のようにエントロピーが比較的小さい実装の場合を考えます。

func NewAuthCode(authSession *model.AuthSession, user *model.User) *model.AuthCode {
    return &model.AuthCode {
        Code: generateRandomNumber(authCodeLen), // ${authCodeLen}桁のランダムな数字列を生成
        ClientID:    authSession.ClientID,
		UserID:      user.ID,
		Scope:       authSession.Scope,
		RedirectURI: authSession.RedirectURI,
		ExpiresAt:   now.Add(authCodeDuration),
    }
}

この場合、authCodeLen を6と仮定すると、認可コードの種類は高々 $10^6$ しかなく、ブルートフォースが現実的に可能です2

そして、認可コードはUniqueである必要があるため、このブルートフォースによりDoSが引き起こされる側面もあります。なぜなら、攻撃者は認可コードのプールを枯渇させて、認可サーバが提供する認可コードグラントのフローを妨害することが可能だからです。

また、次のようにDBの AUTO_INCREMENT 機能等に頼って認可コードを生成している場合、あるユーザによって正規に生成された認可コードから、他ユーザの認可コードが推測される可能性があります。

        // 認可コードの作成と永続化
		authCode = model.NewAuthCode(authSession, user)

        // このinsert時に、DB側のAUTO_INCREMENTでcodeが採番されるケース
        err = s.authCodeRepo.Insert(ctx, tx, authCode)
		if err != nil {
            return err
		}

この辺りのエントロピーの話は、OAuth ClientIDやClientSecret(Confidential Clientの場合)などにおいても考慮すべき問題です。

対策として、RFC 6819では以下の対策が書かれています。

  • 認可コードを高いエントロピーにする
  • assertionベースのトークンはハッシュベースのメッセージ認証コードやデジタル署名等の署名を付与する
  • クライアント認証を行う
  • 認可コード発行時に認可コードとリダイレクトURIの紐付けをして、アクセストークン発行時にリダイレクト先が想定されたものか検証する
  • 認可コードの有効期間を短くする
    • 認可コードのブルートフォース対策に加え、リプレイ攻撃やトークン漏洩の脅威に対する対策としても機能します

これらの対策について、少し考えてみます。

まず乱数の強度について。RFC 4086が公開されたのは2005年なので、推奨事項が変化していないかと思いましたが、2018年に公開された乱数生成におけるドキュメントNIST SP 800-90Aでも最低のビット数は128ビットと書かれていました。私が調べた限りでは後続の情報は見当たらなかったので、更新された情報があれば教えてください。

次に、認可コードとリダイレクトURIの紐付けによる検証について。これは認可コード漏洩後のアクセストークン漏洩を防ぐために機能します。認可コードの推測に関する攻撃においては、クライアント認証で十分かもしれませんが、後述する認可コードの不正な紐付けにおいても効果的なので、実装と運用の観点で可能なら検証しても良いと思いました。

そして、最後の有効期間を短命にする話について。これは、認可サーバにおけるセキュアさとUXを天秤にかける必要のある問題です。認可コードグラントにおいては、認可コードのリダイレクト後に速やかにトークンリクエストを実施するものかと思います。

しかし、現実はネットワーク、アプリケーションのレイテンシ、User-Agentのスペックなどの外的要因によって、その速やかなトークンリクエストが為されない可能性があるため、認可サーバの管理者はログやモニタなどから統計を収集し、適した有効期間を設定するのが良いかと思います。この辺り、結構エイヤで決めているIdPが多い気がしており、どのように決めているのか気になるところです。

参考までに、RFC 6749のSection 4.1.2では認可コードのライフタイムは最大10分と書かれています。しかし、私個人としては、そこまで長い処理も不要だと思うので1分程度が現実的なのかなと思っています。

SQL Injectionの脆弱性を悪用した認可コードの漏洩

例えば、clientIDに応じて登録されているOAuth Clientの取得部分に、SQL Injectionの脆弱性があった場合を考えます。

    // 略
    var client *model.Client
	txErr := func() error {
		// OAuth Clientの取得
		client, err = s.clientRepo.Get(ctx, tx, req.cliendID)
		if err != nil {
			return err
		}
        // 略
	}()
	if txErr != nil {
		handleError(w, r, txErr)
		return
	}
    // 略

この Get の内部実装において、以下のようにORMやPreparedStatement等を利用せずにSQLを文字列で組み立てている場合、SQL Injectionの脆弱性が存在する場合があります。

// Get メソッドは、`clientID` が一致するOAuth Client情報を返却するメソッド
func (r *clientRepo) Get(ctx context.Context, tx *sql.Tx, clientID string) (model.Client, error) {
    // 文字列でのクエリ構築
    // カラム設計はサンプル用
    query := fmt.Sprintf("SELECT id, secret_hash, scopes, redirect_uris, logo_url, name FROM clients WHERE id='%s'", clientID)

    // SQLを実行して、結果をConfidentialClientのstructにマップ
    var c confidentialClient
    err := tx.QueryRow(query, 1).Scan(&c.ID, &c.SecretHash, &c.Scopes, &c.RedirectURIs, &c.LogoURL &c.Name)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    // (略)
}

ここで、例えばclientIDに以下のような文字列を入力する3と、クライアント名のカラムに認可コードを埋め込むことが出来ます。このクライアント名は、後続の認可同意画面で表示されることがあるため、そこから認可コードが漏洩する可能性があります。

' UNION SELECT c.id, c.secret_hash, c.scopes, c.redirect_uris, c.logo_url, a.code FROM authorization_codes as a JOIN clients as c WHERE c.id='${CORRECT_CLIENT_ID}' AND a.expires_at >= ${TIMESTAMP} LIMIT 1; -- 

同様にして、テーブルの情報やカラム数、他テーブルの情報等も抽出することで、認可コードのみならず全てのテーブルの情報を抜き取られる可能性もあります。この辺りのテクニックの詳細は、PortSwigger - SQL injection UNION attacksの解説が分かりやすいかと思います。

対策として、RFC 6819のSection 4.3.4では以下の対策が書かれています。

  • アプリケーション内で利用されるデータベースユーザの権限を最小限にすること
  • 可能なら静的なSQLを利用すること
  • 動的SQLを利用する場合は、入力文字を結合する動的なSQLを避け、バインド引数によってクエリをパラメータ化すること
  • 入力文字列のフィルタとサニタイズを行うこと
  • Enforce Credential Storage Protection Best Practicesに従って、適切なクレデンシャルのハンドリングをすること

更に、ORMを利用することやPrepared Statementを利用することも有効な対策です。これらはライブラリ実装になると思うので、最新のバージョンを保つことが肝要です。

続いて、認可リクエストにおける client_id はクエリパラメータとして残るため、一般にリクエストログから異常なパラメータを検出することも出来そうです。何か問題が発覚した後の対策の取り方として、どのようなパラメータに対する脆弱な実装だったのかを考察する助けになるかと思います。

そして、そもそも認可コードは短命という性質があるので、メインのDBに保存せず、Redis等のインメモリDBに保持する方法は選択肢としてありそうです。攻撃者はブラックボックスの状態で攻撃を行うため、一般的なDBに対するSQL Injectionのペイロードが刺さらなくなるという副次的な効果も期待できそうです4

また、認可コードをJWTで持つケースもあるようです。認可コードの推測セクションでも紹介した通り、認可コードは短命で推測できないように生成されれば良いので、仕様的には問題ないように思います5。これにより、認可コード自体が有効期限等を持てるようになるなどのメリットがあります。

Refererヘッダを介したクレデンシャル漏洩

関連する実装は、以下の認可同意画面の構築部分です。

今回の実装例では、リクエストパラメータに含まれるスコープ等の情報、クライアント名およびロゴなどを表示するために、クライアント情報と認可リクエストの値を関数の引数として渡しています。

	// ユーザに返却する認可の同意を求める画面の構築
	err = constructApprovePage(w, client, req)
	if err != nil {
		handleError(w, r, err)
		return
	}

Goの実装では、template.HTML() 型の場合でなければ値がエスケープされてしまいますが、今回は説明のために全てエスケープされないと仮定します。

{{if .Scopes}}
<!-- クライアント名を表示 -->
<h4>Requested scopes with {{.ClientName}}</h4>
<ol>
  {{range $scope := .Scopes}}
  <li>{{$scope}}</li>
  {{end}}
</ol>
{{else}}
<h4>No scopes are requested with {{.ClientName}}</h4>
{{end}}

<!-- 認証のためにIDとパスワードをPOSTリクエストで送信する -->
<form method="post" action="/approve">
  <div class="form-group">
    <label for="id">Login ID</label>
    <input type="text" class="form-control" name="id" placeholder="Login ID">
  </div>
  <div class="form-group">
    <label for="password">Password</label>
    <input type="password" class="form-control" name="password" placeholder="Password">
  </div>
  <button type="submit" class="btn btn-primary" name="decision" value="approve">Approve</button>
  <button type="submit" class="btn btn-danger" name="decision" value="deny">Deny</button>
</form>

ここで、スコープ値が認可リクエストに含まれるため、HTMLをインジェクト出来ます。
これにより、CSPが設定されていない場合はXSSを発火させることが出来ますし、CSPが設定されている場合でもRefererヘッダを用いたクエリパラメータ( state等 )を漏洩させることが出来ます。これにより、CSRF対策が無効化され、更なる攻撃にチェインされる可能性があります。

このRefererヘッダを利用した漏洩は、過去にSECCON Beginners CTF 2023 - oooauthで出題されました

Refererヘッダの伝播

ユースケースによっては、認可サーバをソーシャルログイン等に利用している場合、呼び出し元のページに戻すためにRefererヘッダを利用する場合があります。ここで、リクエストで設定したRefererヘッダがレスポンスに反映される場合、最終的に攻撃者のサーバである attacker.com にレスポンスをリダイレクトされてしまいます。

OAuthクライアントに紐づく logo_uri の悪用

logo_uri が認可同意画面で設定される可能性があります。前述したDCRが可能な場合、これに外部URLを設定することでリンク先にクエリパラメータが漏洩する可能性があります。

Refererとは話が変わりますが、認可サーバのインターナルなIPアドレスを設定することで、SSRFのgadgetとして利用される可能性もあります。これを悪用したLabがPortSwiggerにあったので、そちらも良ければ参照してみてください。

対策

Refererヘッダを介したクレデンシャル漏洩に関して、OAuth BCPのSection 4.2.4には以下のように書かれています。

  • そもそも、認可応答の結果として、レンダリングされたページおよび認可エンドポイントには、3rd partyのリソースや外部サイトへのリンクを含めるべきではない(SHOULD NOT)
  • 適切なReferrer-Policyヘッダを適用してRefererヘッダを抑制すること
    • 例えば、応答に Referrer-Policy: no-referrer ヘッダーを設定すると、結果のドキュメントから送信されるすべてのリクエストで Referer ヘッダーが完全に抑制されます。
  • クライアント認証のあるConfidential Clientの利用、もしくはPKCE challengeと紐づけること
    • 仮にRefererヘッダによって認可コードの漏洩まで到達されたとしても、その後のトークンエンドポイントへのリクエストを失敗させられる
  • トークンエンドポイントで認可コードが利用されたら、認可サーバは認可コードを無効にしなければならない(MUST)
    • 攻撃者が正規のクライアントよりも先にトークンとコードの交換に成功した場合、この攻撃は軽減されない
    • コードの交換が2回施行された場合、認可サーバはそのコードに基づいて以前に発行された全てのトークンを取り消すべきである(MUST)
  • state valueは利用された後、clientによってstate値を無効にすべきである(SHOULD)
  • 認可レスポンスには、リダイレクトではなくOAuth 2.0 Form Post Response Modeを利用する

それに加えて、当たり前ですが、テンプレートエンジンで埋め込む場合は値をきちんとエスケープするべきだと思います。しかし、実装によりエスケープできない場合は、CSP等の別のセキュリティ機構を用いて対策を取る必要があります。

そして、Referrer-Policy に関しては、no-referrer を設定できない場合、正く値を設定する必要があります。 Referrer-Policy に関しては下記の記事が大変わかりやすくまとめられていました。

また、Referer ヘッダの伝播に関連して、 Referer ヘッダ以外にも攻撃者が操作可能なHTTPヘッダ (例えば、 X-Forwarded-Host など) を悪用したHTTPヘッダインジェクション攻撃が為される可能性もあります。このような攻撃に対しては、許容するヘッダをホワイトリスト管理して検証する対策などがとれます。

ブラウザ履歴/リクエストログを介したクレデンシャル漏洩

リダイレクトにおいて、下記の通りURLに認可コードを含みます。

func (s *AuthorizationServer) Approve(w http.ResponseWriter, r *http.Request) {
    // 略

    // 認可レスポンスのリダイレクト
    http.Redirect(w, r, constructRedirectURI(authSession.RedirectURI, u), http.StatusFound)
    // 略
}

func constructRedirectURI(redirectURI string, u url.Values) string {
    return redirectURI+"?"+u.Encode()
}

このようなアクセス履歴はブラウザに残ります。昨今のGoogle ChromeやFireFoxといったブラウザは履歴を端末間で共有する機能を有しているもの多いです。したがって、攻撃者がブラウザの履歴を閲覧できる状態にある場合、認可コードを窃取できる可能性があります。同様にして、攻撃者がWebサーバのリクエストログにアクセスできる場合も、同様にして情報がログ経由で漏洩する可能性もあります。

一応、今回は認可コードグラントを仮定しているので、クライアント認証でアクセストークン取得はできません。しかし、本記事の末尾で紹介しているようにImplicitグラントでは認可エンドポイントでアクセストークン取得が可能なので、脅威の1つではあると思います。

対策としては、前述の通りリダイレクトの代わりにOAuth 2.0 Form Post Response Modeを利用することも可能です。

このForm Post Response Mode、大分良さそうに見えるのですが、要件的に導入できないケースなどがあるのでしょうか?特に問題なければ導入されても良いのかなと思うものの、Draft(12) - The OAuth 2.1 Authorization Frameworkでは言及すらされていなかったので、何か理由があるのかなと思いました。単にOpenID Connectの拡張として提案されたからなのでしょうか?

詳しい方がいたら教えてください。

Mix-Up攻撃

この認可サーバ以外に、1つ以上やり取りする認可サーバが存在すると仮定した場合に生じる攻撃シナリオです。個人的には、下記の日本語の記事が理解の助けになるかと思いますので、リンクを載せておきます。

このMix-Up攻撃に関して、攻撃者が操作できる脆弱なIdPの存在を仮定しているので、シナリオとして追加のIdPへの攻撃に利用するのが少し現実的では無いように思います。一応、こちらのレポートのSection 5.3で、それぞれのサービスプロバイダごとにMix-Up攻撃について脆弱か調査した表が公開されていました。しかし、私が調べた限り、実際にこの攻撃に関して現実で起きたケースは見当たりませんでした。もしあれば教えてください。

余談

アプリケーションの機能で認可コードが漏洩するケース

個人的に読んでいて面白いなと思ったのが、Google Analyticsで持ち出すケースです。

こちらのレポートに書かれている通り、この攻撃は機能として提供されているGoogle Analyticsを利用して、リアルタイムで飛んできたリクエストを読み取っています。このようにして、通常の機能にも拘らず、持ち出しの方法として悪用するフローを考えつくのは面白いなと思いました。

補足: 想定されていないImplicit Grant Typeの利用

今回は認可コードグラントの実装に絞っているので、補足です。

というのも、IdPを導入する上でフルスクラッチではなく、既存のIDaaSやKeycloakのようなOSSを用いて、認可サーバをサービスに導入することも少なくありません。ここで、意図せずにImplicitグラントタイプを有効にしている場合、認可エンドポイントから直接アクセストークンが漏洩する場合もあります。

この場合、上記の攻撃手法に加えて response_type=token を設定するだけでアクセストークン取得まで攻撃が成功する場合があるため、注意が必要です。

おわりに

本記事では、OAuth 2.0のトピックとして、認可サーバの認可エンドポイント、かつ認可コードグラントのみに絞り、脆弱な実装例とその対策について紹介しました。トピックを絞ってもこれだけの分量になったことから、認可サーバの実装には様々な実装不備が起き得ることを想像しやすくなったのではないでしょうか。

本記事で提示した脆弱な実装例に対する攻撃は、概ね以下のドキュメント等で言及されていると思います。興味のある方は必要に応じて参照してください。

そして、本記事で提示した脆弱な実装は通常のWebアプリケーション開発にも存在し得るものだと思います。

"OAuthの" 脆弱な実装という捉え方ではなく、"Webアプリケーションにおける" 脆弱な実装という捉え方をして、日々のWebアプリケーション開発に活かしていただければ幸いです。

また、今回のように脅威を洗い出す脅威モデリングを実施したい気持ちがあるのですが、いかんせん経験がないので知見のある方がいれば参考になる資料等をコメントで教えていただければ幸いです。

非常に長い記事となってしまいましたが、最後までお読みいただきありがとうございました。少しでも参考になる部分があれば幸いです。

Reference

  1. ユーザの認可同意スキップも脅威にはなり得るのですが、外的要因が必要になるので今回は説明を省きます

  2. そもそも、認可コードが重複するので認可サーバとして機能しなさそうですが

  3. ${CORRECT_CLIENT_ID} および ${TIMESTAMP} は実値に読みかえてください

  4. 攻撃者はインメモリDBに対するNoSQL Injectionのためのペイロードも投げる可能性があるので、あまり意味はないかもしれない

  5. 推測できない、と言う部分にフォーマットの文脈が含まれていると解釈すると問題あると捉えることも出来そうです

61
59
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
61
59

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?