2024年12月31日火曜日

ISO-2022-JPの自動判定によるクロスサイト・スクリプティング(XSS)

サマリ

ISO-2022-JPという文字エンコーディングの自動判定を悪用したクロスサイト・スクリプティング(XSS)攻撃について説明する。これは、文字エンコーディングを適切に指定していないウェブコンテンツに対して、文字エンコーディングをISO-2022-JPと誤認させることでバックスラッシュが円記号と解釈されることによりエスケープ処理を回避する攻撃である。本稿で紹介する攻撃は、従来からのセキュリティベストプラクティスである「文字エンコーディングの明示」に従っていれば影響を受けることはない。

はじめに

クロスサイト・スクリプティング対策として、記号文字のエスケープ処理に加えて、コンテンツの文字エンコーディングをレスポンスヘッダやmetaタグで明示しましょうと言われてきました(参照)。その背景として、UTF-7という文字エンコーディングを悪用したXSSの存在がありました。この攻撃については以下の記事を参照ください。

はせがわようすけさんの記事にもあるように、UTF-7によるXSSは、UTF-7に特徴的な文字列を注入することで、ブラウザに文字エンコーディングをUTF-7と誤認させることによるものです。
しかし、上記で「ありました」と表現している理由は、この問題がInternet Explorer(IE)限定の問題であること、IEでもかなり前に対策が進められて、現実的な攻撃は困難になっていたことによります。このため、徳丸本初版(2011年3月)でもUTF-7によるXSSは取り上げていません。
そのため、以下の記事に見られるように、「もうウェブコンテンツに文字エンコーディングが明示されていなくても脆弱性として指摘しなくてもよいのでは?」という意見も見られます。

令和になった今、Content-TypeヘッダのCharset付与によるクロスサイトスクリプティングについて考えてみる - 僕と技術とセキュリティ

ところが、今年(2024年)の7月に、SonarSourceのブログに以下の記事が投稿され、早々にCTFの作問等に応用されました。

Encoding Differentials: Why Charset Matters | Sonar

この記事ではISO-2022-JP文字エンコーディングの文字列を注入することにより、ブラウザにコンテンツの文字エンコーディングをISO-2022-JPと誤認させるテクニックです。すなわち、「文字エンコーディングを誤認させる」という点で、この記事の手法はUTF-7によるXSSの後継と考えられます。以下、上記ブログ記事のTechnique 1のパターンについて説明します。

ISO-2022-JP とはなにか

まずは、ISO-2022-JPについて説明しましょう。徳丸本2版から、ISO-2022-JPの説明を引用します。

ISO-2022-JPは7ビットの文字エンコーディングで、エスケープシーケンスという符号により文字集合(US-ASCIIとJIS X 0208)を切り替える方式です。「JISコード」と呼ばれる場合もあります。図6-15にエスケープシーケンスの例として、ISO-2022-JP符号化された「ABCと漢字!」という文字列を図示します。

 同図で、「ESC $ B」がJIS X 0208の始まり、「ESC ( B」がUS-ASCIIの始まりを示します。ISO-2022-JPは状態の切り替えを伴うため、コンピュータ上の内部処理や検索用のデータとしては適していません。歴史的な理由から、主に電子メールの伝送に用いられてきました。
 なお、「インターネットでは半角片仮名を使うな」という主張を目にする場合がありますが、これはISO-2022-JPが半角片仮名(JIS X 0201の片仮名)をサポートしないことに由来しています。
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 P535より引用

上記には言及されていませんが、ISO-2022-JPは、US-ASCIIとJIS X 0208以外も扱えるようになっていまして、主なものとしては以下があります。表はとほほの文字コード入門から引用しました。

記号表記 16進表記 意味
ESC ( B 1B 28 42 ASCII。
ESC ( J 1B 28 4A JIS X 6220(JIS X 0201)-1976 ラテン文字集合
ESC $ @ 1B 24 40 JIS C 6226(JIS X 0208)-1978(通称:旧JIS)
ESC $ B 1B 24 42 JIS X 0208-1983(通称:新JIS) または JIS X 0208-1990

下の2種類はJIS X 0208の旧規格と新規格なので中身は少し違うもののセキュリティ上の違いはありませんが、問題はJIS X 0201です。これは8ビットの1バイトでASCIIと半角カナを使えるようにした文字集合です。以下、英語版のWikipediaより引用です。


https://en.wikipedia.org/wiki/JIS_X_0201 より引用

この赤枠で囲った箇所がASCIIとは異なっていて、\(バックスラッシュ)の代わりに¥(円記号)、~(チルダ)の代わりに ‾ (オーバーライン)が割り当てられています。ISO-2022-JPでは、片仮名の部分は割り当てられておらず、ローマ字と記号の部分(0x00~0x7F)のみが使えます。ISO-2022-JPで半角片仮名が使えないのは、この制約によるものです。

ISO-2022-JPでは、エスケープシーケンス ESC $ J によりJIS X 0201に切り替えができますが、使えるのはローマ字部分のみで、ASCIIとの違いは\ → ¥、~ → ‾ のみなので実用上はわざわざ用意する意味がないように思える一方、これがセキュリティ上問題になります。

ISO-2022-JPによるXSSの基本的な考え方

我々に馴染み深いShift_JIS等ですと、表示上は ¥ となっていても、ブログラミング等ではバックスラッシュとして扱われますが、JIS X 0201の ¥ はあくまで円記号でありバックスラッシュではありません。このため、JavaScript等でエスケープ処理のために使ったバックスラッシュが円記号に化けてしまうと、「エスケープ処理が無効になってしまう」というセキュリティ上の問題になってしまいます。

たとえば、PHP等でJavaScriptを以下のように動的生成する箇所があったとします。

const v = "ここを動的生成する";

動的生成の箇所に "; alert(1)// を入力するXSSを防ごうとすると、以下のようにエスケープ処理するはずです。

const v = "\"; alert(1)//";

ところが、\が¥に化けると以下のようになり、XSS攻撃ができてしまいます。

const v = "¥"; alert(1)//";

日本のプログラマだと¥がバックスラッシュに見えてしまう方が多いと思いますが、上記はバックスラッシュではない円記号です。なので、文字列リテラルは “¥” だけとなり、後続の ; alert(1)//が文字列リテラルからはみ出し、実行されてしまいます(//以下はJavaScriptのコメントとなり無視されます。JavaScriptは行末のセミコロンは省略可能です)。

文字エンコーディングの自動判定

そうは言っても、文字エンコーディングがISO-2022-JPと認識されていなければ問題はありません。具体的には、レスポンスヘッダやmeta要素でcharsetが明示されている場合は、ISO-2022-JPと誤認されることはありません。問題は、適切な文字エンコーディング指定がない場合です。具体的には、以下のケースです。

  • 文字エンコーディング指定がない
  • 文字エンコーディング指定はあるが間違っている(charset=EUC_JP等)

これらのケースでは、ブラウザはテキストの中身から文字エンコーディングを「推測」します。このため、ISO-2022-JPに特徴的なエスケープシーケンスがある場合、ISO-2022-JPのコンテンツと「誤認」する場合があります。これを以下のスクリプトにより試してみましょう(実験する場合は日本語のコメントは削除してください)。

<?php
  header('Content-Type: text/html; charset=EUC_JP');  // 正しくはEUC-JP
?><body>
<?php echo htmlspecialchars($_GET['p']); ?>
<script>
  document.write("\\".codePointAt(0).toString(16)) // \のコードポイントを取得する
</script>
</body>

このスクリプトを p=%1b(J というクエリ文字列を指定して実行すると a5 と表示されます。バックスラッシュのコードポイントはU+005C、円記号のそれはU+00A5ですから、ESC ( J の指定によりJIS X 0201と解釈され、バックスラッシュが円記号に化けていることが分かります。

XSSを試す

ISO-2022-JPによるXSSの肝は、バックスラッシュが円記号に誤認されるところですから、バックスラッシュによりエスケープ処理を行っているスクリプトが脆弱性の対象になります。その具体例として、以下のPHPスクリプトを用います。

<?php
  header('Content-Type: text/html; charset=EUC_JP');
  function escapeJS($str) {  // Escape function for JavaScript String
    $replacements = [
        '\\' => '\\\\',
        '"'  => '\\"',
        "'"  => "\\'",
        "\n" => '\\n',
        "\r" => '\\r',
        "\t" => '\\t',
        '<'  => '\\u003C',
        '>'  => '\\u003E'
    ];
    return strtr($str, $replacements);
  }
?><body>
<script>
  const v = "<?php echo escapeJS($_GET['p']); ?>"
  console.log(v)
</script>
</body>

このスクリプトは、クエリー文字列 p をJavaScriptの変数 v に代入していて、その際に関数escapeJSを呼んでJavaScript文字列リテラルのエスケープ処理を行っています。小なり・大なり記号のエスケープはJavaScriptの文法上は必要ありませんが、これがないと、</script><script>alert(1)</script> のような形で、いったんscriptタグを終端するという攻撃ができるための処理です。
これに対する攻撃文字列は以下の通りです。

p=%1b(J";alert(1)//

実行結果は以下となります。

この際に生成される該当箇所を示します。まずは16進数ダンプ。

赤枠で囲った部分が ESC (J であり、JIS X 0201への切り替えのエスケープシーケンスです。
その結果、当該箇所は以下のようにデコードされます。

const v = "¥";alert(1)//"

¥はバックスラッシュではない円記号なので、直後のダブルクォート「"」で文字列リテラルが閉じられ、後続のalertが実行されていることが分かります。

コンテンツにマルチバイト文字があればどうなるか?

この攻撃は、文字エンコーディングの自動判定の結果ISO-2022-JPと判定されることが条件なので、元々コンテンツにShift_JISやEUC-JP等の日本語があれば1、ISO-2022-JPと本来の文字エンコーディングのどちらが「勝つ」かが問題になります。私の調べた範囲では、文字列を注入できる箇所(外部入力の表示など)の前にマルチバイトの文字があると、ISO-2022-JPとは判定されないようです。かと言ってUTF-8やShift_JIS、EUC-JP等と判定されるわけでもなく、windows-1252(ISO-8859-1に類似のMS独自文字エンコーディング)と判定されることが多いようです(下図)。

<head>
<title>日本語のタイトル</title>
</head>
<body>
ESC ( J 等としてもISO-2022-JPとは判定されない
</body>

一方、コンテンツが英字のみで構成されていると、ISO-2022-JPと判定させることは容易になります。
それでは、外部入力を注入できる箇所より「後に」マルチバイト文字列がある場合はどうでしょうか。下図のようなケースです。

<head>
<title>English Title</title>
</head>
<body>
<div>ここに外部入力を注入できる</div>
<div>日本語コンテンツ(Shift_JISあるいはEUC-JP)がある</div>
</body>

このようなケースでは、ISO-2022-JPの文字列を長くすることで、後ろにUTF-8等のマルチバイトの文字があっても、ISO-2022-JPと判定されることが分かりました。ただし、ブラウザにより挙動が異なります。まず、実験に用いたPHPスクリプトを以下に示します。

<?php
  function escapeJS($str) {  // Escape function for JavaScript String
    /* Same as before. Omitted. */
  }
?><body>
<?php
  echo htmlspecialchars($_GET['txt']);
?>
<script>
  const v = "<?php echo escapeJS($_GET['js']); ?>"
  console.log(v)
</script>
<!-- 雀の往来(EUC-JPコンテンツのための魔法の文字列) -->
<p>こんにちは こんにちは!!</p>
</body>

このスクリプトは2つのクエリ文字列txtとjsを取ります。txtは通常のテキスト(要素内容)としてHTMLエスケープして表示します。jsはJavaScriptエスケープしてJavaScriptの文字列リテラルとして埋め込まれます。txtの方は、元コンテンツの日本語が出現する前に置かれている必要があります。jsの方は場所はどこでも問題ありません。「雀の往来」はEUC-JP文字エンコーディングのコンテンツを文字化けしにくくする「魔法の文字列」です(参照)。「雀の往来」は実験には必要ありませんが、ノリで入れてみました。

Google Chrome

Google Chrome用の攻撃文字列は以下となります(バージョン131.0.6778.205で確認)。

http://example.jp/iso-xss-m.php?js=%1B(J";alert(1)//&txt=%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA

クエリー文字列 js は先のものと同じです。txtの方は、以下の文字列「誓A」を8個繰り返したものです。見やすくするために空白を挟んでいます。

ESC $@ @@ ESC (B A

jsの方(JavaScrpit文字列)で文字エンコーディングの誤認がさせられないか調べましたが、私が試した範囲ではできませんでした。なので、「文字エンコーディングの誤判定(txt)」と「XSS攻撃(js)」は別に指定する必要があるようです。

Firefox

Firefox用の攻撃文字列は以下です(バージョン133.03で確認)。

http://example.jp/iso-xss-m.php?js=%1B(J";alert(1)//&txt=%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA%1B$@@@%1B(BA

js側はGoogle Chromeと同じですが、txtの方は先の「誓A」を120個続けた形となっています。パーセントデコード後のバイト数は1080バイトなので、文字数制限をしているフィールドだと攻撃は難しいかもしれません。
Firefox特有の性質が2つ見つかりました。
まず、Firefoxは文字エンコーディングの自動判定する際に、コンテンツがある程度の長さがあると、レンダリングを2回行う場合があります。1回目は最初に判定した文字エンコーディングでレンダリングして、コンテンツ全体から判定した文字エンコーディングが1回目と異なる場合、もう一度レンダリングを用います。そうすると、先の「誓A」でいったんISO-2022-JPによりレンダリングした後、後続のEUC-JP等のデータも合わせて他のエンコーディング(典型的にはwindows-1252)でレンダリングし直します。これに合わせてJavaScriptも2回実行されます。
この場合、1回目のJavaScript実行の際に文字エンコーディングがISO-2022-JPと判定されているので、XSS攻撃は成功します。2回目は攻撃に失敗するのですが、1回成功すれば攻撃には十分です。
次に、Firefoxの場合だと、js側に「文字エンコーディング誤判定用の文字列」を入れても攻撃は成功します。なので、攻撃対象フィールドが1つかない場合でも攻撃できる可能性があります。

Safari

Safari(macOS 15.2上のSafari 18.2)で確認したところでは、Safariは文字エンコーディングの自動判定の結果ISO-2022-JPを選択することはないようです。明示的にISO-2022-JPを指定することはできますが、本稿で説明するISO-2022-JPの自動判定による攻撃のパターンはできないようです。

影響を受けるアプリケーション

この攻撃の影響を受ける条件は下記の両方を満たす場合です。

  • 文字エンコーディングを正しく指定していない
  • バックスラッシュによるエスケープ処理でXSS対策をしている

ただし、日本語のコンテンツだと通常title要素として日本語が記載されているわけで、その前に「外部から文字列を注入できる箇所がある」というのはレアケースかなと思います。しかし、多国語化対応されているサイトであれば、英語版のページを狙うことで攻撃成功の可能性は高まると思います。

対策

ISO-2022-JPの自動判定によるXSSの対策は以下の通りです。

  • ウェブコンテンツの文字エンコーディングを適切に行う

また、JavaScriptの動的生成は元々XSS対策が難しいので、HTML上のカスタムデータ属性に動的な値を設定して、その値をJavaScriptから読み込むという方法を推奨します。詳しくは拙著「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版」を参照ください。

まとめ

ISO-2022-JPの自動判定によるXSSについて説明しました。ウェブコンテンツの文字エンコーディングの設定漏れは脆弱性と言えるのかという意見も見られるところでしたが、やはり文字エンコーディングは適切に設定しましょう。文字エンコーディングの指定は元々必要な処理なので、「既知の攻撃手法ができなくなったから、もう対応しなくてよい」というものではありません。


  1. 文字エンコーディングを指定しない場合、UTF-8は自動判定されないようなので、Shift_JISあるいはEUC-JPを使っている場合が問題になると思います。 ↩︎

0 件のコメント:

コメントを投稿

フォロワー

ブログ アーカイブ