独自ヘッダをチェックするだけのステートレスな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対策を実際に使うべきか?
ということで、独自ヘッダをチェックするだけの対策も、上記のブラウザでは、有効であることが確認されました。
上記の記事に関する徳丸さんのツイートがありましたので、以下に引用しておきます。
@ockeghem お忙しいところ失礼します。CSRF対策として「X-Requested-Byヘッダがあればリクエストをそのまま通し、なければ400(BadRequest)」という方法が紹介されてたのですが、これはアリでしょうか
http://t.co/YCG20lEXIB
— 早すぎる最適化オジサン (@makotokuwata) 2014, 1月 22
@ockeghem つづき。トークンを使わないためステートレスにできる点は、開発者にとっては魅力的です。しかし徳丸本には載ってない方法なので、採用していいか、自分では判断がつきかねます。なにせセキュリティに関することなので自分だけでは決めにくいため、お聞きしました。
— 早すぎる最適化オジサン (@makotokuwata) 2014, 1月 22
@makotokuwata こんにちは。ぎりぎり大丈夫そうですが、ちとマージンが少ないの不安です。XHR Level2でリクエストを送れる(レスポンスは受け取れないがCSRFには十分)からです。独自ヘッダをつけるとプレフライトリクエストが飛ぶので、応答しなければ大丈夫ですが…
— 徳丸 浩 (@ockeghem) 2014, 1月 22
@makotokuwata クッキー使わないCSRF対策としては、はせがわようすけさん提唱のこちらを推奨しますhttp://t.co/lPmofhjFIH
— 徳丸 浩 (@ockeghem) 2014, 1月 22
ということで、徳丸さんの去年のご意見では、独自ヘッダをチェックするだけの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