独自ヘッダをチェックするだけのステートレスなCSRF対策は有効なのか?

WebAPIのステートレスなCSRF対策」という2011-12-04の記事がありました。

ここで説明されているCSRF対策は、

  • GET、HEAD、OPTIONSメソッドのHTTPリクエストはCSRF保護の対象外
  • HTTPリクエストにX-Requested-Byヘッダがなければエラーにする

という非常にシンプルなものです。

そして、この対策の原理として以下の説明がありました。

form, iframe, imageなどからのリクエストではHTTPリクエストに独自のヘッダを付与することができません。独自のヘッダをつけるにはXMLHttpRequestを使うしかないわけです。そしてXMLHttpRequestを使う場合にはSame Origin Policyが適用されるため攻撃者のドメインからHTTPリクエストがくることはない、ということのようです。

ここで、

XMLHttpRequestを使う場合にはSame Origin Policyが適用されるため攻撃者のドメインからHTTPリクエストがくることはない

という記述があります。これはXHR Level1では正しい記述ですが、XHR Level2では正確ではありません。

XHR Level2では、クロスオリジンのリクエストは可能だからです。クロスオリジンのリクエストはどのサーバにも可能で到達します。しかし、CORSによりサーバ側で許可されていないクロスオリジンのリクエストの結果(レスポンス)はJavaScriptからはアクセスできません(ブラウザにより捨てられる)。

CSRFでは、リクエストがサーバに到達し処理されれば攻撃は成功します。レスポンスは必要ありません。

ということで、実際にどうなるかを検証してみます。

検証方法

「攻撃対象ページ」と「罠ページ」を作成し、「罠ページ」にアクセスするとXHRで「攻撃対象ページ」にAjaxでPOSTします。このとき、リクエストにX-Requested-Withヘッダを追加します。

元記事ではX-Requested-Byヘッダですが、より一般的と思われるX-Requested-Withに変更しています。どちらも独自ヘッダであり、対策としての効果に違いは生じません。

「攻撃対象ページ」は、何のCSRF対策もせずにログを記録するだけにします。

詳細はこの記事の最後に記載します。

検証結果

X-Requested-Withヘッダを追加したため、ブラウザが「プリフライトリクエスト」を発行しました。

プリフライトリクエストとは、以下のようなヘッダを含むOPTIONメソッドでのリクエストです。クロスオリジンのリクエストが許可されているかを確認するためのものです。特定の条件によりブラウザが自動的に発行します。

Origin: http://csrf-trap.herokuapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: x-requested-with

この場合、サーバではこのようなクロスオリジンのリクエストを許可していないので、実際のX-Requested-Withヘッダ付きのリクエスト(攻撃のためのリクエスト)は発行されません。

ということで、そもそもCSRFのリクエストが(プリフライトリクエストのみしか)発行されず、(CSRF対策をしていないにもかかわらず)CSRF攻撃は失敗しました。

結果

攻撃失敗(プリフライトリクエストのみ)

  • Firefox 36.0 (Linux)
  • Firefox 36.0 (Mac OS X)
  • Chrome/41.0.2272.89 (Linux)
  • Safari 8.0.4 (Mac OS X)
  • IE 11.0 (Windows 7)

この結果からは、独自ヘッダをチェックするだけのCSRF対策は、サーバ側で攻撃者のドメインからのリクエストを許可していない限り、有効だということになります。

このCSRF対策を実際に使うべきか?

ということで、独自ヘッダをチェックするだけの対策も、上記のブラウザでは、有効であることが確認されました。

上記の記事に関する徳丸さんのツイートがありましたので、以下に引用しておきます。

ということで、徳丸さんの去年のご意見では、独自ヘッダをチェックするだけのCSRF対策は推奨しないということでした。

検証方法の詳細

「攻撃対象ページ」と「罠ページ」を作成します。

攻撃対象ページ

まず、リクエストを受ける攻撃対象ページを作成します。

POSTメソッドでリクエストされた場合に、ログに記録するだけです。何のCSRF対策もしていません。

GETされた場合は、ログを表示するようにします。

<?php

if (! function_exists('getallheaders')) {
    function getallheaders()
    {
        $headers = [];
        foreach ($_SERVER as $name => $value) {
            if (substr($name, 0, 5) === 'HTTP_') {
                $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
            } elseif ($name === 'CONTENT_TYPE') {
                $headers['Content-Type'] = $value;
            } elseif ($name === 'CONTENT_LENGTH') {
                $headers['Content-Length'] = $value;
            }
        }
        return $headers;
    }
}

$id = $_GET['id'];

if (preg_match('/\A[a-f0-9]{64}\z/', $id) !== 1) {
    exit('Invalid Request');
}

$log = __DIR__ . '/logs/'. $id;
$method = $_SERVER['REQUEST_METHOD'];

if ($method === 'GET') {
    echo '<pre>' . "\n";
    if (file_exists($log)) {
        echo htmlspecialchars(file_get_contents($log));
    } else {
        echo 'No request.';
    }
    echo '</pre>' . "\n";
} else {
//    $_POST = [
//        'comment' => '<s>This is a comment.</s>'
//    ];
    $data = $_POST;
    if (! isset($data['comment'])) {
        $data['comment'] = '';
    }
    if ($data['comment'] === '') {
        $output = 'CSRF attack was failed.' . "\n";
    } else {
        $output = 'CSRF attack was succeeded.' . "\n";
    }

    $time = date(DATE_ATOM);
    $output .= sprintf('[%s] %s "%s" %s' . "\n", $time, $method, $data['comment'], $id);
    foreach (getallheaders() as $name => $value) {
        $output .= "$name: $value\n";
    }
    $output .= "\n";

    file_put_contents($log, $output, LOCK_EX | FILE_APPEND);
}

これで、攻撃対象ページは完成です。

罠ページ

次に、罠ページを作成します。攻撃したい人をこの罠ページにアクセスさせれば、CSRFによる攻撃が実行されます。

jQueryで自動的にAjaxでPOSTするようにします。X-Requested-With: XMLHttpRequestというヘッダを追加するように指定しています。

<?php
$id = hash('sha256', openssl_random_pseudo_bytes(32));
$url = 'https://csrf-target.herokuapp.com/target/?id=' . $id;
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>CSRF attack via Ajax</title>
        <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
        <script>
            $(function () {
                function post() {
                    $.ajaxSetup({
                        crossDomain: true,
                        xhrFields: {
                            withCredentials: true
                        },
                        headers: {'X-Requested-With': 'XMLHttpRequest'}
                    });

                    $.post(
                        '<?php echo $url; ?>',
                        {comment: 'This is CSRF attack via Ajax'}
                    )
                }

                post();
            });
        </script>
    </head>

    <body>
        <p>CSRF attack via Ajax</p>
        <p><?php echo date(DATE_ATOM); ?></p>
        <p>See <a href="<?php echo $url; ?>">results</a>.</p>
    </body>
</html>

これで、完了です。面倒なので、攻撃したとかそういう親切な表示はありません。現在時刻だけ表示してます。

試したい人へ

すぐに試せるように、罠ページをhttp://csrf-trap.herokuapp.com/ajax-post-x-requested-with/に作成しました。

ということで、攻撃されてみたい人はhttp://csrf-trap.herokuapp.com/ajax-post-x-requested-with/にアクセスし、その後、「See results.」のリンクをクリックしてください。

攻撃が成功していれば、1行目に「CSRF attack was succeeded.」、2行目にCSRFでPOSTしたメッセージ「This is CSRF attack via Ajax」が表示されます。

関連

Date: 2015/03/23

Tags: ajax, csrf, security