【実務・中級編】 OpenID ConnectのIDトークン検証不備によるなりすまし – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

OpenID ConnectのIDトークン検証不備を突く「なりすまし」攻撃:実例と堅牢な防御策

お疲れ様。今日も今日とて、世の中には数えきれないほどのWebアプリケーションが日々デプロイされている。その一方で、見えないところで静かに、しかし確実に、攻撃者はその隙を伺っている。特に、認証周りは常に狙われやすい。今回は、比較的新しい認証・認可フレームワークであるOpenID Connect (OIDC) における、IDトークンの検証不備を突く「なりすまし」攻撃について、現場で実際に遭遇した事例を交えながら、その手口と、どうすれば堅牢に防御できるのかを、皆にわかりやすく、そして実務に直結する形で解説していこう。

なぜ、OIDCのIDトークン検証が重要なのか?

OpenID Connectは、OAuth 2.0を基盤とした認証レイヤーで、IDトークンというJWT (JSON Web Token) を使って、ユーザーの身元情報をやり取りする。このIDトークンは、認証サーバー(IdP: Identity Provider)が発行し、クライアントアプリケーション(RP: Relying Party)がそれを受け取って、ユーザーが確かに本人であることを確認する。

ここで重要なのは、IDトークンは「誰が」発行し、「誰のために」発行されたのか、そしてその発行されたIDトークンが「改ざんされていないか」を、クライアントアプリケーションが厳格に検証する必要があるという点だ。この検証が甘いと、攻撃者は巧妙に細工したIDトークンを送りつけ、あたかも正当なユーザーであるかのように振る舞うことが可能になる。いわゆる、認証バイパスやなりすましだ。

攻撃者の視点:IDトークン検証の「盲点」を探る

攻撃者がIDトークン検証の不備を突く際、主に以下の3つのポイントを狙ってくる。

1. 署名検証のスキップまたは不十分な検証:
IDトークンは、発行者(IdP)の秘密鍵で署名されている。クライアントアプリケーションは、公開鍵暗号方式を用いて、この署名が正当な発行者によって行われたものであることを検証しなければならない。しかし、開発者がこの署名検証を実装し忘れたり、あるいは(例えば、RSA署名なのにHMAC署名と誤って検証したり、検証アルゴリズムが不正だったり)不正確な方法で検証したりすると、攻撃者は署名のない、あるいは偽の署名が付いたトークンでも受け入れさせてしまうことができる。

2. 発行者 (iss) クレームの検証不足:
IDトークンには、issというクレーム(キーと値のペア)があり、これはIDトークンを発行したIdPの識別子を示す。クライアントアプリケーションは、このissクレームが、想定しているIdPのものと一致するかどうかを確認する必要がある。もし、この検証が甘いと、攻撃者は自ら用意した、あるいは侵害された他のIdPから発行されたIDトークンを送りつけ、クライアントアプリケーションを騙すことができる。

3. オーディエンス (aud) クレームの検証不足:
audクレームは、そのIDトークンが「誰(どのクライアントアプリケーション)のために」発行されたのかを示す。クライアントアプリケーションは、このaudクレームに、自身のクライアントIDが含まれていることを確認する必要がある。これが不十分だと、攻撃者は他のアプリケーションのために発行されたIDトークンを、自分のアプリケーションで利用しようとするかもしれない。

実践的な攻撃シナリオ(PoC)とそのリスク

では、具体的にどのような攻撃が行われるのか、簡単なシナリオを見てみよう。

シナリオ:署名検証をスキップしてIDトークンを偽造する

あるWebアプリケーション(RP)が、外部のIdPとOIDC連携しているとする。このRPでは、IdPから受け取ったIDトークンの署名を検証する処理が実装されているが、その実装に不備があり、実際には署名検証が行われていない、あるいは常にtrueを返すような状況だと仮定しよう。

攻撃者は、まず正規のIDトークンを傍受するか、あるいはダミーのIDトークンを生成する。そして、そのIDトークン内のペイロード(クレーム)を改ざんする。例えば、ユーザーIDを攻撃者のものに変更したり、管理者権限を示すクレームを追加したりする。

// 正規のIDトークン(例)
{
  "iss": "https://accounts.google.com", // 発行者
  "aud": "your_client_id",              // オーディエンス(RPのクライアントID)
  "sub": "1234567890",                  // ユーザーID
  "exp": 1678886400,                    // 有効期限
  "iat": 1678882800,                    // 発行日時
  "name": "正当なユーザー",
  "email": "user@example.com"
}

攻撃者は、このペイロードを以下のように改ざんする。

// 攻撃者が偽造したIDトークン(例)
{
  "iss": "https://accounts.google.com", // 発行者は正規のものと同じに偽装
  "aud": "your_client_id",              // オーディエンスも正規のものと同じに偽装
  "sub": "attacker_id",                 // ユーザーIDを攻撃者のものに変更!
  "exp": 1678886400,                    // 有効期限は適当に設定
  "iat": 1678882800,                    // 発行日時も適当に設定
  "name": "なりすましユーザー",
  "email": "attacker@example.com",
  "admin": true                         // 管理者権限を示すクレームを追加(RP側でこのクレームをチェックする場合)
}

そして、この改ざんされたペイロードに対して、署名検証をスキップしたRPに送信する。RPが署名検証を甘く見ていると、この偽造されたIDトークンを正当なものとして受け入れ、subクレームの値(attacker_id)を持つユーザーとしてログインを許可してしまう。これにより、攻撃者は正規ユーザーになりすましてシステムに侵入できる。

リスク:

  • 認証バイパス: 攻撃者が正規ユーザーとしてログインできてしまう。
  • 権限昇格: admin: trueのようなクレームを改ざんして、管理者権限を奪取する可能性がある。
  • 情報漏洩: ログインしたユーザーの権限で、機密情報にアクセスされる可能性がある。
  • サービス妨害: 攻撃者が不正な操作を行い、サービスを停止させる可能性がある。

堅牢な防御策:セキュアな実装サンプルコードと設定

では、このような攻撃を防ぐためには、具体的にどのように実装すれば良いのか。ここからは、PHP、Python、JavaScriptのサンプルコードと、WAFやNginxの設定例を交えて解説していく。

1. 署名検証の徹底

IDトークンを受け取ったら、まず最初に、IdPの公開鍵を使って署名を検証する。この際、JWTライブラリを適切に利用することが重要だ。

PHPでの実装例(firebase/php-jwtライブラリを使用)

<?php

require 'vendor/autoload.php'; // Composerでfirebase/php-jwtをインストールしている場合

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

// --- 設定値 ---
$idToken = $_POST['id_token'] ?? ''; // クライアントから受け取ったIDトークン
$idpPublicKeysUrl = 'https://accounts.google.com/.well-known/openid-configuration'; // IdPの公開鍵取得URL(例:Googleの場合)
$clientId = 'your_client_id'; // あなたのRPのクライアントID

// --- 1. IdPの公開鍵を取得 ---
function getIdpPublicKeys(string $keysUrl): array
{
    $json = file_get_contents($keysUrl);
    $config = json_decode($json, true);
    $jwksUri = $config['jwks_uri'];

    $jwksJson = file_get_contents($jwksUri);
    $jwks = json_decode($jwksJson, true);

    // キーを連想配列形式に変換(kidで検索しやすくするため)
    $keys = [];
    foreach ($jwks['keys'] as $key) {
        if (isset($key['kid'])) {
            $keys[$key['kid']] = $key;
        }
    }
    return $keys;
}

try {
    $idpPublicKeys = getIdpPublicKeys($idpPublicKeysUrl);

    // IDトークンのヘッダーからkidを取得
    $header = JWT::urlsafeB64Decode(explode('.', $idToken)[0]);
    $decodedHeader = json_decode($header, true);
    $kid = $decodedHeader['kid'] ?? null;

    if (!$kid || !isset($idpPublicKeys[$kid])) {
        throw new Exception('Kid not found or invalid.');
    }

    $publicKey = $idpPublicKeys[$kid];

    // --- 2. 署名検証 ---
    // JWTライブラリに公開鍵を指定して検証
    // アルゴリズムは 'RS256' など、IdPが使用しているものに合わせる
    $decoded = JWT::decode($idToken, new Key($publicKey, 'RS256')); // ここで署名検証が行われる

    // --- 3. iss (発行者) クレームの検証 ---
    // IdPが指定する発行者URIと一致するか確認
    $expectedIss = 'https://accounts.google.com'; // 例:Googleの場合
    if ($decoded->iss !== $expectedIss) {
        throw new Exception('Invalid issuer.');
    }

    // --- 4. aud (オーディエンス) クレームの検証 ---
    // IDトークンがあなたのクライアントIDのために発行されたか確認
    // audクレームは文字列または配列の可能性がある
    if (is_array($decoded->aud)) {
        if (!in_array($clientId, $decoded->aud)) {
            throw new Exception('Invalid audience.');
        }
    } else {
        if ($decoded->aud !== $clientId) {
            throw new Exception('Invalid audience.');
        }
    }

    // --- 5. exp (有効期限) クレームの検証(JWTライブラリが自動で行う場合も多いが、念のため) ---
    // 現在時刻が有効期限を過ぎていないか確認
    if ($decoded->exp < time()) {
        throw new Exception('Token expired.');
    }

    // --- 検証成功! ---
    // ここで、ユーザーID ($decoded->sub) を使って、セッションを開始するなど、
    // アプリケーション固有の処理を行う
    echo "ID Token is valid. User ID: " . htmlspecialchars($decoded->sub);

} catch (Exception $e) {
    // 検証失敗時のエラーハンドリング
    error_log("ID Token validation failed: " . $e->getMessage());
    http_response_code(401); // Unauthorized
    echo "Authentication failed.";
}

?>

Pythonでの実装例(PyJWTライブラリを使用)

import jwt
import requests
import time

# --- 設定値 ---
id_token = request.form.get('id_token') # クライアントから受け取ったIDトークン
idp_keys_url = 'https://accounts.google.com/.well-known/openid-configuration' # IdPの公開鍵取得URL
client_id = 'your_client_id' # あなたのRPのクライアントID
expected_iss = 'https://accounts.google.com' # 想定される発行者

# --- 1. IdPの公開鍵を取得 ---
def get_idp_public_keys(keys_url):
    response = requests.get(keys_url)
    config = response.json()
    jwks_uri = config['jwks_uri']

    jwks_response = requests.get(jwks_uri)
    jwks = jwks_response.json()

    # キーを辞書形式に変換 (kidで検索しやすくするため)
    keys = {}
    for key in jwks['keys']:
        if 'kid' in key:
            keys[key['kid']] = key
    return keys

try:
    idp_public_keys = get_idp_public_keys(idp_keys_url)

    # IDトークンのヘッダーからkidを取得
    header = jwt.get_unverified_header(id_token)
    kid = header.get('kid')

    if not kid or kid not in idp_public_keys:
        raise ValueError('Kid not found or invalid.')

    public_key_data = idp_public_keys[kid]

    # PEM形式の公開鍵に変換(RSA鍵の場合)
    from cryptography.hazmat.primitives import serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography.hazmat.backends import default_backend

    public_key = serialization.load_pem_public_key(
        public_key_data['n'].encode('utf-8'), # 実際には、JWKからPEMへの変換が必要
        backend=default_backend()
    )
    # 上記は簡略化。実際にはjwk_to_pemなどのライブラリや実装が必要。
    # より簡単な例として、IdPから直接PEM形式で取得できる場合はそちらを使う。
    # 例:https://www.googleapis.com/oauth2/v3/certs (Googleの場合、PEM形式の公開鍵が直接取得できる)

    # --- 2. 署名検証 ---
    # audience (aud) と issuer (iss) は、decodeメソッドの引数で指定し、自動検証させる
    decoded_payload = jwt.decode(
        id_token,
        public_key, # ここで署名検証が行われる
        algorithms=["RS256"], # IdPが使用するアルゴリズムに合わせる
        audience=client_id,
        issuer=expected_iss,
        leeway=10 # 有効期限の許容範囲(秒)
    )

    # --- 検証成功! ---
    # decoded_payload['sub'] にユーザーIDが含まれる
    user_id = decoded_payload.get('sub')
    print(f"ID Token is valid. User ID: {user_id}")

except jwt.ExpiredSignatureError:
    print("Error: Token has expired.")
    # 401 Unauthorized
except jwt.InvalidAudienceError:
    print("Error: Invalid audience.")
    # 401 Unauthorized
except jwt.InvalidIssuerError:
    print("Error: Invalid issuer.")
    # 401 Unauthorized
except Exception as e:
    print(f"Error: {e}")
    # 401 Unauthorized

2. 発行者 (iss) とオーディエンス (aud) の厳格な検証

IDトークンを受け取ったら、issクレームが信頼できるIdPのものであること、そしてaudクレームに自らのクライアントIDが含まれていることを、必ず確認する。

JavaScript (Node.js) での実装例 (jsonwebtokenライブラリを使用)

const jwt = require('jsonwebtoken');
const jwkToPem = require('jwk-to-pem'); // npm install jwk-to-pem
const axios = require('axios'); // npm install axios

// --- 設定値 ---
const idToken = req.body.id_token; // クライアントから受け取ったIDトークン
const idpKeysUrl = 'https://accounts.google.com/.well-known/openid-configuration'; // IdPの公開鍵取得URL
const clientId = 'your_client_id'; // あなたのRPのクライアントID
const expectedIss = 'https://accounts.google.com'; // 想定される発行者

async function verifyIdToken(token) {
    try {
        // 1. IdPの設定と公開鍵を取得
        const configResponse = await axios.get(idpKeysUrl);
        const jwksUri = configResponse.data.jwks_uri;
        const jwksResponse = await axios.get(jwksUri);
        const keys = jwksResponse.data.keys;

        // IDトークンヘッダーからkidを取得
        const header = jwt.decode(token, { complete: true }).header;
        const kid = header.kid;

        // kidに一致する公開鍵を検索
        const jwk = keys.find(key => key.kid === kid);
        if (!jwk) {
            throw new Error('Public key not found for the given kid.');
        }

        // JWKからPEM形式の公開鍵に変換
        const pem = jwkToPem(jwk);

        // 2. 署名検証、iss, aud, exp の検証
        const decoded = jwt.verify(token, pem, {
            algorithms: ['RS256'], // IdPが使用するアルゴリズム
            audience: clientId,     // audクレームの検証
            issuer: expectedIss     // issクレームの検証
        });

        // 3. 検証成功!
        // decodedオブジェクトにペイロードが含まれる。decoded.sub がユーザーID
        console.log('ID Token is valid. User ID:', decoded.sub);
        return decoded; // 有効なトークンであれば、ペイロードを返す

    } catch (error) {
        console.error('ID Token verification failed:', error.message);
        throw new Error('Authentication failed.'); // エラーを再スローして、呼び出し元で401などを返す
    }
}

// 関数の呼び出し例
verifyIdToken(idToken)
    .then(payload => {
        // ログイン処理など
        res.status(200).send(`Welcome, user ${payload.sub}`);
    })
    .catch(err => {
        res.status(401).send(err.message);
    });

3. WAFやリバースプロキシによる対策

アプリケーションコードでの検証は必須だが、それに加えて、WAF (Web Application Firewall) やリバースプロキシ(Nginxなど)で、不正なリクエストを早期にブロックすることも有効だ。

WAFの設定例(一般的なルール):

  • IDトークン形式のチェック:
  • POSTリクエストのボディにid_tokenというパラメータが存在するかどうか。
  • id_tokenの値が、JWTの一般的な形式(3つの部分が.で区切られ、Base64エンコードされている)に合致するかどうか。
  • 例:id_token=ey... のようなパターンをチェック。
  • 既知の脆弱性パターン:
  • alg: noneのような、署名検証をスキップさせるようなクレームが含まれていないかチェック。
  • Base64エンコードされたペイロード部分に、不審な文字列(例: <script>タグなど、XSSを誘発する可能性のあるもの)が含まれていないか。
  • レートリミット:
  • 短時間に大量のIDトークンリクエストが来る場合、ブロックする。

Nginxの設定例(リバースプロキシでの基本的なチェック):

# /etc/nginx/conf.d/my_app.conf

server {
    listen 80;
    server_name your-app.com;

    location /auth/callback { # IDトークンを受け取るエンドポイント
        # IDトークンパラメータの検証 (正規表現で簡易チェック)
        # payload部分がBase64エンコードされていることを想定
        # 実際には、より厳密なバリデーションが必要
        if ($request_body ~* "id_token=ey[A-Za-z0-9+/=]+\.[A-Za-z0-9+/=]+\.[A-Za-z0-9+/=]+") {
            # 有効なIDトークン形式と判断し、アプリケーションサーバーへ転送
            proxy_pass http://your_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;
            proxy_set_header X-Forwarded-Proto $scheme;
            break; # 条件に合致したら、以降のlocationディレクティブは実行しない
        }

        # IDトークン形式が不正な場合、400 Bad Request を返す
        return 400;
    }

    # その他のlocation設定
    location / {
        proxy_pass http://your_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;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

注意: Nginx単体でJWTの厳密な検証を行うのは複雑です。上記はあくまで簡易的な形式チェックであり、最終的な検証はアプリケーション側で行う必要があります。

4. OAuth 2.0 / OIDCライブラリの利用

もし、自分でOIDCのフローをゼロから実装しているなら、それは避けるべきだ。多くの言語で、実績のあるOAuth 2.0 / OIDCクライアントライブラリが存在する。これらを利用することで、面倒で間違いやすい検証ロジックを、信頼できる実装に任せることができる。

  • PHP: league/oauth2-client (OIDC拡張あり), firebase/php-jwt (JWT操作用)
  • Python: authlib, PyJWT
  • JavaScript (Node.js): openid-client, jsonwebtoken, jwk-to-pem
  • Java: Spring Security OAuth, Nimbus JOSE+JWT

これらのライブラリは、署名検証、クレーム検証、トークン更新など、OIDCの複雑な仕様を正しく実装してくれる。開発者は、ライブラリの設定(クライアントID、シークレット、IdPのエンドポイントなど)を正しく行い、ライブラリが提供するコールバック処理で、検証済みのユーザー情報を安全に利用することに集中できる。

まとめ:信頼は、厳格な検証の上に成り立つ

OpenID Connectは、現代的な認証メカニズムとして非常に強力だが、その安全性を確保するには、IDトークンの検証プロセスを徹底することが不可欠だ。

  • 署名検証は、IDトークンが「改ざんされていないか」を確認する生命線。
  • 発行者 (iss) の検証は、IDトークンが「信頼できるIdPから発行されたか」を確認する。
  • オーディエンス (aud) の検証は、IDトークンが「まさにあなたのアプリケーションのために発行されたか」を確認する。

これらの検証が一つでも欠けていると、攻撃者は巧妙な手口でシステムに侵入し、なりすましや情報漏洩を引き起こす可能性がある。

今回紹介したサンプルコードや設定例は、あくまで基本的なものだ。実際のシステムでは、IdPのドキュメントをよく読み、利用しているライブラリのドキュメントに従い、すべての検証ステップを厳格に実装・確認してほしい。

何よりも重要なのは、「このIDトークンは絶対的に信頼できる」という前提に立って開発を進めるのではなく、「常に疑って、厳格に検証する」という姿勢だ。この基本原則を守ることで、皆のシステムはより堅牢になり、ユーザーは安心してサービスを利用できるようになるだろう。

何か不明な点があれば、いつでも聞いてくれ。明日の安全は、今日の我々の努力にかかっている。

コメント

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