OAuth 2.0 認可コードの横取りを実際にやってみる
前回、前々回とOAuth2.0、OpenID Connectのセキュリティに関して簡単にまとめましたが、やっぱり実際にどう実装されているのかをこの目で確かめたくなってきますよね。
ということで、今回はダメダメなアプリ開発者と執念深い攻撃者の二足の草鞋を履いて、stateパラメータがどのように我々を守ってくれているのかを簡単なサンプルコードで試してみました、という実験録となります。
stateのおさらい
stateパラメータはOAuth2.0のフローで認可コードをユーザからクライアントへ送信する際に本当に正しいユーザから送られてきたデータなのかをクライアントが確かめるためのものでした。
前々回は
- 認可コード=握手券
- ユーザ=握手会の参加者
- クライアント=握手会会場の管理者
という形で置き換えて説明してみました。
この仕組みがないと、推しとだけの間の秘密の話が、攻撃者の人にも伝わってしまうリスクがあるというお話もしました。
stateパラメータがあるとき
ということで、まずはどんな風にクライアントが認可コードの検証をしているのか?を見ていきたいと思います。
まずはクライアントアプリを用意する必要がありますが、今回はこちらのPHPアプリを使わせていただきました。
GitHub - aaronpk/quick-php-authentication: Add Authentication to your PHP App in 5 Minutes!
ご参考までに私の環境は下記のような感じで作成しました。他にも色々やり方はあると思いますがパッとできるやり方でまずは・・・。
PHPクライアントアプリ : Azure Web Apps上で構築
認可サーバ:Azure AD(今回はOktaを使おうと思いつつ、結局Azure ADになりました)
①認可サーバへのリダイレクトまで
前々回の絵を使いながらですが、この時点でクライアントアプリからはstateパラメータが発行されます。
ですが、それと同時にユーザ側、厳密にはユーザの使うブラウザに対して同様にセッションIDが払い出されています(今回はPHPのデフォルトを使用しているので、PHPSESSIDの値に入っています)。
ブラウザ側ではセッションIDのみですが、クライアント側では、セッションIDとstateが紐づけられて保管されているのがわかります。
②ユーザへの認可コード返却まで
その後認可サーバで認証を行い、成功すると認可コードがユーザに返却されます。
実際の値はトレースをとるとこんな感じです。stateの値も入ってきているのがわかります。
③クライアントアプリへの認可コード送信まで
この認可コードとstateをクライアントアプリへ送信します。その際保持しているセッションIDについても送信します。図の赤丸三点セットです。
ブラウザから送られる赤丸三点セットと、クライアントアプリ側で保持している情報が一致しているかで正式なリクエストかどうかを判別しています。
実際の値も一部確認してみます。ブラウザで保持しているセッションID(PHPSESSID)と
クライアントアプリで保持しているstateの値です。
ブラウザで保持しているPHPSESSIDの値が、クライアントアプリ側で保持しているセッション保存ファイル名にマッピングされつつ、その中で持っているstateの値(②で見たデータ)と、ブラウザからクライアントアプリへ送信されるstateの値が一致していることがわかります。 この照合ステップで、認可コードが送られた際に正しいユーザからなのか確かめて次のステップへ進んでいくことになります。
stateパラメータがないとき
一方、stateパラメータがない世界では、本当に悪意あるユーザの意図通りに動いてしまうのでしょうか。
先にイメージ図を載せますが、stateの機能している世界だと、悪意あるユーザが認可コードを不正に利用しても、下記のように、ブラウザ側でセッションのないユーザからのリクエストとなり、弾かれるはずです。
それでは早速検証のためにちょっと無理やりにstateが機能していないアプリを作ろうと思います。手を加える箇所はそこまで多くないですが、今回使わせていただいたこちらのクライアントアプリのソースから49~51行目のstateの検証個所をコメントアウトします。
49 /*if($_SESSION['state'] != $_GET['state']) { 50 die('Authorization server returned an invalid state parameter'); 51 }*/
また、未使用の認可コードを横取りするために、56行目に一行だけコードを付け足します。
53 if(isset($_GET['error'])) {
54 die('Authorization server returned an error: '.htmlspecialchars($_GET['error']));
55 }
56 sleep(200);
sleepの時間は適当ですが、一度ここで処理を止めないとクライアントアプリが認可コードを使ってtoken_endpointへリクエストを投げてしまい、横取りした認可コードが使えなくなってしまうので、リクエスト前にいったん処理を止めます(sleepしている間にすることが多いので、長めに設定していますが後程コメントアウトします)。
この状態で下記の手順で動かしてみます。
①ブラウザを立ち上げて、F12で開発ツールを表示 ※ネットワークトレースが取れるなら他ツールでも可です
②クライアントアプリへアクセス
③認可サーバへリダイレクトされる
④認可サーバでID/パスワードを入力 ←いったんここまで!
認証完了後、56行目に付け足したsleepコマンドの部分で処理が止まっているので、ブラウザを中断しつつ、立ち上げている開発ツールのネットワークログから、認可コードが返ってきている箇所を探します。
ここで表示されているURLに認可コードやstateが含まれているので、がさっとコピーした後、別のブラウザへ貼り付けます。
そしてコピーしたURLを実行!する前に、時間短縮のため先ほど設定したsleep設定をコメントアウトしておきます。
53 if(isset($_GET['error'])) {
54 die('Authorization server returned an error: '.htmlspecialchars($_GET['error']));
55 }
56 //sleep(200);
では改めて、ようやくURL実行!
アクセスできてしまいました。stateの検証をしていないので当たり前なのですが、未使用の認可コードを取得できれば意図しないユーザがログインできてしまうという恐怖の片鱗が少し掴めるかなと思います。
ここでstateを検証しているとしっかり不正なアクセスということでログインを弾くことができます。
こうして見ると、認可サーバー側も認可コードの再利用ができないよう対策はしていますが、使い捨てられていない認可コードさえ使われてしまうと、認可サーバ側では対処しようがなく
認可コードとユーザの紐づきの部分はクライアント側で責任をもって実装しなければならない箇所だということが改めてわかるかなと思います。