どうしてリスクアセスメントせずに JWT をセッションに使っちゃうわけ?

備考

2018/09/21 22:15 追記
2018/09/20 12:10 に公開した「どうして JWT をセッションに使っちゃうわけ?」というタイトルが不適切だとご指摘をいただいています。 その意見はもっともだと思いますので、現在、適切となるようにタイトルを調整しています。 ご迷惑およびお騒がせをして大変申し訳ございません。 本文の表現についても改善の余地は大いにありそうですが、こちらは (すでにご意見を頂戴している関係で、) 主張が変わってしまわないように配慮しつつ慎重に調整させていただくかもしれません。

はあああ〜〜〜〜頼むからこちらも忙しいのでこんなエントリを書かせないでほしい (挨拶)。もしくは僕を暇にしてこういうエントリを書かせるためのプログラマーを募集しています (挨拶)。

JWT (JSON Web Token; RFC 7519) を充分なリスクの見積もりをせずセッションに使う事例が現実に観測されはじめ、周りにもそれが伝染しはじめているようなので急いで書くことにします。 (ステートレスな) JWT をセッションに使うことは、セッション ID を用いる伝統的なセッション機構に比べて、あらゆるセキュリティ上のリスクを負うことになります。

ちなみに僕は JWT をはじめとする JOSE (JSON Object Signing and Encryption) 関連の標準はドラフトの段階からある程度読んでいて、リクエスト署名手段として JWS (JSON Web Signature; RFC 7515) を何度かプロダクトに採用しています。で、このたび開発しているプロダクトで OpenID Connect を採用するにあたって、 ID Token の表現として JWT が登場するわけですが、これを素直にセッション継続手段として使うわけにはいかないし [1]、どうするかなあと悩んだうえで、それらしいアプローチを見出したところでした。

つまりこのエントリで述べることはそうした中で生まれた僕自身の考えと、それから勤務先での議論の結果を反映したものです――あ、しかしそれでもこのエントリの文責は海老原昂輔 (@co3k) 自身にありますし、勤務先での議論をガン無視したことを言いはじめるかもしれないので苦情や苦情等は全部 @co3k までよろしく――。

とはいえ、やはり同じような主張をしている方は既にいらっしゃいます。以下の 2 エントリを読むだけでたぶん充分だと思う (ので読んでほしい!) のですが、こういう後ろ盾を得つつ、僕なりの主張を展開していこうと思いますのでよろしければお付き合いください。

何を問題にしたいの?

そもそも JWT ってそれ単体で認証とかの仕組みを構成するものではないです。なのでほとんどの場合、認証とかセッション継続とかはみんなが勝手にやっているだけだと思うのですが、もし OpenID Connect 以外にもそういう標準があるなら教えてほしい。とりあえず以下のようなセッション管理機構を考えます。

  • ログイン後、サーバ側でセッションを発行せず、代わりに JWT をレスポンスする
    • レスポンスされる JWT はユーザ ID 等の情報をペイロードに載せ、サーバ側の秘密鍵で署名もしくは暗号化したもの
    • トークンに有効期限を持たせたければ、ペイロードにその情報を含める
    • JWT が JWS であれば、このユーザ ID 等の情報は平文で (URL safe な base64 エンコードされた状態で) 載る。 JWE であれば暗号化される。どちらの場合も秘密鍵を知らなければトークンの改竄はできない
  • クライアント側に JWT を保存し、認証が必要なリソースへのアクセス時にその JWT を送出する。まあ XMLHttpRequest なり Fetch API なりで Authorization: Bearer {JWT 値} みたいに送出してもらうことにしましょう
  • サーバ側では JWT を検証し、問題がなければそのペイロードの内容を信頼する。つまりペイロードでユーザ ID を 1 番と名乗っているなら 1 番さんとして扱うし、 co3k さんと名乗っているなら co3k さんとして扱う

要はサーバ側にセッションストレージを用意する必要がない! ステートレス最高! スケーラビリティ抜群!

なお、 JWT にセッションデータそのものではなく、セッション ID を (も) 格納する場合はここでは問題としません。また、 JWT そのものについても問題とはしません。あくまでセッションデータそのもののみを JWT に含むことでステートレスなセッション機構を実現する場合について考えます。

で、何が問題なの?

Ruby on Rails のデフォルトのセッション管理機構 cookie store と同じ問題を抱える

みんな cookie store は避けるのにどうして同じ問題を持つ JWT をセッション継続に使っちゃうんだろう? むしろ cookie store を強い意志で使ってくれていれば、今頃はノウハウが蓄積され、JWT による妥当なセッション継続の実装が幅を効かせていたに違いない。

まあそれはともかく、 cookie store っていうのも上で説明した JWT によるセッション継続機構と似ていて、認証に用いるペイロードをサーバ側の秘密鍵で署名するか、または暗号化し、そいつを cookie に保存させてクライアントに送出させ続けることで、ステートレスな (万歳!) セッション管理機構を実現するというもの。

で、こいつは以下のような問題が知られており、これがそのまま JWT にも当てはまります。

サーバ側でのセッション無効化ができない (鍵を無効化し、発行済セッション全体を無効化することはできる)

サーバ側でセッションを管理しない以上、サーバ側でどのセッションを無効化するべきか判断することができません。

個別のセッションを無効にしたい場面としては、たとえば、

  • ユーザのパスワード変更時、そのユーザのアクティブなセッションをすべて無効にし、再ログインを促す
  • ユーザのログイン履歴から、ユーザ自身の身に覚えのないセッションを選んで無効にする
  • パスワードリスト攻撃などによってアカウントが乗っ取られたユーザのセッションを無効化する

あたりがあるでしょうか。こういった機能は少なくとも単純には実現できません。機能そのものを諦めるか、ペイロードにサーバ由来の値をなんらか含むなどの工夫が必要です。

ただし、たとえば秘密鍵が漏洩した場合や、極めて広範なアカウント乗っ取りが発生した場合、重大なセキュリティ脆弱性があった場合の対応策として、発行済セッション全体を無効化することはできます。どうやればよいかというと、秘密鍵を更新するだけです。そうすれば古い鍵を使って作られたトークンは無効となりますので、 (その鍵を使ったセッションを利用していた) 全アクティブユーザは再ログインを要求されます。

……と、さらっと書いちゃいましたが、ログイン処理は Web アプリケーションにおいて比較的コストがかかる部類の処理 (のはず。たとえばパスワード認証の場合であれば password stretching をしているはずなので) であり、それが集中しうるということは計算に入れる必要があります。まあもっとも、これは程度問題で、セッション管理機構の実装方式に依らずに覚悟しなければならないことではあります。しかし、 30% のユーザがなりすましの影響を受けたとして、影響を受けた 30% のユーザのために影響を受けなかった 70% のユーザのセッションまでリセットするか、もしくはその逆、つまり、影響を受けなかった 70% のユーザに配慮して影響を受けた 30% のユーザのセッションを危険に晒すか、という、 all or nothing の問題となってくることは認識しておいたほうがよいでしょう。

ユーザの能動的な「ログアウト」はセッションを失効させない

また、似たような理屈で、ユーザがログアウト機能を用いて能動的に「ログアウト」した場合でも、ユーザのストレージからトークンが削除されるだけで、トークンそのものが無効になるわけではありません。ログアウト前にトークンのデータを控えておけば、何事もなかったかのようにセッションを継続することができます。

セッションデータの削除処理がクライアント側で確実に実行されさえすれば、たとえば CSRF 攻撃対策としてのログアウト行為は伝統的なセッション機構と同程度に機能するかと思います。

ただし、何らかの要因でトークンを外部に晒してしまい、そのトークンによるセッションハイジャックを自主的に防ぐためのアクションとしてのログアウトは期待通りに機能しません。この場合、ユーザがログアウトをおこなってもセッションハイジャックは止められません。

そういったわけなので、サービスのログアウト機能に対して、運営者やユーザが暗にどういった効用を期待しているか、その期待を満たすことができるのかについて、サボらずによくよく吟味しなければいけません。

有効期限を明示的にペイロードに含まない限り、セッションが恒久的に有効になる

いや、さすがに含めるでしょって思いますけど、含んでいなかった例が確認されています。含めましょうね……。

Ruby on Rails の cookie store はペイロードに有効期限を含まないらしいので、アプリケーション開発者が自主的に考慮を加える必要があります。

しかし、 JWT では考慮済みで、 exp というフィールドがオプションですが存在しますので、これをちゃんと指定しておくことでこの問題は回避できます。

また、さすがというか、さすがにというか、 OpenID Connect の ID Token においては、 exp は REQUIRED です。

秘密鍵を知っていれば任意のユーザに対するセッションハイジャックが可能

これはつまり内部犯行を想定していて、それを言ったらって話なわけですけど、まあまあちょっと聞いてくださいよ。

サーバ側にセッションデータを格納し、セッション ID を払い出す方式の場合、内部犯がセッションハイジャックするためには、

  • セッション DB へのアクセス権を得る。もっとも Web サーバを経由するでしょうし、これはクリアするんじゃないでしょうか
  • セッション DB からセッション ID を盗む
  • 盗んだセッション ID を使ってセッションハイジャックする

というステップを経るわけですけど、全セッション ID を一覧するような行動はさすがに発覚の危険がありますから、バレないように数件ずつとか、あるいは 1 ユーザずつとかやっていくことになるわけです。ただ、そのいずれもクライアントから送られてくるセッション ID を検索する操作とは異なる (ログインセッションの一覧機能を提供している場合はこの限りではありません) ので、ひょっとしたら事後になるかもしれませんが攻撃の痕跡を見つけることはできそうではあります。

一方で、今回問題にしている例の場合、内部犯は、

  • Web サーバで動くアプリケーションのメモリ上に載っている秘密鍵を盗む
  • 任意のあらゆるユーザのセッションデータを作って署名し、セッションハイジャックする

というステップを経ることになります。サーバ側から見たときに通常と異なるイベントは最初のステップだけです。これどうやって防ぎます? もしくはどう検知します?

というかまさか秘密鍵をソースコードに埋め込んでいたりしないですよね? 当たり前ですが、それはもう秘密鍵を盗む必要すらなくなります。今まさに内部犯行がおこなわれていてもまったく不思議ではありません。いますぐ鍵を更新し、鍵管理方法を見直しましょう。

いわゆる退職者バックドアになる

そういうわけですから鍵管理が重要となります。

運営者や開発者の人員構成に変更があった場合 (有り体に言うと異動や退職があった場合) に秘密鍵を更新しないと、いわゆる退職者バックドアとなります。

まあこれも組織内で何かしらの共有パスワードを使っている場合の管理と同じっちゃ同じ [2] ですが、この秘密鍵がそういう種類のものだということを理解しておく必要があります。この認識が抜けている例も確認しています。

また、運営会社と開発会社が異なる場合など、開発用に一時的に解放していたサービスのアカウント情報などを、運営に移行する際にひととおり更改する、というのは当然の発想ですし、おそらくフローに組み込まれているはずですが、 JWT の秘密鍵についても同様に変更しなければならないものとして扱う必要があります。

というより、秘密鍵を渡していますか?(受け取っていますか?) というよりというより、 JWT をセッション管理に使っていることを知らせていますか?(知らされていますか?)

そもそも秘密鍵は定期的に更新しなければならない

最初に、僕は暗号技術には明るくないので、この項目についてズレたことを言ってしまっているかもしれません。ただし要点は外していないはず。ここに限らずですがあらゆるフィードバックを歓迎します。

トークンに対するオフライン攻撃が可能なので、共通鍵暗号や HMAC の場合ならば鍵の総当たり、公開鍵暗号 (非対称鍵暗号) の場合、たとえば RSA 暗号であれば公開鍵に対する素因数分解を想定しないわけにはいきません。つまり、みなさんが PKI の上で TLS 通信をやっているときと同じように、秘密鍵に有効期限を与える必要があるってことです。なにせ単体でセッションハイジャック可能な鍵なわけですから、それくらいはやって当然ですね。

そうなるともちろん、定期的な鍵の更新を考慮しておく必要があります。一年に一回、元旦に全ユーザが一斉ログアウト&一斉ログイン、みたいなことをやるのはちょっとおもしろすぎる (「あけおめメール」ならぬ「あけおめログイン」ですね) ので、新旧鍵の併用期間が必要になってきます。そういう運用は想定していますか?

ちなみにあんまりこのエントリで OpenID Connect を激推しするつもりはない [3] のですが、 OpenID Connect にはこのための仕組みがあります。 JWK (JSON Web Key) っていう標準がある (RFC 7517) んですけど、有効な公開鍵の一覧を用意しておいて、更に鍵を一意に特定するための ID を与えておくというものです (JWK Set)。それでもって ID Token (JWT) のヘッダ部にて、用いた鍵の ID を kid フィールドに格納しておくと、

  1. JWT 検証時には kid の指す鍵を用いるようにしておく
  2. 古い鍵を廃止する前に、新しい鍵を生成して JWK Set に追加する
  3. 新規に発行する JWT については、新しい鍵を用いるようにする (新しい鍵の ID を kid に含む)
  4. 古い鍵を使った JWT の有効期限が訪れるのを待つ (もちろん待たなくてもよい)
  5. 古い鍵を JWK Set から削除する

これで一斉ログアウトなしに鍵が更新できるというわけです。まあもちろん JWK を使わないといけないわけではありませんが、鍵のローテーションをするのであれば、似たような仕組みは備えておく必要があります。

JWT 特有の罠がある

alg の柔軟性 (alg=none 許容で即死など)

ああ、そういえばそんな問題あったなーという感じなんですが、 JWT は、

  • トークン側に、そのトークンで利用している暗号アルゴリズムが含まれる
  • どの暗号アルゴリズムを許容するかは、そのトークンを検証する側 (今回の例であればサーバ側) に完全に委ねられている

という性質を持ちます。このことから、以下のような問題が知られています。基本的にはほとんどのライブラリで対策済みのはずですが、本当に対策済みかどうかは確認しておいたほうがいいでしょう (本当に即死するんで!)。

  • algnone を許容している場合は、署名部分を空にしたトークン (つまり {"alg":"none","typ":"JWT"}.{"user_id": "1"}.) が有効となるので、秘密鍵を知らなくても任意のセッションをハイジャックできます
  • サーバ側が公開鍵暗号 (非対称鍵暗号) を期待しているにも関わらず algHS256 を、つまり HMAC-SHA256 などを許容している場合で、そのトークンを検証するのに使われる RSA 公開鍵を HMAC における秘密鍵として扱ってしまう実装が存在しました。つまり秘密鍵を公開している状態になるので、これもセッションハイジャックし放題です

まあこういう性質を持つってことは、 TLS におけるダウングレード攻撃と同等のことができるってことです。許容する暗号アルゴリズムは必要最低限のものに絞りましょう。というかクライアント側でトークンの検証をしないのであれば、サーバ側で利用可能な最強のアルゴリズムだけを許容しておきましょう。

これが JOSE の Security Consideration に書かれていないのがちょっとよくない、というか draft 段階の実装時点で既に問題になったトピックなんで、なんというかどうにかしてフィードバックすればよかったごめんなさい。

JWE で利用可能な暗号アルゴリズムが微妙 (らしい)

先述の通り、僕は暗号技術に明るくありません。したがってこのトピックは完全に僕の手に余るものなのですが、 No Way, JOSE! Javascript Object Signing and Encryption is a Bad Standard That Everyone Should Avoid - Paragon Initiative Enterprises Blog では、 JWE で利用可能な鍵暗号アルゴリズムについて、

  • RSA with PKCS #1v1.5 Padding はパディングオラクル攻撃に対して脆弱である
  • RSA with OAEP Padding は RSA を信頼するなら安全であるが、 RSA は長期的には信頼しにくい
    • 僕でも知っていることだと、「ハードウェアの性能向上による鍵解読リスクへの対策として鍵長を増やしているわけだが、そのうち限界来ません?」とか「乱数生成に問題があって、異なる鍵同士が同じ素数を選択してしまった場合に脆弱だよね」とかですが、他にもあるのかもしれません (これすら間違っていたらごめんなさい。暗号は本当に素人なんです)
  • 楕円曲線暗号は invalid-curve attacks に脆弱な ECDH しか利用できない
  • AES-GCM については……ごめんなさい、文意が取れなかったので原文をそのまま引きます
    > Because no list of questionable public-key encryption modes could be complete without shoehorning a shared-key encryption mode, the JOSE standards also allow you to use AES-GCM to possibly exchange an AES-GCM key.

としています。

事実とはいえ自分で何度も書くのが辛くなってきたのですが、僕は暗号技術に明るくなく、暗号アルゴリズムの選定の際には CRYPTREC暗号リスト (※リンク先 PDF) に頼りっきりという有様なので、リストに掲載されている RSA-OAEP を普通に採用することになるだろうなと思います。

もちろんどのような暗号アルゴリズムを採用するとしても、暗号アルゴリズム自体の危殆化には備えておかなければならないわけで、まあそこさえ抑えておけば大丈夫じゃないかな……たぶん……。

トークンのサイズが大きくなる場合があり、 cookie にデータを保存しにくい

JWT は URL safe Base64 によってトークンを構成する各要素をエンコードするのと、署名を含む関係でどうしてもサイズが大きくなります。 RFC 6265 では、 各 cookie (値だけでなく、名前、属性も含めて) の長さ制限は少なくとも 4096 bytes であるべき (SHOULD) であるとされています。これはだいぶ実装が出揃ってからの RFC なので、現実の実装を素直に反映しているようです。 Browser Cookie Limits によると、おおむね 4093 bytes から 4096 bytes で、一部の実装で 5117 文字であったりする模様です。

まあそんなわけで、 cookie に格納可能なサイズを超えてしまうかもしれない、ということから、 Web Storage API を利用するアプローチが選択されがちです。これによって JavaScript の利用が前提となるわけですが、 cookie がもたらしてくれたセキュリティ保護の恩恵を受けられなくなる、といった問題もあります。

cookie の secure フラグの保護を受けない

あと cookie が守ってくれるものって何かないかなーと考えていたら secure フラグがありました。

とはいえ、 HTTP 通信時と HTTPS 通信時でセッションを分ける必要があるのは cookie も Web Storage も変わらないし、なんなら Web Storage の場合は same-origin policy の保護を受けるので、 security フラグがなくとも自然に分離した形で保存されます。

しかし、攻撃者の罠サイト、あるいは攻撃対象サービスの HTTP なリソースを (通信路上で改竄したうえで) 経由して JWT を返す認証 API (HTTPS) の実行を強制させることができ (※)、かつ認証 API (HTTPS) のレスポンスの CORS 設定が雑 (※) な場合、 HTTP なリソースからセッション用の JWT を盗むことができる――つまり HTTPS の保護を迂回してセッションを盗み、セッションハイジャックすることが可能です。

cookie の場合は、たとえどんなに Access-Control-Expose-Headers の設定が緩かったとしても XMLHttpRequest や Fetch API から Set-Cookie や Set-Cookie2 ヘッダの内容を読み取ることはできません。攻撃者が通信の内容を得ることができない以上、 secure フラグ付きの cookie の内容を得ることはできません。

……と、一応書いてはみたものの、※印で示したような前提は突破する必要があります。なにか見落としている可能性をあまり否定はしませんが、これはちょっと無理のあるシナリオかな、と思います。

……というようなことを考慮して実装する必要がある

JWT によるセッション管理を選択することはつまり、「長い年月を経てベストプラクティスが確立された、セッション ID による伝統的なセッション管理機構をあえて避け」、「あまり叩かれなれていない技術を使って直接的にセキュリティに関連する機能の再実装を独自でおこなう」ことと同義です。

まあチャレンジは大いに結構。大いに結構ですが、あえて危ない橋を渡るというのであれば、ここまで書き連ねたようなことくらいは一通り考え尽くしている必要があるかと思います。しかし実際のところ、考慮が不充分過ぎる実装にばかり出会うのは僕の運が悪いだけなのでしょうか。

で、どうすればよいの?

まあ僕自身のケースで言うと、 OpenID Connect 経由で得られる ID Token (JWT) はログインのためだけに使い、伝統的なセッション管理を引き続き使いますよ、もしくは ID Token にセッション ID を含みますよ、で要件を満たせてしまいます (実際には OpenID Connect の Session Management における拡張仕様のどれかにも載っからないと各サービス間でのログアウト状態の整合性が取れないので頑張りが必要ですが)。

どうしてもステートレスに JWT を使いたい? んー……まあこれまで述べたようなリスクを理解したうえで、有効期限に気を遣ったり、鍵管理を頑張っていきましょう、ということになるんでしょうかね。セッション失効周りも頑張って作り込めばどうにか実現はできるとは思います。でもそこまでしてステートレスに JWT を使わなくてはいけないか? というのは熟考しまくったほうがいいです。

最後に、冒頭で紹介した http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/http://cryto.net/~joepie91/blog/2016/06/19/stop-using-jwt-for-sessions-part-2-why-your-solution-doesnt-work/ の著者である @joepie91 氏が作成したフローチャートが本当に素晴らしいのでご紹介します。

日本語訳して紹介したい、とお願いしたところ、なんとご親切に図の元データまでご提供いただきました。 Very thanks to @joepie91.

ここまでしていただいたにも関わらず、僕の力不足によってかなり苦しい日本語訳となってしまったことは痛感の極みであります。あくまで オリジナルのフローチャート の参考訳として以下の図をお使いいただければ幸いです。

[1]隠し iframe を活用するアプローチなど、やってやれないこともないでしょうが、運用まで考えるとそれはそれで厳しいものがあります。
[2]より実際に近いのは TLS サーバ証明書管理かもしれません (thanks to @ajiyoshi)。イメージできる人はこちらのほうをイメージしてみましょう。
[3]OpenID Connect を安全に使うのはそれはそれで難しいというか、知っておくべきことが多いからです。「OAuth 2.0 認証」よりはマシですけれどもね……
comments powered by Disqus

Recently entries