JavaScriptセキュリティの基礎知識

第6回DOM-based XSS その1

JavaScriptによるブラウザ上での処理量およびコード量の増加に伴い、JavaScript上のバグが原因で発生する脆弱性も増加しています。そのような脆弱性の最も代表的なものが、DOM-based XSSです。今回から数回に分けて、DOM-based XSSについて説明していきます。

DOM-based XSSとは

本連載第2回で説明したような一般的な反射型および蓄積型のXSSのほとんどは、Webアプリケーションがサーバ上でHTMLを生成する際に、攻撃者が指定した文字列のエスケープが漏れていることが原因で発生します。一方、DOM-based XSSは、サーバ上でのHTMLの生成時には問題はなく、ブラウザ上で動作するJavaScript上のコードに問題があるために発生します。

たとえば、以下のようなJavaScriptコードがあったとします。

// bad code
div = document.getElementById("info");
div.innerHTML = location.hash.substring(1); 

このコードは、URL中の#より後ろの部分の文字列をHTML内に表示するだけのものですが、攻撃者によって誘導されたユーザーが「http://example.jp/#<img src=1 onerror=alert(1)>」のようなURLをIEやEdgeで開いた場合には、#以下の部分がHTML内に展開され、攻撃者の用意したJavaScriptがユーザーのブラウザ上で動いてしまいます[1]⁠。

このように、ブラウザ上で動くJavaScriptによって発生する種類のXSSをDOM-based XSSと呼びます。

これまではサーバ上でHTMLの生成が行われていましたが、ブラウザ上でJavaScriptによってHTMLを操作・生成する機会が増えているため、DOM-based XSSの発生する割合もそれに合わせて近年増加しています。

また、動的にデータを処理する、いわゆるWebアプリケーションだけでなく、静的なHTMLのみを配信している場合であっても、メニューなどHTMLの一部分をアニメーション動作させたり、スマートフォンとPCなどデバイスごとに表示を最適化するなどのためにJavaScriptを使用している場合、それらのJavaScriptコードが原因でDOM-based XSSが発生することもあります。

DOM-based XSSが厄介な理由

IEやEdgeのXSSフィルタをはじめ、現在の多くのブラウザでは、単純なXSSからユーザーを保護するための機能が備えられています。ブラウザによって差はありますが、⁠リクエスト内に含まれる文字列がレスポンス内でスクリプトなどとして現れた場合には、反射型のXSSであるとみなして、その動作をブロックする」というのが基本的な原理です。

しかし、JavaScriptによってクライアント上でHTMLを組み立てると、攻撃用のコード部分がサーバにリクエストとして送信されないため、ブラウザの備えているXSS保護機構を回避して攻撃が成立しやすい傾向があります。また、location.hash内に攻撃コードを含めている場合には、攻撃コードがサーバ側に送信されないことになり、万が一XSSによって多数の被害が発生したとしてもサーバ側にはそのログが残らず、被害の把握が困難になることも考えられます。

さらに、従来からの検査手法では脆弱性の存在を発見することが非常に困難なことも問題です。通常のWebアプリケーションの脆弱性診断では、検査ツールから対象のWebサーバに対してリクエストを発行し、そのレスポンスの内容によってサーバ側に脆弱性が存在するかどうかを調べるといった手法が一般的ですが、DOM-based XSSではクライアント上でJavaScriptが動作するまではXSSが発生しません。

それにも関わらず、攻撃者は対象となるWebアプリケーションが実際に使用しているJavaScriptコードを丹念に読むことでDOM-based XSSを探し出すこともできるという、現在のところまさに攻撃者にとって有利としか言いようのない状況がそろっていると言っても過言ではありません。

DOM-based XSSの原因 ~シンクとソース

かんたんなDOM-based XSSの例として、先に挙げたJavaScriptコードをもう一度見てみましょう。攻撃者は、ユーザーを「http://example.jp/#<img src=1 onerror=alert(1)>」に誘導することによって、location.hash内に実行させたいコードを含めることができます。そして、それがユーザーのブラウザ上でDOM要素のinnerHTMLへと代入されることで、XSSが成立します。

このとき、攻撃者が実際の攻撃のためのJavaScriptコードを含めていたlocation.hashのような箇所を「ソース」といいます。また、ソースに含まれる文字列を受け取り、文字列からJavaScriptを生成、実行してしまう箇所のことを「シンク」といいます。ソース、シンクという用語は広く普及しているわけでもなく、必ずしも知らなければいけないというものではありませんが、DOM-based XSSに関する理解を深めるためには便利な概念なので、覚えておくといいかと思います。

DOM-based XSSのソースとして働く機能の代表例としては、以下のようなものがあります。

  • location.hash
  • location.search
  • location.href
  • document.cookie
  • document.referrer
  • window.name
  • Web Storage
  • IndexedDB
  • XMLHttpRequest.responseText

一方、DOM-based XSSのシンクとして働く機能の代表例としては、以下のようなものがあります。

  • HTMLElement.innerHTML
  • location.href
  • document.write
  • eval
  • setTimeout, setInterval
  • Function
  • jQuery(), $(), $.html()

これら以外にも、ソースあるいはシンクとして働く機能にはさまざまなものがあります。

攻撃者がソースに与えたJavaScriptが、さまざまな処理を経て、最終的にシンクとなる機能に渡ることにより、DOM-based XSSが発生することになります。

DOM-based XSSが発生するまでの流れ
DOM-based XSSが発生するまでの流れ

シンクにはさまざまなものがありますが、DOM-based XSSを発生させる主要な原因としては、innerHTMLやdocument.writeといったシンクが文字列からHTMLを生成する場合がほとんどを占めています。つまり、⁠ソースに与えられたデータを処理し、シンクに至る」という経路上のどこかの点で、攻撃者が与えた文字列から「<」「>」あるいは「"」などを検査、排除できれば、DOM-based XSSの多くを防ぐことができるということになります。

ただ、データの入力時に危険と思われる文字列を排除あるいは加工する方法は、プログラムの規模が大きくなると破綻しやすくなります。従来サーバ上で行っていたXSS対策の原則である「HTMLを生成する時点でエスケープして出力する」という考え方をブラウザ上の処理にも適用すれば、⁠ブラウザ上での最終的なレンダリング時に、JavaScriptによって文字列をエスケープして出力する」というのが合理的といえるでしょう。もちろん、JavaScriptであれば、HTMLの操作のために文字列ではなくDOM経由で値の設定ができるので、適切なDOM操作関数を選ぶことで文字列のエスケープという操作は不要になります。

また、JavaScriptによるHTMLの操作では、⁠<」「>」の挿入によるXSSだけでなく、javascript:スキームやvbscript:スキームへのリンクを作成することによって発生するXSSにも注意する必要があります。

さらに、JavaScriptライブラリに含まれる脆弱性が原因でXSSが発生することもあるので、⁠使用しているJavaScriptライブラリの新しいバージョンがリリースされたときには脆弱性の修正が含まれていないかを確認し、必要に応じて更新する」という、これまでサーバ側で使用しているライブラリやミドルウェアなどで行っていたことと同じ作業をJavaScriptライブラリについても行う必要があります。

DOM-based XSSを防ぐための3つの基本原則

これら、DOM-based XSSを防ぐための基本的な原則をまとめると、以下の3つとなります。

  1. HTMLを組み立てる際には適切なDOM操作関数を選ぶ
  2. リンクを作成するときにはhttp:あるいはhttps:のみとなるようにスキームを限定する
  3. 使用しているJavaScriptライブラリも更新する

これら3つの点について、さらに掘り下げて解説します。

HTMLを組み立てる際には適切なDOM操作関数を選ぶ

DOM-based XSSを発生させやすい最も大きい原因は、JavaScriptによってHTMLを組み立てる際に、innerHTMLやdocument.writeのような「文字列からHTML要素を生成してしまう機能」すなわち先ほど説明したDOM-based XSSにおける「シンク」を利用してしまうことにあります。以下は、先ほど示したコードです。

// bad code
// URL内の # より後ろを表示したい
// http://example.jp/#<img src=1 onerror=alert(1)> のようなURLへ誘導することでXSSが発生する
var div = document.getElementById("info");
div.innerHTML = location.hash.substring(1); 

URL中の#より後ろの文字列(ソース)を、<div>要素のinnerHTML(シンク)に代入していることによって、XSSが発生します。

この場合は、テキストノードとして文字列を表示するようなDOM操作を行うことで、XSSの発生を防ぐことができます。具体的には、

var div = document.getElementById("info");
var text = location.hash.substring(1);
div.appendChild(document.createTextNode(text));

または

var div = document.getElementById("info");
var text = location.hash.substring(1);
div.textContent = text;

のように、innerHTMLではなくtextContentを経由してテキストノードとして取り扱うようにします。

また、テキストノードだけでなく属性値についても、同様に適切なDOM操作を行う必要があります。以下のコードでは、攻撃者が変数textにたとえば「" onmouoser="alert(1)」などを挿入できた場合にはXSSが発生してしまいます。

// bad code
var form = document.getElementById("form1");
var text = "....";   // 変数 text は攻撃者がコントロール可能な文字列
form.innerHTML = '<input type="text" name="key" value="' + text + '">';

この場合の対策も、innerHTMLを使ってHTMLを生成するのではなく、属性値を適切に操作するというものになります。

var form = document.getElementById("form1");
var text = "....";   // 変数textは攻撃者がコントロール可能な文字列
var elm = document.createElement("input");
elm.setAttribute("type", "text");
elm.setAttribute("name", "key");
elm.setAttribute("value", text);  // 属性値を設定する
form.appendChild(elm);

このように、属性値の設定においても、innerHTMLではなく、setAttributeのように対象を限定して適切に操作を行えるAPIを選択することで、DOM-based XSSの発生を抑えることができます。

リンクを作成するときにはhttp:あるいはhttps:のみとなるようにスキームを限定する

テキストノードおよび属性値をDOM操作APIを使って適切に設定していても、URLの生成にjavascript:スキームなどが混入すると、DOM-based XSSが発生することになります。たとえば、以下のようなコードがあったとします。

// bad code
var div = document.getElementById("info");
div.innerHTML = '<a href="' + url + '">' + url + '</a>'; // 変数urlは攻撃者がコントロール可能な文字列

このコードでは、先に説明したように、要素のinnerHTMLにそのまま攻撃者がコントロールできる文字列urlを代入しているため、攻撃者が「" onmouseover="alert(1)」「"><img src=# onerror=alert(1)>」などを挿入した場合にはDOM-based XSSが発生します。そのため、対策としてテキストノードおよび属性値を設定するためにcreateTextNodeやsetAttributeといったAPIを使うと説明しました。実際にそれらを使って書き直したコードが以下のものです。

// bad code
var div = document.getElementById("info");
var elm = document.createElement("a");
// 変数urlは攻撃者がコントロール可能な文字列
elm.setAttribute("href", url);
elm.appendChild(document.createTextNode(url));
div.appendChild(elm);

このコードでは、新たに<a>要素を作り、urlの文字列をhref属性とテキストノードにそれぞれ設定しており、開発者が想定しているとおりのDOM構造を生成しているので、一見するとDOM-based XSSは発生しないように思えます。しかし、攻撃者が変数urlに「javascript:alert(1)」「vbscript:msgbox 1」などを挿入すると、HTMLとしては「<a href="javascript:alert(1)">javascript:alert(1)</a>」などが生成され、ユーザーがこの文字列をクリックした場合には攻撃者の作成したJavaScript(やVBScript)がユーザーのブラウザ上で動作してしまいます。

そのため、URLを表す文字列からリンクを生成する場合には、URLがhttp:あるいはhttps:に限定されるようにスキームを確認する必要があります。

var div; 
var elm;

// 変数urlは攻撃者がコントロール可能な文字列
if (url.match(/^https?:\/\//)) {
    div = document.getElementById("info");
    elm = document.createElement("a");
    elm.setAttribute("href", url);
    elm.appendChild(document.createTextNode(url));
    div.appendChild(elm);
}

このコードでは、変数urlの指している文字列が「http://」あるいは「https://」から始まるかどうかを確認し、そうであった場合にだけ<a>要素を生成し、リンクとして設定しています。これにより、javascriptスキームやvbscriptスキームを利用したDOM-based XSSを防ぐことができます。

この方法では、リンク先のURLとしては「http://」あるいは「https://」から始まる文字列しか使用できません。リンク先のURLとして「/nextpage.html」のような相対URLを許容したいという場合には、連載第5回で説明した方法で与えられたURLをパースして絶対URLに変換し、その絶対URLのプロトコルスキームが「http:」あるいは「https:」かどうかを確認するという方法を採ります。

var div; 
var elm;

// 変数urlは攻撃者がコントロール可能な文字列
var urlObj = createUrl(url);    // URLをパース
if (urlObj.protocol === "http:" || urlObj.protocol === "https:") {
    div = document.getElementById("info");
    elm = document.createElement("a");
    elm.setAttribute("href", urlObj.href);
    elm.appendChild(document.createTextNode(url));
    div.appendChild(elm);
}

ここまで、<a>要素のhref属性にURLを設定する場合を例に、javascript:スキームなどが挿入されてDOM-based XSSが発生する点についての対策を説明しました。しかし、実際のWebアプリケーションでは、<a>要素へのURLの設定だけでなく、locationオブジェクトへの代入やlocation.assignメソッド、location.replaceメソッドの呼び出しなどによって表示しているドキュメントのURLを遷移する場合においても、<a>要素の場合と同様に、javascript:スキームやvbscript:スキームが渡されることを避けなければいけません。

たとえば、以下のようなコードでは、攻撃者がユーザーを「http://example.jp/#javascript:alert(1)」のようなURLへ誘導することによって、DOM-based XSSが発生してしまいます。

// bad code
var nextPage = location.hash.substring(1);   //URL内の#より後ろの部分
location.href = nextPage;

また、locationへの代入などによるページの移動においては、javascript:スキームなどによるXSSだけでなくhttp:やhttps:スキームであっても任意サイトのURLへ移動できると、オープンリダイレクト脆弱性になってしまいます。

そのため、locationへのURLの設定においては、そのURLのオリジンがリダイレクト先として許されるものかどうか――通常は、現在表示しているドキュメントのオリジンであるかどうか――を検査しなくてはいけません。

与えられた文字列をURLとしてパースし、オリジンを取得する方法についても、連載第5回のコードがそのまま利用できます。

var newLocation;
if (location.origin === undefined) {
    location.origin = location.protocol + "//" + location.hostname + (location.port ? ":" + location.port : "");
}

// 変数urlは攻撃者がコントロール可能な文字列
newLocation = createUrl(url);   // URL文字列をパースし、protocolやhost、originなどのプロパティに分解
if (newLocation.origin === location.origin) {
    // 現在表示しているドキュメントと同一オリジンであればページを移動
    location.href = newLocation.href;
}

このコードでは、まずlocationオブジェクトにoriginプロパティを持っていない場合(IEが該当します)には、現在のドキュメントのオリジンをlocation.originプロパティとしてセットします。その後、連載第5回で説明した方法によりURLをパースし、その移動先URLのオリジンが現在のページのオリジンと一致している場合にのみlocation.hrefにその値を設定して、新しいドキュメントへと移動しています。

このようにすることで、ページの移動は現在のドキュメントと同じオリジンに限定され、javascript:スキームなどによるXSSや、任意サイトへのページ遷移によるオープンリダイレクトを防ぐことができます。

リダイレクト先として、現在のドキュメントとは異なるオリジンへの移動を許可する場合には、事前に移動先として許容されるオリジンを固定のリストとして定義しておき、移動先URLのオリジンがそのリスト内に存在するかどうかを確認するという方法を採ります。

var newLocation;
if (location.origin === undefined) {
    location.origin = location.protocol + "//" + location.hostname + (location.port ? ":" + location.port : "");
}
// 移動先として許容されるオリジンのリスト
var allowedOrigins = [ "http://site1.example.jp", "https://site1.example.jp", "http://site2.example.jp", location.origin ];

// 変数urlは攻撃者がコントロール可能な文字列
newLocation = createUrl(url);   // URL文字列をパースし、protocolやhost、originなどのプロパティに分解
if (allowedOrigins.indexOf( newLocation.origin ) !== -1) {
    // リスト内にオリジンが存在するならページを移動
    location.href = newLocation.href;
}

このコードでは、allowedOriginsにリダイレクト先として許可するオリジンとして以下の4つを定義し、リダイレクト先URLのオリジンがこれらに一致する場合のみページを移動しています。

  • http://site1.example.jp
  • https://site1.example.jp
  • http://site2.example.jp
  • 現在のドキュメントのオリジン

使用しているJavaScriptライブラリも更新する

現在、ある程度の規模のWebアプリケーションにおいては、ゼロからすべてのJavaScriptコードを書くということはまれで、たいてい何らかのJavaScriptライブラリ――たとえばjQueryやAngularJSのような――を使っていることが多いと思います。そういったJavaScriptライブラリにおいても、脆弱性が発見、報告されることがあります。過去には、jQuery Mobileに脆弱性があったため、特定のバージョンのjQuery Mobileを使っているサイトすべてにおいてDOM-based XSSが存在するといったこともありました。

DOM-based XSSを防ぐために注意深くコードを書いている人であっても、自身で書いたコードではないために意識から抜け落ち、サービス上で使われているそれらのJavaScriptライブラリの更新をうっかり忘れてしまうことがあるようです。

また、動的にHTMLを生成する、いわゆるWebアプリケーションに属さないような静的なHTMLのみを配信する場合であっても、スマートフォンやPCなどのデバイスごとに最適化された表示を行うためにJavaScriptライブラリを使用している場合もあります。そういった静的なHTMLのみを配信しているWebサーバであっても、使われているJavaScriptライブラリは随時更新する必要があります。

サーバサイドのライブラリやミドルウェアと同様に、こういったJavaScriptライブラリの更新もきちんと行うようにしましょう。

おすすめ記事

記事・ニュース一覧