JWT(JSON Web Token)は、現代のSPAやマイクロサービスの認証・セッション管理で広く使われます。だからこそ、JWTの検証を一つ間違えると、認証が丸ごと崩壊します。PortSwigger が言う通り「JWTベースの仕組みの安全性は、暗号署名に大きく依存」しており、攻撃の大半はその署名検証の手抜きを突きます。本記事は、その攻撃手法を公式に忠実に解説します。
絶対の前提: 全手順は 合法ラボ または許可スコープでのみ。他人のトークンの偽造・なりすましは、検証目的でも無許可なら不正アクセスです(→ 法律ガイド)。地図は ピラー。JWTを含むトークン設計の基礎は IDトークンとアクセストークンの違い も参照。
1. JWTの構造 — 3つのパート
JWTは header.payload.signature の3つをドット区切りで base64url エンコードしたものです。
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJ3aWVuZXIifQ . <署名>
└─ header ────────┘ └─ payload ──────────┘
{"alg":"HS256"} {"sub":"wiener","role":"user"}
- header:署名アルゴリズム(
alg)などのメタ情報。 - payload:クレーム(誰か・権限・有効期限など)。Base64は暗号化ではないので、誰でも中身を読める。
- signature:header+payload を鍵で署名したもの。改ざん検出はここだけが頼り。
つまり、署名検証が甘ければ、payloadを "role":"admin" に書き換え放題になります。
2. 署名検証の不備を突く
2.1 署名を検証していない / 任意署名の受理
開発者が「デコード」と「検証」を混同し、署名を検証せずにpayloadを信用しているケース。payloadを書き換えるだけで通ります。
# payload の "sub":"wiener" を "sub":"administrator" に書き換えて送るだけで通る
# (Burp の JWT Editor 拡張でワンクリック改ざん→送信できる)
2.2 alg:none
JWS仕様には「署名なし(alg:none)」が存在します。サーバーがこれを受理すると、署名を空にしてpayloadを自由に改ざんできます。
# header を {"alg":"none"} にし、署名部分を空にする
eyJhbGciOiJub25lIn0 . eyJzdWIiOiJhZG1pbmlzdHJhdG9yIn0 .
└─ 署名は空
単純な none が弾かれても、PortSwigger が指摘する通り None・nOnE・予期しないエンコードといった難読化で検証フィルタを抜けることがあります。
3. 弱い秘密鍵のブルートフォース(HS256)
HMAC系(HS256)は**「任意の文字列」を秘密鍵**にします。鍵が弱い/既定値だと、hashcat で割れます。
# JWTを辞書攻撃(-m 16500 が JWT モード)。割れた鍵が標準出力に出る
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/jwt.secrets.list
# 鍵が判明したら、任意のpayloadを「正規に」署名できる = 完全な認証バイパス
# 例: {"sub":"administrator"} を割れた鍵で署名して送る
鍵が割れたら終わりです。だから秘密鍵は十分に長く・ランダムでなければなりません(弱い既定鍵の流用が事故の典型)。応用暗号と鍵管理の原則は パスワードハッシュ・暗号・鍵管理ガイド を参照。
4. ヘッダ・パラメータ注入(jwk / jku / kid)
JWT仕様は、署名検証に使う鍵をヘッダで指定する仕組みを持ちます。サーバーがこれを無検証で信用すると、攻撃者の鍵で検証させられます。
| パラメータ | 意味 | 攻撃 |
|---|---|---|
| jwk | 公開鍵をトークン内に直接埋め込む | 自分の鍵ペアで署名し、公開鍵をjwkに埋めて送る |
| jku | 鍵セット(JWK Set)のURLを指す | 攻撃者サーバーのJWK SetをURLに指定 |
| kid | 使う鍵のID(ファイル/DB参照に使われがち) | パストラバーサル(../../dev/null)やSQLiで鍵を操作 |
# jwk 注入の例(header に自前の公開鍵を埋め、その秘密鍵で署名)
{"alg":"RS256","typ":"JWT","jwk":{"kty":"RSA","n":"<攻撃者の公開鍵>","e":"AQAB"}}
# kid をパストラバーサルに:既知内容のファイルを鍵に使わせ、署名を予測可能にする
{"alg":"HS256","kid":"../../../../../../dev/null"}
# /dev/null は空 → 鍵が空文字列になり、攻撃者が署名を作れる
5. アルゴリズム混同(鍵混同)攻撃 — RS256 → HS256
最も巧妙なのが**アルゴリズム混同(algorithm confusion / key confusion)**です。サーバーがRS256(非対称:公開鍵で検証・秘密鍵で署名)を想定しているのに、検証時にalgを固定していないと成立します。
仕組み:
- RS256の公開鍵は誰でも入手できる(JWKエンドポイントや証明書から)。
- 攻撃者は
algを HS256(対称)に変えたトークンを作る。 - そのトークンを、公開鍵の文字列を「HMACの秘密鍵」として署名する。
- サーバーは「HS256だな」と判断し、手元の公開鍵をHMAC鍵として検証 → 一致してしまう。
# 概念:本来 RS256 のサーバーへ、HS256 で署名したトークンを送る
# 署名鍵 = サーバーのRSA「公開」鍵(PEM文字列そのもの)
header = {"alg":"HS256"}
payload = {"sub":"administrator"}
signature = HMAC-SHA256(base64(header)+"."+base64(payload), <RSA公開鍵PEM>)
公開鍵は秘密でないため、攻撃者は正規に「見える」トークンを偽造できる。これが鍵混同の怖さです(Burpの JWT Editor でPoC可能)。
6. 【守る側】根本対策
攻撃の構造がわかれば、防御は明快です。PortSwigger の推奨に沿います。
// ✅ 検証時にアルゴリズムを「固定」する(最重要)。none も他algも受け付けない
import { jwtVerify } from "jose";
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // ← 許可リスト。これが無いと alg 混同/none を許す
issuer: "https://auth.example",
audience: "https://api.example",
});
設計原則:
- アルゴリズムをサーバー側で固定(許可リスト)。
alg:noneを拒否し、トークンのalgを信用しない。これだけで alg混同・none の大半を封じる。 - 検証は必ず行う:「デコード」関数(
decode)と「検証」関数(verify)を混同しない。 - jku/jwk のホストを許可リスト化(任意URLの鍵を信用しない)。kid はパストラバーサル/SQLi 対策を施す。
- 秘密鍵は長くランダムに(HS256の弱鍵を避ける)。鍵のローテーションを設計。
- 実績ある最新ライブラリを、ドキュメント通りに使う(自前実装しない)。
jose/jsonwebtoken等。 - 有効期限(exp)と失効:短い有効期限 + リフレッシュトークン + 失効リストで、漏洩時の被害を限定。
- 保存場所:XSSでの窃取を避けるため、ブラウザでは
HttpOnlyCookie を検討(→ XSS対策)。
認証基盤としての堅牢な設計(Cognito/Auth0/Clerk/Supabase Authの選定と、RS256のJWKS検証)は 認証プラットフォーム選定 と Cognito JWT RS256検証 で詳説しています。
7. まとめ
- JWTの安全性は署名検証に全依存:検証の手抜き(未検証・alg:none・甘い検証)が主な穴。
- 弱鍵はhashcatで割れる:HS256の鍵は長くランダムに。
- ヘッダ注入:jwk/jku/kid をサーバーが無検証で信用すると攻撃者の鍵で通る。
- アルゴリズム混同:alg未固定だと、公開鍵をHMAC鍵にされ偽造される。
- 根本対策:アルゴリズム固定 + 必ず検証 + jku/kid厳格化 + 長鍵 + 最新ライブラリ + exp/失効。
次は、ログイン機構そのものを狙う 認証の脆弱性の完全攻略 へ。