0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WEBエンジニアのための実感を持って学べるCSRF

0
Posted at

はじめに

Webエンジニアが実感を持って学べるように、CSRFの基本を筆者の主観でまとめました。

脆弱性は、応用や細かい攻撃手法は無数にあります。まず必要なのは原理原則の理解ではないでしょうか。

加えて「Same-Origin(同一オリジン)」と「Same-Site(同一サイト)」の区別などの関連する知識を把握することではないでしょうか。

「サッと読めて本質と関連技術を理解できる」

そんな記事を目指しました。

1. CSRFの原理

CSRFの本質は「ブラウザが自動でCookieを送信する性質を利用し、ユーザーの意図しない設定変更(パスワード変更など)のリクエストを発生させること」です。

以下の例でそれを体感しましょう。

2. CSRFを体感する

Node.jsを使って、ローカル環境でCSRFを再現してみます。

Step 1: hosts にダミーの別ドメインを2つ追加する

PCの hosts ファイルを管理者権限で開き、末尾に以下を追記します。

127.0.0.1 target-api.test
127.0.0.1 evil-attacker.test

target-api.testは正規のサイト(被害対象のAPIサーバー)。evil-attacker.testは攻撃者のサイトのイメージです。


Step 2: 被害対象のAPIサーバーを作成

これから作成するフォルダの構成は以下です。

csrf-demo/
  ├── node_modules/
  ├── package.json
  ├── package-lock.json
  ├── server.js
  └── evil.html

まずはフォルダを作成し、expressをインストールします。

mkdir csrf-demo && cd csrf-demo
npm init -y
npm install express cookie-parser

次にserver.jsを作成します。
※ここでは SameSite=None としています。これがCSRFを発生させるためのポイントです。

server.js

const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();

app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));

// 1. ログインエンドポイント(Cookieを発行)
app.get('/login', (req, res) => {
  // SameSite=None を指定
  res.cookie('session_id', 'user_secret_token_12345', {
    sameSite: 'none',
    secure: true, // HTTPS指定だが、ローカル環境ならHTTPでも動作確認可能
    httpOnly: true
  });
  res.send('<h1>ログイン完了</h1><p>session_id Cookie が発行されました。</p>');
});

// 2. パスワード変更エンドポイント(POST)
app.post('/change-password', (req, res) => {
  const sessionId = req.cookies.session_id;

  // 認証チェック(ログインで発行したセッションと同一なら通す)
  if (!sessionId || sessionId !== 'user_secret_token_12345') {
    return res.status(401).send('認証エラー:ログインしていません');
  }

  const newPassword = req.body.new_password;
  console.log(`[CSRF被害] パスワードが勝手に「${newPassword}」に変更されました!`);
  res.send(`パスワードを ${newPassword} に更新しました!`);
});

app.listen(3000, () => {
  console.log('API Server: http://target-api.test:3000');
});

以下のコマンドでAPIサーバーを起動します。

node server.js

Step 3: 攻撃者のページを作成(evil.html)

これは攻撃者が用意した罠サイトのイメージです。訪問した瞬間に、裏側でパスワード変更APIへPOSTを送信します。

evil.htmlを作成してください。

evil.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>懸賞当選おめでとう!</title>
</head>
<body>
  <h1>100万円が当選しました!画面をそのままお待ちください...</h1>

  <!-- 被害者のセッションを使って裏で勝手にPOSTするフォーム -->
  <form id="csrf-form" action="http://target-api.test:3000/change-password" method="POST">
    <input type="hidden" name="new_password" value="hacked_password_999">
  </form>

  <script>
    // ページが開いた瞬間に自動送信
    document.getElementById('csrf-form').submit();
  </script>
</body>
</html>

これをポート 4000 で起動します。

npx serve -l 4000 .

Step 4: 実験手順(罠を踏み、CSRFを発生させる)

ユーザーの意図せぬパスワード変更が行われるイメージです。手順は以下です。

  1. 正規のログインを行う:
    ブラウザで http://target-api.test:3000/login を開きます。
    画面に「ログイン完了」と表示されます。Cookieに session_id が保存されます。
  2. 罠サイトを踏む:
    同じブラウザの別タブで、攻撃者サイト http://evil-attacker.test:4000/evil.html を開きます。
  3. 結果を確認する:
  • 罠ページが開いた直後、自動で target-api.test:3000/change-password へPOSTリクエストが飛びます。
  • APIサーバーのコンソールを見ると、[CSRF被害] パスワードが勝手に「hacked_password_999」に変更されました! と表示されます。

なぜこれが成立するのか?

攻撃者側のコード(evil.html)には、被害者の session_id を読み取る処理はありません。 session_id は httpOnly: true で指定されておりJavaScriptは触れないためXSSを受ける余地もありません。

この攻撃が成立するのは、「ブラウザが自動でCookieを送信する」からです。Cookieには認証情報という、極めて重要な情報が保存されますが、ブラウザはそれを勝手に送信してしまうのです。

しかしながらそれには条件があります。Cookieの設定が SameSite: None であることです。

SameSiteはデフォルト設定が Lax であるため、意図的に設定しない限りNoneにはなりません。

以下で説明しますが、 SameSite: None と設定しなければならないケースは限られます。そのため現代のブラウザ環境では、CSRFは発生しづらく、WEBアプリエンジニアの多くが「知ってはいるが実感の伴った知識がない」状態になってしまっています。


3. 「Same-Origin」と「Same-Site」

SameSiteの説明をする前に、必要な知識の確認を行います。「Same-Origin」と「Same-Site」です。

似たような言葉ですが別物です。以下にまとめます。

  • Same-Origin(同一オリジン):

    • 基準: 「スキーム(http/https) + ホスト名 + ポート番号」が完全一致すること。
    • 使われる場所: SOP(同一オリジンポリシー)、CORS。
  • Same-Site(同一サイト):

    • 基準: ドメインの末尾2階層程度(レジストラに登録したドメイン、正確には eTLD+1)が一致すること。サブドメインやポート番号が異なっていても「同一」とみなされる。
    • 使われる場所: Cookie の SameSite 属性。

具体例で比較する

アクセス元(フロント) アクセス先(API) Same-Origin判定 Same-Site判定
https://example.com https://example.com/api 同一 同一 (Same-Site)
https://app.example.com https://api.example.com 別オリジン (CORS設定必須) 同一 (Same-Site)
https://my-app.vercel.app https://my-api.render.com 別オリジン (CORS設定必須) 別サイト (Cross-Site)

ここが最重要ポイントです。
「サブドメインが違うだけ(上記表の二行目)」ならば、別オリジンですが、同一サイトです。
そのため、SameSite=Lax や SameSite=Strict の設定でもCookieを送受信できます。LaxやStrictであれば、基本的にはCORSは防御されます。

例えば、フロントエンドはVercel、バックエンドはRenderを使用し「フロントエンドとバックエンドが同一サイトでない」場合は、Cookieを送信するには SameSite=None を指定する必要があります。
つまり、上記の例のようにCORSが発生する条件が整ってしまうのです。


4. SameSite属性とは

次は、SameSite属性の確認をします。

SameSite属性でLaxかStrictを指定すれば基本的にはCORSは発生しませんが、実装にミスがあればその限りではありません。
SameSiteについてはWEBエンジニアであれば正確に理解したいものです。

SameSiteの値:Strict, Lax, None

  • SameSite=Strict:

    • 他のサイトからのアクセス時は、一切Cookieを送信しない。
    • 最も安全だが、外部サイトやメールに貼られたリンクから遷移してきた際もCookieが送信されないため、実装方法によっては未ログイン状態となる。
  • SameSite=Lax:

    • トップレベルのGETアクセス(※)のみCookieを送信する。
      • ※トップレベルのアクセスとはアドレスバーのURLが切り替わる遷移(裏側でCSSや画像を取得する処理は対象外)
      • 正確にはGETに加え、HEADやOPTIONSといった安全なメソッド(Safe Method)も対象。
  • SameSite=None:

  • 常にCookieを送信する(ただしHTTPS指定にすることが必須)。

現代ブラウザでCORSが発生するケース。

  1. SameSite=None を付けざるを得ないアーキテクチャ:
    前述の通り、フロントエンドとAPIを別ドメイン(Cross-Site)でホストし、Cookie認証を使っている場合。
  2. 状態変更を「GETリクエスト」で行っている場合:
    Lax は「GETによる画面遷移」ではCookieを送信します。もしパスワード変更や退会処理などをGETメソッドで受け付ける実装をしていると、Lax設定でもCSRFが発生します。

5. CSRF対策

上述の通り、SameSiteがLaxかStrictであればCORSは発生しません。
しかしセキュリティ対策は多重対策が基本です。SameSite指定はブラウザの機能を使った対策です。
以下では実装側の対策としてCSRFトークンの説明をします。

対策: CSRFトークン(正規のリクエスト元である証明)

「ブラウザが自動で添えるCookieとは別に、正規のリクエスト発行元でしか添えられない値をリクエストに含めさせること」です。

例

  1. トークンの発行:
    ユーザーが画面を開いた際、サーバーが一時トークン(ランダムな文字列)を発行し、正規画面のHTML(<input type="hidden">)やJSに保持させる。
  2. リクエストへの付与:
    ユーザーが送信ボタンを押した際、そのトークンをリクエストのBodyやヘッダーに詰めて送信する。
  3. サーバーでの検証:
    サーバーは「Cookie」と「送られてきたトークン」の両方を照合する。

Cookieは、アクセス元によらず(SameSite=Noneの場合)ブラウザが勝手に添えてしまいますが、トークンは正規のリクエスト元(自社画面)でないと知りません。攻撃者のサイトからはトークンを埋め込むことができないため、サーバー側で「リクエスト元が正しくない」と判断できます。

※補足(セッションID自体のローテーションとの違い):
「リクエストごとにセッションIDを変えれば安全」と言われることがありますが、Cookie自体のセッションIDを毎リクエスト変更すると、APIの並列リクエスト発生時やブラウザの「戻る」ボタン押下時にもセッションが切れることになります。この要件を受け入れられるシステムは、そう多くはないでしょう。

また、

  • APIリクエストのヘッダーに認証トークンを含める。
  • 送金などの重要処理の前にワンタイムパスワード認証を必須とする。

といったこともCSRFの対策になります。

最後に

WEBエンジニアなら、
「CSRF対策ちゃんとされてる?」
と聞かれたら即答したいものです。

昨今ではVercelやRenderなどのPaaSを使用して、プロトタイプをサクッと開発する場面もあるでしょう。
こういう時にCSRF対策がなされていないと、情報漏洩に繋がりかねないですね。

技術が進むと、なぜか基本の技術に戻ってくると感じます。

以下はこのシリーズのXSS版です。
WEBエンジニアのための実感を持って学べるXSS(クロスサイトスクリプティング)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?