侵害につながる JWT のミス
最終更新:
JSON Web Token は署名された声明であり、封印された封筒ではありません。ドットで結ばれた 3 つの Base64url エンコード済みセグメント、すなわち署名アルゴリズムを名指すヘッダー、ユーザーやセッションに関するクレームのペイロード、そして最初の 2 つに対する署名です。署名は、そのトークンが鍵の保持者によって発行され、以来改変されていないことを証明します。秘密性については何も証明しません。トークンを保持する者は誰でもペイロードをデコードして中のクレームをすべて読めるため、デコードに必要なのはパスワードではなく貼り付けです。JWT が本番環境で破綻するとき、壊れているのは暗号そのものではほぼありません。壊れているのは、誰かが省略したチェックです。トークン自身から鵜呑みにしたアルゴリズム、誰も検証しなかった有効期限、人が記憶から打てる共有シークレット。任意のトークンを JWT デコーダーに貼れば、ヘッダーとクレームが目の前にそのまま現れます。本記事では、省略されたチェックを侵害に変えるエンジニアリング上のミスと、それぞれを塞ぐ防御ルールを順にたどります。
署名されていることと秘密であることは違う
ペイロードは Base64url、つまり通常の Base64 と同族のエンコーディングであり、暗号化ではありません。その中身は、トークンが経由する誰にでも読めます。メールアドレス、ロール、テナント ID、内部の口座番号、そして時にはパスワードリセットフラグや、開発者がデバッグ用に入れて消し忘れた個人データの断片など、はるかに悪いものまで。トークンは開発者の想像より遠くまで旅します。ブラウザのストレージに眠り、プロキシやアプリケーションのログを通過し、クラッシュレポートにコピーされ、バグチケットやチャットのスレッドに貼られます。そこから直接導かれる作業ルールは、ログファイルに印字されても困らないクレームだけをトークンに入れること。遅かれ早かれ、そこに現れるからです。
誤解が生まれるのは無理もありません。コンパクトなトークンは発音できない文字とドットの羅列で、暗号文のように見えるからです。しかし署名がもたらすのは改ざん検知だけです。クレームを変えれば検証は失敗します。機密性は同じ標準ファミリーの別の構造(暗号化トークン、JWE)の領分であり、通常のアクセストークンがそれであることはまれです。したがってペイロードは、署名の付いた公開テキストとして設計してください。識別子と認可に関する事実だけを入れ、機密データはサーバー側で ID を使って取得し、有効期間を短く保って、漏れたものがあっても早く期限切れになるようにします。
トークンに自分のアルゴリズムを選ばせる
ヘッダーは alg を宣言します。そして JWT の歴史で最も古い失敗は、それを信じる検証器です。宣言値のひとつが none、つまり署名なしのトークンです。トークン自身の宣言したアルゴリズムに従うライブラリや手作りのチェックは、署名が空のトークンを受け取り、本物として受理しかねません。ベストプラクティスの RFC はこの点について率直です。署名なしトークンが許容されるのは、チャネル自体がすでに完全性を保証している場合だけであり、パブリックなインターネットを渡ってくるベアラートークンはそれに当たりません。ヘッダーを信じる同じ欠陥が、アルゴリズム混同を生みます。RS256 トークンを発行するシステムは秘密鍵を保持し、検証用に一致する公開鍵を公開します。検証器を説得してトークンを HS256 として処理させることができれば、検証材料は公開鍵そのものになります。設計上公開され、誰もが知るその鍵が対称シークレットとして使われ、誰でも作れる署名が検証を通り始めます。どちらの失敗でも鍵は盗まれていません。検証器は、送信者が書いた指示にただ従っただけです。
ヘッダーにはもうひとつ、静かなリスクがあります。鍵へのポインタです。kid(鍵 ID)、jku、x5u(鍵や証明書の所在を示す URL)といったパラメータは、検証鍵をどこで見つけるかを検証器に伝えます。そしてそれらは攻撃者の影響下にあるトークンの内部に乗って届きます。kid の値が厳格な検証なしにファイルパス、データベースの検索、鍵の取得へと流れ込めば、その検索自体が攻撃面になります。防御ルールはすべて同じ形をしています。期待するアルゴリズムをサーバー側の設定に固定し、それ以外をすべて拒否すること。鍵は自前のレジストリから解決し、鍵 ID を許可リストと照合すること。トークンが提示する URL から鍵材料を決して取得しないこと。そしてすべてのヘッダーフィールドを信頼できない入力として扱うこと。実際、その通りなのですから。
署名は通った。しかしクレームは検証されなかった
署名の検証が答えるのはひとつの質問だけです。このトークンは鍵の保持者が発行し、手付かずのままか。それが、このトークンをいま、ここで受け入れるべきかどうかについては何も語りません。それはクレームの仕事であり、クレーム検証こそ本番システムが静かに手順を省略する場所です。まず exp。有効期限を決して確認しない検証器は、すべてのトークンを永久の資格情報に変えます。3 年前にログへ漏れたトークンが今日も認証を通るのはそのためです。次に iss。設定した発行者からのトークンだけを受け入れ、たまたま同じアルゴリズムを使う誰かからのものは受け入れないこと。そして最も高くつく省略が aud です。組織はしばしば複数のサービスで 1 つの署名鍵を共用します。すると低価値なサービスのトークン(無料枠の API やステージングに近いツール)が、署名は本物なのに高価値なサービスに受理される、正しく署名されたトークンになります。誰も、そのトークンがどのオーディエンス向けに発行されたのかを尋ねなかったからです。
どれも珍しい話ではありません。これはチェックリストであり、失敗の形は、ライブラリが署名を熱心に検証する一方で、クレームのチェックを任意、無効、あるいは呼び出し側の責任として残すことです。防御の姿勢は、検証のたびに exp、iss、aud を要求し、欠落や不一致にはフェイルクローズで臨み、時計のずれの許容は時間単位ではなく分単位にすることです。許容幅は不正確な時計のためのものであり、広げすぎれば期限切れのトークンを静かに生き返らせます。そして否定ケースを、ログインフォームをテストするのと同じようにテストしてください。期限切れのトークン、オーディエンスの違うトークン、未知の発行者のトークンはすべて拒否されるべきであり、その拒否はセキュリティシグナルとして記録する価値があります。
人が覚えられるシークレットは、攻撃者が試せるシークレット
HS256 は対称です。ひとつの共有シークレットが署名も検証も担います。現実のコードベースでは、そのシークレットはあまりにもしばしば単語や短いフレーズです。会社名、チュートリアルからコピーしたフレームワークのデフォルト、開発者が記憶から打てる何か。これを致命的にする原理は次の通りであり、それは特定のツールの性質ではなく設計自体の性質です。捕捉されたトークンは、推測したシークレットをオフラインでテストするために必要なものをすべて含んでいます。メッセージは既知、署名も既知。したがって推測はローカルで全速に照合でき、サーバーが試行を目にすることも、レート制限が効くこともありません。ゆえにスキーム全体の安全性は、その 1 本の文字列のエントロピーに帰着します。そして人間が選んだ文字列のエントロピーはごくわずかです。
対策は地味です。署名シークレットは機械で生成し、適切な長さ(HMAC なら少なくとも 256 ビットのエントロピー)を持たせ、本番の資格情報そのものとして保管してください。環境ごとに一意にすれば、漏れたステージング設定のシークレットは本番では何も買えません。開発用のデフォルトを実際のデプロイに決して生き残らせないこと。多くのサービスがトークンを検証する環境では、RS256 や ES256 のような非対称アルゴリズムを優先してください。検証器は公開鍵だけを保持するため、検証器が侵害されてもトークンを鋳造できるものは漏れず、署名鍵は 1 か所にとどまります。そして必要になる前にローテーションの手順を用意してください。対称シークレットが漏れても警報は鳴りません。最初の兆候は通常、自分が発行していないトークンが自分のものとして受け入れられることです。
ブラウザのどこにトークンを置くかが、誰に読めるかを決める
クライアント側では、保存場所の選択はセキュリティ上の決定です。誰かが意識して決めたかどうかによらず。localStorage に置かれたトークンは、ページで実行されるすべてのスクリプトに読めます。自前のコード、自分が入れたあらゆるサードパーティのタグや解析スニペット、そしてアプリのどこかにある 1 つのエスケープ漏れのコメント欄からでも実行されうる、あらゆるクロスサイトスクリプティングのペイロードに。ベアラートークンは複製可能な資格情報です。読んだ者はどこからでもそれを提示でき、有効期限が切れるまでそのユーザーそのものになれます。スクリプトから読めるストレージに置かれた長寿命のトークンは、したがって最悪の XSS バグ 1 つ分の価値しかありません。そして大抵のアプリケーションはいずれ XSS を抱えます。
通常の代替案である HttpOnly クッキーは、スクリプトからのアクセスを完全に断ちます。XSS ペイロードはトークンを読んで持ち出すことができなくなります。しかしブラウザがクッキーを自動で添付するため、対処すべき脅威はクロスサイトリクエストフォージェリとなり、SameSite 属性と CSRF トークンで応じます。トークンをメモリだけに保持すれば両方の問題を回避できますが、更新のたびにセッションを失います。普遍的に正しい答えは存在せず、存在するふりをすることが、各選択肢の最悪の部分をチームが手にする道です。保存を侵害に変えるのは、誰も意図して選ばなかった組み合わせです。スクリプトから読める保存、長い有効期間、広いスコープ、そしてコピーの価値を縮めるリフレッシュトークンのローテーションの欠如。トレードオフは意識的に選び、どちらの方式でもアクセストークンは短命に保ち、その理由を次の開発者が見つけられる場所に書き残してください。