🚨

【WEBセキュリティ】XSS攻撃を「試して」「防いで」学んでみた

に公開

XSS攻撃を「試して」「防いで」学んでみた

前書き

こんにちは。ニシパ(21)です!

私は未経験から転職したて ホヤホヤ のWEB開発エンジニアです。
そんな私何を隠そう、セキュリティが大好きで数年間独学しています。

独学でハッキングラボLinuxサーバーの構築をしてみたり、
情報処理安全確保支援士試験やLpicに合格するプロセスで、体系的なIT知識を得ました。

憧れと厨二病だけで勉強してきた自分でしたが、
いつかの Mini Hardning でPHPがわからずぼろ負け!(厨二プライドは木端微塵)

プログラミングが必要性を感じ、いざ学習を始めると楽しくて、すぐ転職しました。

ブログアプリ構築の研修中、気になって調べたことが面白かったので、
可能な限り読みやすく、わかりやすくを意識して共有してみます。

対象は以下の通りです。

  • セキュリティをちょっと考えてみたい全てのジュニアエンジニア
  • Webセキュリティについて曖昧な理解で過ごしている開発エンジニア

XSS(クロスサイト・スクリプティング)ってなんだ?

名前かっこいいですよね。

XSSは Cross-Site Scripting の略であり、

ウェブアプリにスクリプトを埋め込むことが可能な脆弱性を利用して、
利用者のブラウザ上で不正なスクリプトを実行させる攻撃 です。

利用者からの入力内容などをウェブページに出力するとき、
ページへの出力処理に問題がある場合にスクリプトが埋め込まれたりしちゃうやつですね!
対策にはいろいろありますが、基本的には出力されるデータを無害化することが重要です。

具体的には
このように出力したいユーザーからの入力データがある場合を考えます。
<p>{{ $userInput }}</p>

以下のようなデータが入力された時、上のコードではスクリプトが実行されてしまう。
<script>alert('XSS!');</script>

画面への出力前の段階でこのようにすることで、
<p>{{ htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') }}</p>

画面への出力は以下のようになる
<p>&lt;script&gt;alert('XSS!');&lt;/script&gt;</p>

こうする事で画面出力時に、スクリプトとして動作しなくなる(=無害化される) という事です。

とはいってもわかりにくいぞ という事なので、
実際に攻撃を試して、結果を見て、防御をして、学びを深めていきましょか!

参考 【IPA】 安全なウェブサイトの作り方 - 1.5 クロスサイト・スクリプティング
参考 XSSの概要、ユニクロ、Youtube被害事例

さぁ、XSSをWYSIWYGを使用したブログアプリに試そう

【悲報】 ワイのアプリ、脆弱性が見つかる。

ワイ

「ブログアプリ作ったで!」

WYSIWYGエディタを使用して、表現がリッチになったのがこだわりポイントや。」

「ちなこんな感じ↓。Wordっぽく入力されたデータをHTML形式で保存・表示できるんや。」

マッマ
「ええやん!ところで出力(画面に表示)するデータは無害化 しとるんか?」

ワイ
「...」

マッマ
XSS攻撃の影響で、ブログアプリの利用者全員が被害を受ける可能性があるで。」

つづくーー

つまりどういうこと?

例えば、ブログアプリのUIはこんな感じであることを想定してみましょう。
タイトルやコンテンツを入力して投稿するアプリです。

先ほどの会話で、脆弱な可能性のある機能はこの箇所です!

入力されたデータをHTML形式で保存・表示できるんや

今回は、悪意のあるユーザーがこの脆弱性を利用してスクリプトを投稿に埋め込み
その投稿を閲覧する全てのユーザーに悪影響を与えられちゃう点が問題である!
とマッマが言っているのです。

それでは実際に、スクリプトを投稿データに含めて送信し、
投稿を閲覧したタイミングでそれが実行されるようにしていきましょう。

攻撃を仕込む

【概要】
今回は、このフォームから 「ブログを開いたらブラウザ上でアラートが発生するスクリプト」 
を入力して投稿してみましょう。

【脅威】
XSSでよくあるこのアラートですが、アラートの何が問題なのでしょうか?

根本的な脅威はアラートではなく、
ユーザーのブラウザでスクリプトを実行させられる点」にあります。

例えば、攻撃者が用意したサーバーにユーザーの情報を送信するスクリプトを実行させたり、
偽のログインフォームを表示させ、機密情報を取得させられることが脅威として挙げられますね。

上の実行結果について、記事の最後のおまけで実際の攻撃成功画面を紹介します。

それでは実際にスクリプトを埋め込んでみましょう!

【実行】
フォームに以下の文字列を入力して送信する。
<p><img src="x" onerror=alert(alert('XSS'))></img><br></p>

imgタグのsrcとして存在しない画像を指定し、エラーを発生させる。
エラーの結果、onerrorが実行されてしまう。 というものです。

ポイントは、スクリプトタグを使用しないでスクリプトを実行している点です。
<script></script> だけに注意すれば良いわけでは無いんですね。

投稿!ポチ!

【結果】
投稿は成功。
スクリプトを含んだ投稿が成功!(?)

【確認】
確認のため、送信される直前のデータを見てみると、以下のようになっている。

入力したデータ:
<p><img src="x" onerror=alert(alert('XSS'))></img><br></p>

送信直前データ:
<p>&lt;p&gt;&lt;img src="x" onerror=alert('XSS')&gt;&lt;/p&gt;<br></p>

【結果】
タグの部分が、&lt; などに置き換わって送信されている。
つまり、既に無害化されている。

なぜ既に無害化されているの?

それは、WYSIWYG(JoditEditor)がデータを送信するときに無害化してデータを送信してくれているから
基本的なセキュリティ対策はやってくれているようです!

でも、結果みてみたいですよね。 
攻撃結果を確認するためには、別のアプローチが必要みたいです。


アプローチを換えて、こういう攻撃想定で行きましょう。

【概要】

悪意のあるユーザーが、APIに直接リクエストを送信する ことで、
スクリプトを含んだ投稿を成功させ、
その投稿を表示させる際に、悪意のあるスクリプトを実行させる。

【前提】

  • WYSIWYGを経由しない方法でのアクセスであれば、無害化されない。
  • 奇跡的に、第三者がAPIサーバーへリクエストできる状態になってしまった。

【実行】
雑ですが、Postmanを使用して先ほどのようなデータを送信します。

{
    "title" : "XSS-Test",
    "content" : "<p><img src='x' onerror=alert(alert('XSS'))></img><br></p>"
}

【結果】
投稿が成功し、DBにも無害化されていない投稿が格納された。

攻撃を確認する

【確認】
実際に投稿一覧から、該当の投稿詳細ページにアクセスしてみると、

攻撃が成功したことがわかります。

【結果】
APIへ直接リクエストされた。無害化されていない状態でデータが画面に出力されたため、
攻撃者のスクリプトが実行されてしまった。

このページにアクセスするすべてのユーザーが影響を受けてしまうのであったーー


ワイ

「ほんまや。。どうしよう怖いよマッマ(´;ω;`)」

マッマ

「いろいろ対策はあるけど、今回はフロントエンドで無害化する方法に絞って紹介してみよか


フロントエンドでXSSを防いでみよう

防御をする(frontend)

フロントエンドでの対策は必須なの?

フロントエンドの対策は必須だと考えています。
後ほど触れますが、そもそもXSSの原因はユーザーからの入力値を表示する所に問題があります。
そのため、出力される前に無害化されている状態であればXSSを防ぐことができるのです。

今回はWYSIWYGエディタを使用して入力したHTMLデータを、
dangerouslySetInnerHTML を使用することで画面に出力していました。

  <div
    dangerouslySetInnerHTML={{ __html: article?.content || '' }}
  />

【無害化】
この、article.contentを無害化しましょう。

今回は、「DOMPurify」というライブラリを使用して無害化してみます。

  • ブラウザ上で動作する軽量・高速・強力な HTMLサニタイザー
  • 信頼できないHTMLの中から危険な要素(scriptタグ・イベント属性など)を除去
  • React、Vue、Next.jsなどのモダンフレームワークとも相性が良い

同じ言語でもいくつかありそうですし、他の言語ならその言語で無害化すればOKです!

【使用方法】

  1. インストール
npm install dompurify
  1. インポート
import DOMPurify from 'dompurify';
  1. 無害化

許可するタグや制限するものなど、設定をすることもできる。

const sanitizedContent = DOMPurify.sanitize(article?.content || '', {
  ALLOWED_TAGS: [
    'p', 'b', 'i', 'u', 'strong', 'em', 'br', 'hr',
    'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
  ],
});
<div
  dangerouslySetInnerHTML={{ __html: sanitizedContent }} 
/>

WYSIWYGで使用することを想定しているタグに制限するのが良いです。
これで設定は終わります!

再度スクリプトの埋め込まれたページにアクセスしてみる

【確認】
ページにアクセス

アラートは発生しなくなった。

【結果】
ここでは、問題なさそう!
article.contentのデータを確認すると、

無害化されていたーー


マッマ

「無害化できたみたいやな。」

ワイ

「マッマありがとうやで。これで安心安全や!」

マッマ

「ワイくん、実はバックエンド側でも無害化が求められることもあるんや。」

バックエンドでXSS対策はするの?(調べたもの紹介)

ワイ

フロントエンドで無害化して送信されるのに、なんでバックエンドでもやるんや?」

マッマ

「色々ありそうやが、例えばさっきみたいにWYSIWYGエディタを介さずAPIに直接リクエスト
 来た時なんかに有効かもしれないな。」

「フロントエンドで出力前に無害化してるけど、それはフロントエンド側に
 セキュリティ対策を任せてしまっている状態でもある
んや。」

「DBへのデータが無害化されていれば、根本的に安全やからな。
(データの柔軟性、データが変わる点については要検討)」

「今回のケースではDBに格納されるデータは信頼されたデータであるべき的な考え方や。
もちろん要件・環境によるで。」


バックエンドについては、具体的な実装は割愛!
他の対策も含めて、別の記事で紹介するかもです。
(web securityシリーズ的な)

どのタイミングで無害化するの?

なかなか難しそう。

  • HTMLにおいては、画面出力前にエスケープを実施する事が大切である。
  • フロントエンド送信前、バックエンドDB格納前のサニタイズについては、環境や要件による。
  • 前提として、セキュリティは柔軟性とトレードオフである事を念頭に置いておく。
  • 公開範囲は社内なのかインターネット上なのか
  • APIなら他サービスがどの程度利用するのか
  • DB内においてエスケープされた状態で問題ないのか

HTML出力前は確実に無害化しつつも、多層防御する必要性もある。
ただし、防御策を適用する際も、実用性・ユーザビリティとのバランスを取ることが重要

まとめ

  • XSSは、ウェブアプリにスクリプトを埋め込むことが可能な脆弱性を利用して、
    利用者のブラウザ上で不正なスクリプトを実行させる攻撃
  • 対策の一つとして、ユーザーからの入力を画面に出力するときは無害化する必要がある
  • タイミングやDBへの格納の仕方は、要件や環境に合わせる必要がありそう。

後書き

ここまで読んでいただいて、どうもありがとうございました。

記事を読む前と比べて、
XSSへの理解セキュリティに対する関心が少しでも深まっていれば、幸せです!

わかりにくい点や冗長な点がまだまだ多いので、引き続きアップデートを行ったり、
ご意見を反映させたりして、より良いコンテンツにしていきます。

引き続き、開発者に向けたセキュリティ技術の発信に取り組みたいと考えております。

ではまた!

おまけ

取得した機密情報と端末情報を攻撃者の用意したサーバーに送信する模擬テスト

1 XSSの脆弱性を利用して偽のフォーム投稿に埋め込み、表示する。

2 送信先であるサーバーをローカルに用意する。
  Node.js + Express.js をベースにしたログ受信・保存サーバーを簡易構築

3 実際にデータを送信してみる。
  ポチ

4 記録されたログを確認する。(logs.txtに保存するようにサーバーサイドを設定)

{"time":"2025-04-17T02:51:48.070Z","ip":"::1","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36","email":"test@example.co.jp","password":"password1234"}
{"time":"2025-04-17T02:54:30.443Z","ip":"::1","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36","email":"test@example.co.jp","password":"testpassword"}
{"time":"2025-04-17T02:58:45.334Z","ip":"::1","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36","email":"test@example.co.jp","password":"testuser"}

簡易的な構成ですが、興味があってテストしてみました。
より現実味を感じられますね!

Discussion