コース目次 / 第1章
XSS 三種 — 反射・保存・DOM
XSSの三つの型を並べて見比べます。保存型を自作ゲストブックで発火させ、反射・DOMとの配送経路の違いを掴みます。直しは文脈別の出力エスケープ、補助にCSP。
XSS には三つの型があります。旗艦で見た反射型、試食で見たDOM 型、そしてこの章で新しく起こす保存型。まず違いを、配送経路で並べます。
反射型 — 攻撃コードは URL(?q=…)に乗り、レスポンスに跳ね返って動く。攻撃者は被害者に URL を踏ませる必要がある。
保存型(stored) — 攻撃コードがサーバに保存され、そのページを開いた全員の画面で動く。URL を踏ませる必要すらない。いちばん影響が大きい。
DOM 型 — サーバを介さず、ブラウザ側の JS が入力を innerHTML などに流し込んで動く(試食で見た形)。
三つとも、根は同じ——入力が HTML/JS として解釈される。違うのは「どこを経由して、誰の画面で動くか」だけです。
この章では、いちばん怖い保存型を、自分の手で起こします。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
標的 — 自作ゲストブック
下を vuln-guestbook.js として保存し、起動します。投稿がサーバのメモリに保存され、一覧に表示される、最小のゲストブックです。
// vuln-guestbook.js — 学習用「わざと穴を空けた」ゲストブック。127.0.0.1 だけで動かす。
// 狙う穴: 保存型 XSS(投稿がそのまま保存され、閲覧者全員の画面で動く)。
const http = require('node:http');
const messages = []; // サーバのメモリに保存する = 保存型の舞台
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:3300');
if (req.method === 'POST' && url.pathname === '/post') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const text = new URLSearchParams(body).get('text') || '';
messages.push(text); // 入力をそのまま保存する
res.writeHead(302, { Location: '/' });
res.end();
});
return;
}
// 一覧表示。【穴】保存された投稿を、エスケープせず HTML に埋め込む。
const list = messages.map((m) => '<li>' + m + '</li>').join('');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif">' +
'<form method="post" action="/post"><input name="text" size="40"><button>投稿</button></form>' +
'<ul>' + list + '</ul></body>');
});
server.listen(3300, '127.0.0.1', () => console.log('やられゲストブック: http://127.0.0.1:3300 (Ctrl+C で停止)'));
node vuln-guestbook.js
攻撃 — 一度書けば、開いた全員に効く
http://127.0.0.1:3300/ を開き、フォームに普通の文章を投稿すると、一覧に並びます。では、投稿欄にこれを入れて投稿します。
<img src=x onerror=alert('stored XSS')>
投稿した瞬間、そしてページを再読み込みするたび、alert が出ます。ここが反射型との決定的な違いです——攻撃コードはサーバに保存されたので、この URL(/)を開いた人は全員、何も踏まなくても被弾します。攻撃者が一度書き込むだけで、以後の全訪問者が標的になる。だから保存型は、XSS の中で最も影響が大きい。alert がセッション窃取のコードなら、閲覧者全員のセッションが順に奪われます。
修正 — 出力時にエスケープする
原因は旗艦の反射 XSS と同じ、出力時の未エスケープ。保存する時点ではなく、画面に出す直前でエスケープします(保存データは他の用途にも使うので、加工せず生で持ち、出力の文脈ごとに変換するのが定石)。
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
);
}
// 一覧表示の行を、エスケープしてから組み立てる。
const list = messages.map((m) => '<li>' + escapeHtml(m) + '</li>').join('');
再検証 — 同じ投稿を、もう一度
サーバを再起動して(Ctrl+C → node vuln-guestbook.js)、さっきと同じ <img src=x onerror=alert('stored XSS')> を投稿します。今度は alert は出ず、<img src=x onerror=alert('stored XSS')> という文字列がそのまま一覧に表示されるだけ。保存型が、止まりました。
エスケープは、出す先で変わる
ここが XSS のいちばん大事な一般化です。今回は HTML の本文に出したので HTML エスケープでしたが——
同じ値でも、出す先の文脈が変われば、正しいエスケープも変わります。
・HTML 本文 → < > & をエスケープ
・HTML 属性の中(value=“…”)→ 加えて引用符のエスケープ
・<script> の中(JS 文字列)→ JS としてのエスケープ
・URL の一部 → URL エンコード
「入力時に一括で消す」では、この文脈差に対応できません。だから出す直前に、その場所用のエスケープ。テンプレートエンジンが文脈を見て自動エスケープしてくれるのは、この難しさを引き受けているからです。
補助の壁 — CSP
出力エスケープが本命の守りですが、もう一枚重ねられます。CSP(Content-Security-Policy)——「このページで実行してよいスクリプトの出どころ」をサーバがヘッダで宣言する仕組みです。
インラインスクリプトや onerror= のようなイベント属性を CSP で禁じておけば、万一エスケープを一箇所忘れても、注入されたスクリプトがブラウザ側で実行を拒まれます。これは「エスケープの代わり」ではなく多層防御(defense in depth)——本命(エスケープ)が破れたときの、二枚目の壁です。この Tsumiki サイト自身も CSP を効かせています。詳しくは同じ T3 の「セキュリティヘッダーと CSP」で扱います。
持ち帰る一言
XSS 三種は、配送経路が違うだけ。直しは、出す先の文脈でエスケープ。 保存型は一度書けば全閲覧者に効く、最も影響の大きい型。出力エスケープを本命に、CSP を二枚目の壁に。次は、入力が SQL として解釈される穴——SQL インジェクションです。
こうなっていればOK
卒業まであと4章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。