おい、ちょっといいか?
最近、チームでWeb APIの設計について話す機会があったよな。その時に「JWT(JSON Web Token)を使っています」って答えてくれたメンバーが何人かいたけど、正直なところ、ちょっと胸騒ぎがしたんだ。
JWTは便利だ。ステートレスでスケーラブルな認証・認可を実現する上で、これほど強力なツールはない。でもな、その便利さの裏には、巧妙に仕組まれた落とし穴がいくつも潜んでいる。今日の話は、その中でも特に根深く、未だに多くのシステムで見かける「alg=none攻撃」についてだ。
「え、alg=none?それって古い脆弱性じゃないですか?」って思った奴、いるだろ?
残念ながら、その認識が一番危ない。この手の脆弱性は、古くても有効な場面が山ほどある。そして、その「まさかうちのシステムでは」という油断こそが、サイバー攻撃者が最も狙いやすい盲点なんだ。
俺たちが何百と見てきたインシデント事例の中で、このalg=noneに起因する認証バイパスや特権昇格は決して少なくない。それは、ライブラリの「親切心」と、開発者の「ちょっとした見落とし」が重なった時に、静かに、そして致命的に発生するんだ。
今日は、この攻撃がどういう原理で、どうやってシステムを欺き、そして君たちの手でどうすれば完全に防御できるのかを、具体的なコードを交えながら徹底的に解説していく。コーヒーでも淹れて、じっくりと読んでくれ。
—
JWTの「顔」を理解する:ヘッダー、ペイロード、署名
まず、基本の「き」からおさらいしておこう。JWTは大きく3つの部分から構成されている。これらはすべてBase64 URLエンコードされており、ピリオド(.)で区切られているんだ。
1. ヘッダー (Header): トークンのタイプ(typ)と、使用されている署名アルゴリズム(alg)を記述するJSONオブジェクト。
例: {"alg": "HS256", "typ": "JWT"}
2. ペイロード (Payload): ユーザーID、ロール、有効期限など、実際の情報(クレーム)を記述するJSONオブジェクト。
例: {"sub": "1234567890", "name": "John Doe", "admin": true, "iat": 1516239022}
3. 署名 (Signature): ヘッダーとペイロードの内容が改ざんされていないことを検証するための部分。algで指定されたアルゴリズムと秘密鍵を使って計算される。
これらの要素が結合され、最終的に以下のような形式のJWTが生成されるわけだ。
Base64Url(Header).Base64Url(Payload).Base64Url(Signature)
ここで今日の主役となるのが、ヘッダー内の"alg"フィールドだ。こいつが、今回の攻撃の全ての入り口になる。
—
悪夢のシナリオ:alg=none攻撃のメカニズム
さて、本題に入ろう。alg=none攻撃とは一体何なのか。
JWTの仕様では、algフィールドに"none"という値が存在する。これは文字通り「署名なし」を意味する。本来、これは特殊なケースや、JWTが署名なしで利用されることを意図した設計上の選択肢だった。
しかし、多くのJWTライブラリがこの"none"を「有効なアルゴリズム」として解釈してしまう、という実装上の盲点があったんだ。
どういうことか?
攻撃者は、正当なJWTを入手した後、そのヘッダー部分を以下のように書き換える。
// オリジナルのヘッダー例
{
"alg": "HS256", // HS256アルゴリズムで署名されている
"typ": "JWT"
}
// 攻撃者が改変するヘッダー例
{
"alg": "none", // 署名なしと宣言
"typ": "JWT"
}
そして、この改変されたヘッダーと、自由に改ざんしたペイロード(例えば、"admin": falseを"admin": trueに書き換えるなど)を組み合わせて、署名部分を空にするか、全く無効な値をセットする。
この改変されたJWTをサーバーに送信すると、サーバー側のJWT検証ライブラリがどう反応するか、これが問題なんだ。
多くの脆弱なライブラリは、alg: "none"という宣言を見ると、「ああ、このトークンは署名がないんだな。それなら署名検証はスキップして、ペイロードの内容をそのまま信用しよう」と判断してしまうんだ。
つまり、署名検証を無効化し、攻撃者が意図した通りのペイロードをサーバーに解釈させてしまう。これがalg=none攻撃の恐ろしさだ。認証情報や権限を自由に書き換え、システムを騙すことができる。
現場の泥臭いインシデント:開発者の心理とライブラリの罠
「そんな単純なミス、うちのシステムではありえない」って思うかもしれないが、俺はこういうケースを何度も見てきた。
- 開発者の「とりあえず動かせばいいや」: 「なんかJWTの検証がうまくいかないな…そうだ、ライブラリのオプションで
algorithmsを指定しなかったら、全部受け入れるようになるんじゃないか?」という安易な発想。 - ライブラリのデフォルト挙動: 古いライブラリや、特定のオプションを指定しない限り
noneを許容してしまうライブラリが存在する。 - フレームワークの抽象化: フレームワークがJWTの検証処理をラップしてしまい、中の詳細なアルゴリズム指定が隠蔽されてしまうケース。開発者は「フレームワークに任せているから安全」と誤解してしまう。
- 「最新版を使っているから大丈夫」という幻想: ライブラリのバージョンが新しくても、正しい使い方をしなければ脆弱性は生まれる。重要なのは、その機能がどう動くのかを理解し、セキュアな設定を適用することだ。
この手の脆弱性は、大抵「急いで実装した」「よく分からないけど、ネットのサンプルコードをコピペした」といった状況で生まれる。そして、一度システムに組み込まれてしまうと、なかなか表面化しない厄介な特性を持っているんだ。
—
攻撃の実演:PoC (Proof of Concept)
実際にどうやって攻撃者がJWTを改ざんするのか、具体的なステップを見てみよう。
ステップ1: ターゲットとなるJWTの取得
まず、攻撃者は何らかの方法で正当なJWTを取得する。これは、例えばログイン後のCookieやAuthorizationヘッダーから盗み出すのが一般的だ。
例として、以下のようなJWTをターゲットとする。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
これをjwt.ioのようなツールでデコードすると、以下の情報が得られる。
ヘッダー:
{
"alg": "HS256",
"typ": "JWT"
}
ペイロード:
{
"sub": "1234567890",
"name": "John Doe",
"admin": false,
"iat": 1516239022
}
このユーザーはadmin: falseで、管理者権限を持っていない。
ステップ2: ヘッダーとペイロードの改変
次に、攻撃者はこのJWTのヘッダーとペイロードを改変する。目標は、admin: trueの権限を得ることだ。
1. ヘッダーの改変: "alg": "HS256" を "alg": "none" に書き換える。
{
"alg": "none",
"typ": "JWT"
}
これをBase64 URLエンコードすると: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
2. ペイロードの改変: "admin": false を "admin": true に書き換える。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022
}
これをBase64 URLエンコードすると: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0
ステップ3: 署名部分の削除(または無効化)と送信
最後に、改変したヘッダーとペイロードを結合し、署名部分を空にする(あるいは、適当な無効な値を付与する)。
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
この改変されたJWTを、認証が必要なAPIリクエストのAuthorizationヘッダーなどに含めてサーバーに送信する。
脆弱なシステムでは、このトークンを受信すると、"alg": "none"を見て署名検証をスキップし、ペイロードの"admin": trueを信用してしまう。結果として、攻撃者は管理者権限を取得してしまうわけだ。恐ろしいだろ?
—
完全防御策:コピペで動くセキュアな実装と設定
この攻撃に対する防御策は、基本に忠実かつ厳格であることだ。最も重要なのは、アプリケーション側で許可する署名アルゴリズムを明示的に指定すること。これが最も効果的な防御策であり、あらゆるJWTライブラリで共通して実践すべきことだ。
そして、多層防御の観点から、WAFなどのインフラ層でも対策を講じることで、より堅牢なシステムを構築できる。
1. アプリケーション層での防御(最も重要!)
JWTライブラリを使用する際、検証時に許可するアルゴリズムのリストを厳格に指定する。"none"がそのリストに含まれていなければ、脆弱なライブラリであってもこの攻撃は防げる。
PHP (Firebase/PHP-JWT) の例
<?php
require_once 'vendor/autoload.php'; // Composerでインストールした場合
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
/**
* JWTの検証とデコードを行う関数
* @param string $jwt_token 検証するJWT文字列
* @param string $secret_key JWT署名用の秘密鍵
* @return object|false デコードされたペイロードオブジェクト、または検証失敗時はfalse
*/
function verifyAndDecodeJwt(string $jwt_token, string $secret_key)
{
// JWTの有効期限検証時の許容誤差(秒)
// 例えば、ネットワーク遅延やサーバー間の時刻同期のズレを吸収するため
$leeway = 60;
// 🚨 ここが最重要ポイント 🚨
// 許可するアルゴリズムを明示的に配列で指定する。
// HS256以外のアルゴリズムを意図的に受け入れる場合は、ここに追加する。
// "none" は絶対に含めない。
$allowed_algorithms = ['HS256'];
try {
JWT::$leeway = $leeway; // 誤差を設定
// Keyオブジェクトを使って、秘密鍵とアルゴリズムを指定する
// 第3引数で許可するアルゴリズムの配列を渡すことで、"alg": "none"攻撃を防ぐ
$decoded = JWT::decode($jwt_token, new Key($secret_key, 'HS256'), $allowed_algorithms);
// デコード成功
return $decoded;
} catch (\Exception $e) {
// 検証失敗(署名不正、期限切れ、無効なアルゴリズムなど)
error_log("JWT検証失敗: " . $e->getMessage());
return false;
}
}
// 使用例
$secret = 'your_super_secret_key_here_for_HS256'; // 本番環境では環境変数などから安全に取得する
$valid_jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
$decoded_payload = verifyAndDecodeJwt($valid_jwt, $secret);
if ($decoded_payload) {
echo "JWT検証成功!\n";
print_r($decoded_payload);
// 認証・認可処理に進む
} else {
echo "JWT検証失敗。\n";
// エラーレスポンスを返す
}
// 🚨 攻撃者が改変したJWTの例 (alg=none) 🚨
$attack_jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.';
echo "\n--- alg=none攻撃の検証 ---\n";
$decoded_attack_payload = verifyAndDecodeJwt($attack_jwt, $secret);
if ($decoded_attack_payload) {
echo "JWT検証成功 (alg=none)!これは危険!\n"; // このメッセージは表示されないはず
} else {
echo "JWT検証失敗 (alg=none)!防御成功!\n"; // こちらが表示されることを確認
}
Python (PyJWT) の例
import jwt
from jwt.exceptions import InvalidTokenError, DecodeError, ExpiredSignatureError, InvalidAlgorithmError
def verify_and_decode_jwt(jwt_token: str, secret_key: str) -> dict | None:
"""
JWTの検証とデコードを行う関数
:param jwt_token: 検証するJWT文字列
:param secret_key: JWT署名用の秘密鍵
:return: デコードされたペイロード辞書、または検証失敗時はNone
"""
# 🚨 ここが最重要ポイント 🚨
# 許可するアルゴリズムを明示的にリストで指定する。
# HS256以外のアルゴリズムを意図的に受け入れる場合は、ここに追加する。
# "none" は絶対に含めない。
allowed_algorithms = ["HS256"]
try:
# algorithms引数で許可するアルゴリズムのリストを渡すことで、"alg": "none"攻撃を防ぐ
decoded_payload = jwt.decode(
jwt_token,
secret_key,
algorithms=allowed_algorithms,
# optional: verify_exp=True (有効期限の検証), verify_signature=True (署名の検証)
# これらはデフォルトでTrueなので明示的に指定しなくても良いが、意図を明確にするために記述することもある
)
return decoded_payload
except InvalidAlgorithmError:
print("JWT検証失敗: 許可されていないアルゴリズムが使用されました。")
return None
except ExpiredSignatureError:
print("JWT検証失敗: トークンの有効期限が切れています。")
return None
except InvalidTokenError as e:
print(f"JWT検証失敗: 無効なトークンです。詳細: {e}")
return None
except Exception as e:
print(f"予期せぬエラーが発生しました: {e}")
return None
# 使用例
secret = 'your_super_secret_key_here_for_HS256' # 本番環境では環境変数などから安全に取得する
valid_jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
decoded_payload = verify_and_decode_jwt(valid_jwt, secret)
if decoded_payload:
print("JWT検証成功!")
print(decoded_payload)
# 認証・認可処理に進む
else:
print("JWT検証失敗。")
# エラーレスポンスを返す
# 🚨 攻撃者が改変したJWTの例 (alg=none) 🚨
attack_jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.'
print("\n--- alg=none攻撃の検証 ---")
decoded_attack_payload = verify_and_decode_jwt(attack_jwt, secret)
if decoded_attack_payload:
print("JWT検証成功 (alg=none)!これは危険!") # このメッセージは表示されないはず
else:
print("JWT検証失敗 (alg=none)!防御成功!") # こちらが表示されることを確認
Node.js (jsonwebtoken) の例
const jwt = require('jsonwebtoken');
/**
* JWTの検証とデコードを行う関数
* @param {string} jwtToken 検証するJWT文字列
* @param {string} secretKey JWT署名用の秘密鍵
* @returns {object|null} デコードされたペイロードオブジェクト、または検証失敗時はnull
*/
function verifyAndDecodeJwt(jwtToken, secretKey) {
// 🚨 ここが最重要ポイント 🚨
// 許可するアルゴリズムを明示的に配列で指定する。
// HS256以外のアルゴリズムを意図的に受け入れる場合は、ここに追加する。
// "none" は絶対に含めない。
const allowedAlgorithms = ['HS256'];
try {
// algorithmsオプションで許可するアルゴリズムの配列を渡すことで、"alg": "none"攻撃を防ぐ
const decoded = jwt.verify(jwtToken, secretKey, {
algorithms: allowedAlgorithms,
// optional: ignoreExpiration: false (有効期限の検証), ignoreNotBefore: false (nbfクレームの検証)
// これらはデフォルトでfalseなので明示的に指定しなくても良いが、意図を明確にするために記述することもある
});
return decoded;
} catch (error) {
console.error(`JWT検証失敗: ${error.message}`);
return null;
}
}
// 使用例
const secret = 'your_super_secret_key_here_for_HS256'; // 本番環境では環境変数などから安全に取得する
const validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
let decodedPayload = verifyAndDecodeJwt(validJwt, secret);
if (decodedPayload) {
console.log("JWT検証成功!");
console.log(decodedPayload);
// 認証・認可処理に進む
} else {
console.log("JWT検証失敗。");
// エラーレスポンスを返す
}
// 🚨 攻撃者が改変したJWTの例 (alg=none) 🚨
const attackJwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.';
console.log("\n--- alg=none攻撃の検証 ---");
let decodedAttackPayload = verifyAndDecodeJwt(attackJwt, secret);
if (decodedAttackPayload) {
console.log("JWT検証成功 (alg=none)!これは危険!"); // このメッセージは表示されないはず
} else {
console.log("JWT検証失敗 (alg=none)!防御成功!"); // こちらが表示されることを確認
}
—
補足:非対称鍵(RS256など)の利用も検討する
今回のalg=none攻撃は、対称鍵(HS256など)と非対称鍵(RS256など)のどちらを使っているかに関わらず発生しうる。しかし、鍵管理の観点から言えば、非対称鍵の利用はセキュリティを高める。
- 対称鍵 (HS256): 署名と検証に同じ秘密鍵を使用する。サーバー側でこの秘密鍵が漏洩すると、攻撃者は任意のJWTを署名・検証できるようになる。
- 非対称鍵 (RS256): 署名には秘密鍵を、検証には公開鍵を使用する。公開鍵が漏洩しても、攻撃者は新しいJWTを署名することはできない。秘密鍵はサーバーサイドでのみ厳重に管理し、公開鍵はクライアントや他のサービスに配布する。
可能であれば、公開鍵暗号方式であるRS256などのアルゴリズムへの移行も検討してほしい。もちろん、その際にも許可するアルゴリズムは厳格に指定するのを忘れるな。
2. サーバー・インフラ層での防御(多層防御)
アプリケーション層での対策が最も重要だが、WAF(Web Application Firewall)やAPI Gatewayを使って、不正なリクエストをさらに手前でブロックする「多層防御」の考え方も重要だ。WAFは、アプリケーションが脆弱性を抱えていた場合の最後の砦となり得る。
WAFでの防御(ModSecurity + Nginx の例)
ModSecurityのようなWAFでは、HTTPリクエストのボディやヘッダーを検査し、特定のパターンにマッチした場合にブロックするルールを設定できる。
Nginx + ModSecurity 設定例:
# Nginxのhttpブロックまたはserverブロック内に記述
http {
# ModSecurityモジュールをロード
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/modsecurity.conf; # ModSecurityのメイン設定ファイルへのパス
server {
listen 80;
server_name example.com;
location /api/ {
# 🚨 ここにJWT alg=noneを検知するModSecurityルールを追加 🚨
# Authorizationヘッダーまたはリクエストボディ内のJWT文字列を検査
# "alg":"none" のパターンを正規表現で検知し、ブロックする
# 大文字小文字を区別しないように `i` フラグを使用
# SecRule REQUEST_HEADERS:Authorization|REQUEST_BODY "@rx \"alg\"\\s*:\\s*\"none\"" \
# "id:100001,phase:2,block,msg:'JWT alg=none attack detected',log,auditlog"
# より厳密に、Base64デコードされた部分を検査する例(複雑になるため、アプリケーション層での対策が基本)
# ここではシンプルに生のJWT文字列内のパターンを検知するルールを提示
#
# REQUEST_HEADERS:Authorization: Authorizationヘッダーを検査
# REQUEST_BODY: POST/PUTリクエストのボディを検査 (JWTがボディに含まれる場合)
# "@rx": 正規表現マッチング
# \"alg\"\\s*:\\s*\"none\": "alg":"none" または "alg" : "none" のようなパターンを検知
# id: ルールID (ユニークな値)
# phase: 2 (リクエストボディの検査フェーズ)
# block: マッチした場合にリクエストをブロック
# msg: ログに出力されるメッセージ
# log, auditlog: イベントをログに記録
SecRule REQUEST_HEADERS:Authorization|REQUEST_BODY "@rx \"alg\"\\s*:\\s*\"none\"" \
"id:9000001,\
phase:2,\
block,\
msg:'JWT alg=none detected - Possible Tampering Attempt',\
log,\
auditlog,\
severity:'CRITICAL'"
# proxy_passの設定など、通常のAPI処理
proxy_pass http://backend_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
ModSecurityの注意点:
- WAFは強力だが、あくまで補助的な防御策だ。最も信頼できるのはアプリケーションコードでの厳格な検証だ。
- 正規表現の誤設定は、正当なリクエストをブロックしたり、逆に攻撃を見逃したりするリスクがある。テストを十分に行うこと。
- Base64エンコードされたJWTの内部をWAFで完全に検査するには、より高度な設定やカスタムモジュールが必要になる場合がある。
クラウドWAF(AWS WAFなど)での防御
AWS WAFのようなクラウドWAFでも、カスタムルールを作成してalg=none攻撃を防ぐことができる。
AWS WAF カスタムルール設定例:
1. ルールタイプ: String match rule または Regex match rule を選択。
2. Part of the request to filter on:
HTTP headerを選択し、Header field nameにAuthorizationを指定。- JWTがリクエストボディに含まれる場合は、
Bodyも対象に含める。
3. Match type:
Contains stringを選択し、String to matchにalg":"noneを指定する。- または
Regex matchを選択し、Regex patternに\"alg\"\\s*:\\s*\"none\"を指定する。
4. Text transformation: NONE または LOWERCASE (正規表現によっては必要)
5. Action: BLOCK
これで、Authorizationヘッダーやリクエストボディ内に"alg":"none"パターンを含むリクエストをブロックできる。
—
まとめと次のステップ
今日の話、どうだった? JWTのalg=none攻撃は、古参の攻撃手法でありながら、未だにシステムに大きなダメージを与える可能性を秘めている。それは、ライブラリのデフォルト設定や開発者の見落としという、ソフトウェア開発につきものの「人間的な隙」を突くからだ。
改めて、この攻撃からシステムを守るための最も重要なポイントは、JWTライブラリで許可する署名アルゴリズムを明示的に、かつ厳格に指定することだ。そして、WAFなどのインフラ層での対策は、アプリケーション層の防御を補完する多層防御の一環として非常に有効だ。
君たちの手で、今一度、既存のシステムでJWTがどのように検証されているか、徹底的に見直してほしい。そして、新しいAPIを開発する際には、今日の話を思い出して、このセキュアな実装を最初から組み込んでくれ。
セキュリティは「言われたからやる」ものではない。「なぜ必要なのか」を理解し、自らの手で堅牢なシステムを築き上げる、クリエイティブな仕事だ。頼むぞ、未来のシステムは君たちにかかっているんだからな。
もし何か疑問があれば、いつでも俺に聞いてくれ。一緒に、よりセキュアなシステムを創っていこうじゃないか。
コメント