Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

Webセキュリティ勉強会 Part-2

Webセキュリティ勉強会 Part-1 の続きものです。

目的

Webセキュリティ勉強会 Part-1 では、Day 1 → Day 2 途中までは OWASP Juice Shop を題材にハンズオン学習を行いますが、以下の脆弱性に関しては OWASP Juice Shop の問題は難易度が高く短時間のハンズオンでやるには難しかったです。

  1. Stored XSS (格納型クロスサイトスクリプティング)
  2. CSRF (Cross-Site Request Forgery)
  3. Clickjacking
  4. SSRF (Server-Side Request Forgery)

そこで、上記4脆弱性の学習を目的とした Web サイトを作成しました。

vulnerable-web-site.vercel.app

github.com

本書ではこのサイトを題材に、上記4脆弱性の解説をします。

サイトアクセス

まず、この脆弱性デモサイトのアクセスの仕方を説明します。

vulnerable-web-site.vercel.app

ユーザー登録画面で適当な「メールアドレス」、「パスワード」を入力して登録を行って下さい。メール送信とかはされないのでご安心を。

登録した「メールアドレス」、「パスワード」でログインします。

vulnerable-web-site.vercel.app

ログイン後は「Profile」ページで、自身のプロフィール設定を変更できる。たったそれだけのサイトです。

解説

本 Web サイトを題材に、次の4脆弱性を説明します。

  1. Stored XSS (格納型クロスサイトスクリプティング)
  2. CSRF (Cross-Site Request Forgery)
  3. Clickjacking
  4. SSRF (Server-Side Request Forgery)

1. Stored XSS (格納型クロスサイトスクリプティング)

近年のフロントエンド開発では React, Vue, Angular 等のフレームワークを使って開発ことが多く、これらのフレームワークは自動でサニタイズをしてくれます。

const userInput = `<script>alert('xss')</script>`

// ちゃんと、テキストノードはサニタイズされる
// &lt;script&gt;alert('xss')&lt;/script&gt;
return(
  <div>{ userInput }</div>
)

しかし、フレームワークサニタイズ対応は完璧ではありません。 例えば React の場合、主に次の2点が XSS を許すセキュリティホールとして知られています。

  1. dangerouslySetInnerHTML
  2. javascript: scheme

Vue.js 公式でも同様の注意喚起がされていますね! * https://ja.vuejs.org/guide/best-practices/security

dangerouslySetInnerHTML

React の全ての Component は dangerouslySetInnerHTML という Attribute があります。 文字列 (変数) で与えられた HTML を解釈して、実行時に動的に DOM を生成できます。

これは内部的には単に Element: innerHTML を呼び出すのですが、文字列として渡した HTML から DOM 生成される為、悪意あるコードの埋め込みを許してしまいます。

const userInput = `<iframe src="javascript:alert('xss')" />`

return(
  <div dangerouslySetInnerHTML={{ __html: userInput }} />
)

脆弱性デモサイトでは Memo テキストエリアに入力した内容が dangerouslySetInnerHTML を使って描画される様になっています。 (※ソース/profile/page.tsx#L43))

vulnerable-web-site.vercel.app

javascript: scheme

分かってないとやってしまう可能性が高いのはこちらで、 javascript: scheme によるコード埋め込みです。

次の様に、ユーザー入力をアンカー要素 <a href=... の URL に指定すると、 javascript: scheme を用いた悪意あるコードの埋め込みを許してしまいます。

const userInput = `javascript:alert('xss')`  // 開発者は "https://..." 入力を期待しているが...

return(
  <a href={ userInput } />
)

脆弱性デモサイトでは Image URL テキストフィールドに入力した URL が、プロフィール画像をクリックした際の宛先 URL になっています。 (※ソース/profile/page.tsx#L35))

vulnerable-web-site.vercel.app

⇩⇩⇩

XSS 対策

2024年現在は、XSS 脆弱性に対して有効な対策があります。CSP (Content-Security-Policy) を適用することです。

developer.mozilla.org

サイトへの Top-Level-Navigation アクセスに対して、レスポンスに Content-Security-Policy ヘッダを付与します。 (※ソース)

Content-Security-Policy: script-src 'self'

CSP (Content-Security-Policy) が適用されたサイトでは、悪意あるコード (インラインスクリプト) はブラウザが実行を防ぎます。

実は CSP (Content-Security-Policy) はコードが「悪意あるかどうか」を判断しません。次の様な「インラインコード」を丸ごと全て禁止にしています。

<!-- ❌ これはインラインなので NG -->
<script type="text/javascript">
  alert('xss');
</script>

<!-- ❌ これはインラインなので NG -->
<iframe src="javascript:alert('xss')" />

逆に、次の様に src= 要素を指定してロードした <script> はインラインとはみなされません。

<!-- ✅ これは OK ! インラインではない -->
<script src="/path/to/javascript.js" />

ほぼ全ての XSS は「インラインコード」によって実現される為、CSP (Content-Security-Policy)XSS 対策にとても効果的です。

2. CSRF (Cross-Site Request Forgery)

2020年に Google を中心として、Web 標準で COOKIE 仕様の SameSite 属性が追加されました。

developers.google.com

developer.mozilla.org

これによって昨今では、CSRF (Cross-Site Request Forgery) はかなり起こりづらくなっています。

従って、これから原理を説明はしますが、発生するシチュエーションは次の通り限定的です。

  1. サイトの認証情報を Cookie で保存している
  2. その Cookie には SameSite=None が明示的に指定 (*1) されている
  3. クロスサイトからのアクセス対象は <form action="※攻撃対象のURL">...</form> である (*2)
  1. SameSite 属性は現在 Default 値が SameSite=Laxであり、標準で CSRF 対策がされます
  2. Ajax (XHR, Fetch API) でのアクセス は CORS で明示的に指定したドメインにしか、Cookie は送信されません

攻撃手法の解説は、実際に JSFiddle で書いた攻撃コード (デモサイト) を見た方が速いでしょう。

jsfiddle.net

<form action="https://vulnerable-web-site.vercel.app/api/userUpdateForm" method="post" id="form">
  <input type="hidden" name="name" id="name" value="CSRF !!" />
  <input type="hidden" name="message" id="message" value="CSRF !!" />
  <input type="hidden" name="imageUrl" id="imageUrl" value="http://example.com" />
  <input type="submit" value="submit" />
</form>
// Automatically submit form.
const form = document.getElementById('form');
form.submit();

全く無関係の別のサイト (攻撃者) から、<form method="post">...</form> による POST リクエストが攻撃対象のサイトに送信されます。

この時、攻撃者のサイトには認証に関わるコード・情報は一切ありません。

しかし、被害者がこのサイトにアクセスすると、攻撃対象のサイトに POST リクエストが送信される際に、ブラウザに保存された Cookie が送付されてしまいます。

3. Clickjacking

実際に JSFiddle で書いた攻撃コード (デモサイト) を見た方が速いでしょう。

jsfiddle.net

次の画像は攻撃者が用意したサイトです。 Click me ! ボタンを押下すると、攻撃対象のサイトで「ログアウト」操作がされてしまいます。

  • サイト内に <iframe> で攻撃対象のサイトを埋め込みしています。
  • <iframe> で埋め込んだサイトの上に別の要素を被せて、視覚的に見えない様にします
  • 被せた要素に CSS pointer-events: none を指定する事で、クリックイベントを下部の <iframe> まで貫通させます

この脆弱性への対策は、外部のサイト (クロスサイト) で <iframe> 埋め込みを許さない事です。

サイトへの Top-Level-Navigation アクセスに対して、レスポンスに X-Frame-Options: SAMEORIGIN ヘッダを付与します。 (※ソース)

developer.mozilla.org

X-Frame-Options: SAMEORIGIN

4. SSRF (Server-Side Request Forgery)

実例

SSRF 脆弱性だけは インターネット上に公開している脆弱性デモサイト では有効にしていません。

実際に攻撃が成功すると、 このデモサイトをホスティングしている Vercel に被害が及ぶ可能性がある 為です。

SSRF 脆弱性によって被害が出た有名な実例があります。

logmi.jp

※ 出典: ログミーBiz・徳丸浩 様

このケースでは、攻撃者は SSRF 脆弱性を用いて AWS Instance Metadata の取得に成功しました。 AWS Instance Metadata には AWS のアクセスキー などが含まれています。

# Amazon EC2 サーバー内部で有効な、プライベート IP アドレス `169.254.169.254`.
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/{role-name}

{
  "AccesskeyId": "...",
  "SecretAccessKey": "...",
  ...
}

説明

例えばあなたの Web サイトにインターネット URL を入力するフォームがあるとします。

そのユーザー入力 (URL) はサーバーへ送信され、サーバー内部のコードで HTTP リクエストを実行します。

// express で書いた 「脆弱なサーバー」                                                                                                    
const express = require('express')
const app = express()

app.get('/', async (req, res) => {
  // クエリパラメーター `?url=` で指定した URL のリソース本文を                                                                             
  // そのまま帰す、HTTP エコーサーバー                                                                                                      
  const url = req.query.url
  const response = await fetch(url)
  const text = await response.text()

  res.send(text)
})

app.listen(3000, () => {
  console.log(`Echo server listening on port 3000`)
})

上記の様なリクエストを転送・プロキシするサーバーは、サーバーが属するプライベートネットワーク上のリソースへの間接的なアクセスを許してしまいます。

次のシーケンスは、上記「脆弱なサーバー」への HTTP リクエスhttps://{脆弱なサーバー}?url=http://169.254.169.254/... を説明した図です。

169.254.169.254 はプライベート IP アドレスの為、本来インターネット上からはアクセスできません。

sequenceDiagram
    participant A as 攻撃者
    participant S as 脆弱なサーバー
    participant I as 内部ネットワークリソース<br />(169.254.169.254)

    A->>S: 悪意のあるリクエスト<br>(?url=http://169.254.169.254/...)
    Note over S: サーバーはリクエストを検証しない
    S->>I: リクエストの転送<br />http://169.254.169.254/...
    Note over I: 内部リソースにアクセス
    I-->>S: レスポンス
    S-->>A: レスポンス(内部情報を含む)
    Note over A: 攻撃者は内部情報を受け取る

デモサイト (localhost) で試してみる

脆弱性デモサイトを localhost で起動します。

git clone https://github.com/megmogmog1965/vulnerable-web-site

... (省略) ...
export ENABLE_SSRF=true
npm run dev

Instance metadata (っぽいもの) を返す疑似サーバー (http://localhost:3001) を立てます。

※前述の 169.254.169.254 に当たるもの。

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.json({ id: '......', key: 'xxxxxxx' });
});

app.listen('3001', () => {
    console.log('Example app listening on port 3001');
});
npm install express
node index.js

脆弱性デモサイトにアクセスし、Image URLhttp://localhost:3001 にして保存します。

http://localhost:3000/profile/editlocalhost

壊れたプロフィール画像Chrome DevTools の Inspector で見てみましょう。

img タグに data: 埋め込みしています。

別タブで開くと、この様な結果が得られました。

昨今、この様な data url は本番サイトでは少ないとは思いますが、脆弱性再現の一例として実装しました。