【入門編】 OAuth 2.0における認可コード横取り攻撃(Authorization Code Interception) – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

【OAuth 2.0セキュリティ】「認可コード横取り攻撃」からアカウントを守る!PKCE(ピクシー)の仕組みと正しい実装ガイド

みなさん、こんにちは!セキュリティエンジニアのWeb開発・防犯対策コラムへようこそ。

最近のWebサイトやスマホアプリでは、「Googleでログイン」や「LINEでログイン」といったソーシャルログイン機能(OAuth 2.0)をよく見かけますよね。パスワードを新しく覚える必要がなくて、ユーザーにとっても開発者にとっても本当に便利な仕組みです。

ですが、実装の仕方を一歩間違えると、攻撃者にログイン権限を丸ごと奪われてしまう「認可コード横取り攻撃(Authorization Code Interception)」という恐ろしいリスクが潜んでいるのをご存知でしょうか?

今回は、セキュリティに初めて触れる開発者や新人IT担当者のみなさんに向けて、この攻撃のメカニズムと、それを防ぐ最強の仕組みPKCE(Proof Key for Code Exchange:ピクシー)について、家の鍵や泥棒の例えを交えながら一歩ずつ丁寧に解説していきますね!

—

1. 認可コード横取り攻撃ってなに?(引換券を横取りする泥棒の例え)

まずは、OAuth 2.0でログインが行われる基本的な流れを「高級ホテルでの荷物の預かり」に例えてみましょう。

正常なログインの流れ

1. あなた(ユーザー)がアプリ(クライアント)に「ログインしたい」と伝えます。
2. アプリはあなたを認証サーバー(GoogleやLINEなど)に案内します。
3. あなたが本人確認を済ませると、認証サーバーは「後で本物の鍵(アクセストークン)と交換できる引換券(認可コード)」を発行します。
4. この引換券は、あなたのブラウザ(リダイレクトURI)を経由してアプリの元に届きます。
5. アプリは届いた引換券を認証サーバーに持っていき、「本物の鍵(アクセストークン)」を受け取ってログイン完了です!

[ ユーザー (ブラウザ) ]
    │  ① ログインしたい
    ▼
[ アプリ (クライアント) ]
    │  ② 認証ページへ案内
    ▼
[ 認証サーバー (Google等) ] ── (本人確認OK) ──┐
    │                                         │
    │  ③ 引換券(認可コード)を発行           │
    ▼                                         │
[ ユーザー (ブラウザ) ]                       │
    │  ④ 引換券をリダイレクトでアプリに渡す     │
    ▼                                         │
[ アプリ (クライアント) ] ◄───────────────────┘
    │  ⑤ 引換券を見せて「本物の鍵」と交換
    ▼
[ 認証サーバー (Google等) ] ── ⑥ 本物の鍵を渡す ──> ログイン成功!

一見、とてもスムーズで安全に見えますよね。しかし、ここには重大な盲点が存在します。

攻撃者が狙う盲点

攻撃者の目的は、「③〜④のタイミングで、ブラウザ経由で運ばれている引換券(認可コード)を盗み見ること」です。

特にスマホアプリ(iOSやAndroid)では、特定のURL(例: myapp://oauth-callback)が開かれた時にアプリを起動する仕組み(カスタムURLスキーム)が使われます。もし攻撃者が同じURLを受け取れる悪意ある偽アプリをスマホの中に忍ばせていたらどうなるでしょう?

本物のアプリよりも先に偽アプリが起動して引換券を横取りし、攻撃者が自分自身の端末で「本物の鍵」と交換してしまうのです。これが「認可コード横取り攻撃」です。結果として、あなたのアカウントは攻撃者に丸ごと乗っ取られてしまいます。

—

2. 救世主「PKCE(ピクシー)」登場!合言葉で引換券を守る仕組み

この横取り攻撃を防ぐために開発されたのが、PKCE(Proof Key for Code Exchange / ピクシー)という拡張仕様です。

PKCEの仕組みは、「受取人指定の合言葉(あいことば)」に例えると非常にイメージしやすくなります。

PKCEが防犯する仕組み

1. 合言葉を作成する
アプリはログインを開始する際、ランダムな秘密の文字列(code_verifier:合言葉の本物)を作ります。
2. 合言葉を暗号化して預ける
アプリは code_verifier をハッシュ化(不可逆な暗号化)した文字列(code_challenge:合言葉の暗号)を作成し、最初に認証サーバーへ預けます。
3. 引換券を盗まれても大丈夫!
攻撃者が運良く引換券(認可コード)を横取りできたとします。
4. 最後の鍵交換でチェック!
攻撃者は「引換券」を使って認証サーバーに本物の鍵を請求します。しかし、認証サーバーはこう言います。
「引換券は合っているけど、最初に預かった暗号を元に戻せる『本物の合言葉(code_verifier)』を見せてごらん?」
5. 攻撃者は合言葉の本物を知りません(本物の合言葉は正規アプリのメモリ内にしか存在しないため)。結果として認証サーバーは鍵の交付を拒否し、乗っ取りは失敗します!

【正規アプリ】「合言葉の暗号」を先に送信 ──┐
                                          │
【認可コード(引換券)】が盗まれても…     ▼
【攻撃者】「引換券があるから鍵をくれ!」 ───> [ 認証サーバー ]
                                                │
                                                │「本物の合言葉は?」
                                                ▼
                                          【攻撃者】「えっ…知らない…」
                                                │
                                                ▼
                                          ❌ 拒否!(乗っ取り失敗)

ね、とっても賢い仕組みですよね!これなら万が一引換券が盗まれても安心です。

—

3. ハンズオンで学ぶ!危険なリクエスト vs PKCEを適用した安全な実装

それでは、実際のコードやパラメーターを見ながら、どのようにPKCEを実装するのかを確認していきましょう。

❌ 危険な実装(PKCEなし)

PKCEを使わない従来の認可リクエストは、以下のように非常にシンプルですが、認可コードを横取りされたら一発アウトです。

GET /authorize?
  response_type=code&
  client_id=your_client_id&
  redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&
  scope=read HTTP/1.1
Host: authorization-server.com

⭕ 安全な実装(PKCEあり)

PKCEを適用したリクエストでは、パラメーターに code_challenge と code_challenge_method を追加します。

GET /authorize?
  response_type=code&
  client_id=your_client_id&
  redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&
  scope=read&
  code_challenge=E9MelBo2OwvGJmBD4bCR62pA_03A7pWfAvY1ek8a2PY&  <!-- 暗号化された合言葉 -->
  code_challenge_method=S256 HTTP/1.1                           <!-- ハッシュ化アルゴリズム(SHA-256) -->
Host: authorization-server.com

—

クライアント側(JavaScript/Node.js)でのPKCE実装例

実際にフロントエンドやアプリ側で code_verifier と code_challenge を生成するサンプルコードを見てみましょう。

// cryptoモジュールを使用(ブラウザ環境では window.crypto を利用)
const crypto = require('crypto');

// 1. ランダムなランダム文字列(code_verifier)を生成する関数
function generateCodeVerifier() {
    // 32バイトのランダムデータをBase64URLエンコード(43〜128文字の長さが必要)
    return crypto.randomBytes(32)
        .toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

// 2. code_verifier から SHA-256 ハッシュ(code_challenge)を計算する関数
function generateCodeChallenge(codeVerifier) {
    const hash = crypto.createHash('sha256').update(codeVerifier).digest();
    // SHA-256ハッシュをBase64URLエンコード
    return hash.toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

// --- 実際の処理の流れ ---

// ① 合言葉の本物(code_verifier)を生成し、セッションやローカルに安全に保存しておく
const codeVerifier = generateCodeVerifier();
// ※実務ではここで codeVerifier を Cookie や SessionStorage に退避させます!

// ② 合言葉の暗号(code_challenge)を生成する
const codeChallenge = generateCodeChallenge(codeVerifier);

console.log('認証サーバーに送る暗号 (code_challenge):', codeChallenge);
console.log('手元に隠しておく合言葉 (code_verifier):', codeVerifier);

// ③ ログイン画面へリダイレクトするURLを作成
const authUrl = `https://authorization-server.com/authorize?` +
    `response_type=code` +
    `&client_id=my_client_id` +
    `&redirect_uri=${encodeURIComponent('https://example.com/callback')}` +
    `&code_challenge=${codeChallenge}` +
    `&code_challenge_method=S256`; // 必ずS256を指定しましょう!

console.log('リダイレクト先URL:', authUrl);

—

サーバー側(認可コードとトークンの交換時)の検証実装例

認可コードを受け取り、最後にトークン(本物の鍵)と交換する際のバックエンド側の実装イメージです。

// Express.js などを想定したトークン交換エンドポイントの例
app.post('/oauth/token', async (req, res) => {
    const { code, code_verifier } = req.body;

    // 1. ユーザーのセッションから、最初に預かった code_challenge を取得
    const savedCodeChallenge = req.session.code_challenge;

    // 2. クライアントから送られてきた code_verifier を同じ方法(SHA-256)でハッシュ化してみる
    const calculatedChallenge = generateCodeChallenge(code_verifier);

    // 3. 【重要】最初に預かった暗号と、今計算した暗号が一致するかチェック!
    if (calculatedChallenge !== savedCodeChallenge) {
        // 一致しない場合は、横取りされた悪意あるリクエストと判定して拒否!
        console.error('[警告]PKCE検証失敗!認可コードの横取り攻撃の可能性があります。');
        return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed.' });
    }

    // 4. 検証成功!安心してアクセストークン(本物の鍵)を発行します
    const accessToken = generateAccessToken();
    res.json({ access_token: accessToken, token_type: 'Bearer' });
});

—

4. 現場で見落としがちな盲点(レッドチーム視点のアドバイス)

私たちセキュリティエンジニアが実際のペネトレーションテスト(侵入テスト)を行う際、開発者のみなさんが「PKCEを導入したから100%安心!」と思い込んでしまい、新たな穴を作ってしまうケースをよく見かけます。

現場で特に注意すべき盲点を2つ共有しますね!

盲点①: code_challenge_method=plain を使ってしまう

PKCEにはハッシュ化を行う S256 のほかに、暗号化せずそのまま文字列を送る plain という形式が存在します。

しかし、plain を使うと、最初に送信する code_challenge そのものが code_verifier と同じになってしまいます。ネットワーク通信を盗聴された場合、合言葉が丸見えになってしまい、PKCEの意味がなくなってしまいます。

👉 対策: 必ず code_challenge_method=S256 を使用し、サーバー側でも plain の受け入れを拒否するように設定しましょう!

盲点②: リダイレクトURIのワイルドカード指定

認可コードが送信される redirect_uri のチェックが甘く、以下のようにワイルドカード(*)で設定されているケースがあります。

❌ 不適切な設定例: https://example.com/*

このように設定されていると、同じドメイン内にあるオープンリダイレクト(意図しない外部サイトへ転送してしまう脆弱性)のあるページを経由して、攻撃者のサーバーへ認可コードが流出してしまう恐れがあります。

👉 対策: redirect_uri は省略したり曖昧にせず、完全一致(例: https://example.com/oauth/callback)で厳密に検証してください。

—

5. まとめ:一歩ずつ安全なWebアプリを作っていきましょう!

OAuth 2.0における「認可コード横取り攻撃」と、それを防ぐ「PKCE」の仕組みについて解説してきました。

おさらいしましょう!

1. 認可コードは本物の鍵と交換するための「引換券」であり、ブラウザ経由でやり取りされるため横取りされるリスクがある。
2. PKCE(ピクシー)は、あらかじめ「合言葉の暗号」を預けておき、鍵の交換時に「本物の合言葉」を提示させる安全な仕組み。
3. 実装の際は必ず code_challenge_method=S256 を指定し、リダイレクトURIも厳密に設定する。

現在は、SPAs(シングルページアプリケーション)やモバイルアプリだけでなく、すべてのWebアプリケーションでPKCEの導入(OAuth 2.1標準)が強く推奨されています。

一見難しそうに見えるセキュリティ仕様も、仕組みと「なぜ必要なのか」という理由を分解していけば、決して怖いものではありません。

ユーザーの安全と大切なデータを守るために、ぜひみなさんのプロジェクトでもPKCEの導入を進めてみてくださいね。一歩ずつ対策を学んで、安全で快適なWebサービスを作り上げていきましょう!

それでは、また次回のセキュリティ解説でお会いしましょう!

コメント

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