VAddyとCSRFトークン
VAddyは脆弱性診断を実行する際に、CSRFトークンを最新のものに更新しながら動作します。そのため「どのパラメータがCSRFトークンか?」を判断するロジックが存在しています。最近あるフレームワーク(後述)について「CSRFトークンを正しく認識できない」というバグを修正したのですが、良い機会なのでメジャーなフレームワークやCMSを中心にCSRFトークンの実装をざっと追ってみました。一覧にしても面白くないので、仮想インタビュー形式にまとめてあります。GitHub上で軽く追ったものが多いので、最新のバージョンでなかったり、解釈が間違っている箇所があるかもしれません。
それでは、どうぞ。
Ruby on Rails
金床(以下、金)「こんにちは。ようこそ。」
RoR「こんにちは」
金「相変わらずシェア高いようですね。」
RoR「はい、おかげさまで。この間はルマン24時間耐久レース アマの部で勝利しました。」
金「さっそく本題なんですが、なんかあなたのCSRFトークン、ちょっと変ですよね。ていうか、2つありますよね?」
RoR「さすが、よくご存知ですね」
金「こちらがとりあえず手元のRedmineから持ってきた例なんですが、とりあえずmetaタグにそれっぽいのがあります。」
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="OGjn8n1qaV0(略)lZKu4z3bkXY6Q==" />
金「さらに、次のようにinputとしてもそれっぽいのがいますよね」
<input type="hidden" name="authenticity_token" value="qrH7NiMO(略)f7B1YBg==" />
RoR「たぶん、JSでヘッダからCSRFトークンを送る場合と、単にフォームを実行するときで違うんだと思います。もしかしたら違うかもしれませんが」
金「いくつかフレームワーク見ましたが、RoRの実装は非常に変わってますね」
RoR「まぁ、基本的には深く考えずに用意された仕組みに乗っかってくれればうまく動くんで2つあることは気にしないでください」
金「Railsだけに?」
RoR「Railsだけに。」
金「Base64っぽい文字列ですね。」
RoR「はい。ここらへんで処理してます」
def real_csrf_token(session)
session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
Base64.strict_decode64(session[:_csrf_token])
end
金「なるほどSecureRandomですか。今日はありがとうございました。」
RoR「はい。今日は呼んでいただいてありがとうございました」
OWASP CSRFGuard
金「こんにちは。」
OCG「どうも、私はCSRF対策だけ専門でやってます。今日は呼んでもらえてうれしいです。」
金「実はOCGさんのことはあまり知らなかったんですが、JPCERTのドキュメントなんかでも紹介されてますね。」
OCG「一応OWASPなんで、それなりに人の目に付くみたいです」
金「OCGさんを利用するにはどうやればよいのでしょうか。」
OCG「いわゆるJavaEEのフィルタなんで、Javaサーブレットに慣れている人だったらそのまま使えると思います。」
金「なるほど。HTMLに埋め込まれるCSRFトークンはこんな感じのようですね。」
<input type="hidden" id="OWASP_CSRFTOKEN" name="OWASP_CSRFTOKEN" value="KBS8-DLOB-(略)-39TX-I8DB-RB7Z"/>
OCG「はい。」
金「ハイフンを含んでいますが、これはUUID由来とかそういうことでしょうか?」
OCG「いえ、なんとなくそれっぽいかな?と思って。処理はこのへんでやってます」
public static String generateRandomId(SecureRandom sr, int len) {
StringBuilder sb = new StringBuilder();
for (int i = 1; i < len + 1; i++) {
int index = sr.nextInt(CHARSET.length);
char c = CHARSET[index];
sb.append(c);
if ((i % 4) == 0 && i != 0 && i < len) {
sb.append('-');
}
}
return sb.toString();
}
金「これ、ぶっちゃけハイフンいらないですね…」
OCG「別にいいじゃないですか…」
金「SecureRandomクラスを使っているんですね。王道ですよね。」
OCG「はい。Javaの実装はこれを利用しているのが多いと思います」
金「シンプルな実装でコードを読みやすいんでCSRF対策に興味があるJavaプログラマにはいいかもしれませんね。」
OCG「ですね。ありがとうございました。」
Laravel
金「こんにちは。…気づきました?」
Lrv「え?何がですか?」
金「実はVAddyのコンソールはLaravelなんですよ。」
Lrv「そうなんですね、ご利用ありがとうございます」
金「ではさっそく本題のCSRFトークンですが、Laravelでは次のような感じですね」
<input name="_token" type="hidden" value="TqMhR6ARyGz4OKC4GKvus9h8CTlvDWGzgDtt6yQV">
Lrv「はい。オーソドックスな形でしょ?」
金「実装はこのへんですね。」
/**
* Regenerate the CSRF token value.
*
* @return void
*/
public function regenerateToken()
{
$this->put('_token', Str::random(40));
}
金「PHPあまり詳しくないのですが、Strクラスのrandom関数というのはセキュアなんでしょうか?」
Lrv「StrクラスはPHP標準に存在するわけではなくて、Laravelの独自実装のクラスです。実際の値の生成はここからPHPの標準関数であるrandom_bytesを呼んでいます。」
/**
* Generate a more truly "random" alpha-numeric string.
*
* @param int $length
* @return string
*/
public static function random($length = 16)
{
$string = '';
while (($len = strlen($string)) < $length) {
$size = $length - $len;
$bytes = random_bytes($size);
$string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
}
return $string;
}
金「なるほど、実際にはBase64エンコードした値なのに、=などを消しているので、少しそれがわかりにくくなってるんですね」
Lrv「はい。」
金「中にはランダムな文字列にわざわざ-を入れる実装もあるので、なかなか興味深いです。」
Lrv「そういう方もいらっしゃるんですね。」
金「今日はありがとうございました。今後もお世話になります。」
Lrv「何かお困りの点がありましたらいつでもヘルプを参照してください」
WordPress
金「大御所のワードプレスさんにお越しいただきました。こんにちは。」
WP「こんにちは。」
金「WPさんのCSRFトークンは次のようになってますね」
<input type="hidden" id="_wpnonce" name="_wpnonce" value="5825e1f473" />
金「_wpnonceの_wpはワードプレスという意味、後半はいわゆる"number used once"の略のnonceですよね」
WP「はい」
金「となると、WPではCSRFトークンはワンタイムなんですか?」
WP「いえ、実はそうではないです。ちょっとわかりにくいので、こちらのページに書いてあります。すみません」
金「実装はこちらですね。」
/**
* Creates a cryptographic token tied to a specific action, user, user session,
* and window of time.
*
* @since 2.0.3
* @since 4.0.0 Session tokens were integrated with nonce creation
*
* @param string|int $action Scalar value to add context to the nonce.
* @return string The token.
*/
function wp_create_nonce($action = -1) {
$user = wp_get_current_user();
$uid = (int) $user->ID;
if ( ! $uid ) {
/** This filter is documented in wp-includes/pluggable.php */
$uid = apply_filters( 'nonce_user_logged_out', $uid, $action );
}
$token = wp_get_session_token();
$i = wp_nonce_tick();
return substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
}
endif;
金「単にランダムな文字列を生成しているという雰囲気ではないですね」
WP「はい、wp_verify_nonceの方も読んでもらえればわかると思いますが、nonceの値から有効期限についてもチェックできるようになってます」
金「そうなんすか…」
金「では、本日はありがとうございました。売れっ子で忙しいと思いますので今日はこのへんで。」
WP「はい。ありがとうございました。」
MovableType
金「ひさしぶりにPerlを読みました」
MT「いきなり失礼な方ですね」
金「MTさんのCSRFトークンはたぶんこれですよね」
<input type="hidden" name="magic_token" value="7G2Pziy2jV1tRW70DcIiHzPJ7hzxu3Idk27WhwPO" />
MT「はい。magic_tokenっていう名前、いいでしょ?実装はこのへんです。」
sub magic_token {
my $auth = shift;
require MT::Util;
my $pw = $auth->column('password');
if ( $pw eq '(none)' ) {
$pw
= $auth->id . ';'
. $auth->name . ';'
. ( $auth->email || '' ) . ';'
. ( $auth->hint || '' );
}
require MT::Util;
MT::Util::perl_sha1_digest_hex($pw);
}
金「このコードを読む限り、パスワードを元に生成されているように見えますね。大丈夫なんでしょうか?」
MT「SHA1ですが、この値はそもそもユーザにしか見えないものなので問題ありません」
金「なるほど」
金「コメント欄でご指摘頂いたのですが、上記は間違いで、CSRF対策のmagic_tokenの生成はこちらが正解のようですね」
sub make_magic_token {
my @alpha = ( 'a' .. 'z', 'A' .. 'Z', 0 .. 9 );
my $token = join '', map $alpha[ rand @alpha ], 1 .. 40;
$token;
}
MT「しっかりしてください。もっと普段からPerlを読まないと」
金「反省してます」
金「では、今日はありがとうございました」
MT「また呼んでください。」
CakePHP
金「こんにちは」
Cake「VAddyエンジニアの市川さんいますか?」
金「いや、彼は今福岡ですね」
Cake「市川さん、idはcakephperなのに最近お会いしてなくて」
金「そういう内輪ネタは置いておいてCSRFトークンの話をしましょう」
<input type="hidden" name="_csrfToken" value="58d2264fdee47c834b5f(略)d07bd6808cb60919"/>
金「名前が_csrfTokenとなっていて非常に明確ですね」
Cake「はい」
金「値が非常に長いですよね」
Cake「実際にランダムに生成しているのは16バイトなんですが、sha512を使って128バイトの文字列として埋め込んでます。コードはこのへんです。」
/**
* Set the cookie in the response.
*
* Also sets the request->params['_csrfToken'] so the newly minted
* token is available in the request data.
*
* @param \Cake\Network\Request $request The request object.
* @param \Cake\Network\Response $response The response object.
* @return void
*/
protected function _setCookie(Request $request, Response $response)
{
$expiry = new Time($this->_config['expiry']);
$value = hash('sha512', Security::randomBytes(16), false);
$request->params['_csrfToken'] = $value;
$response->cookie([
'name' => $this->_config['cookieName'],
'value' => $value,
'expire' => $expiry->format('U'),
'path' => $request->webroot,
'secure' => $this->_config['secure'],
'httpOnly' => $this->_config['httpOnly'],
]);
}
金「Security::randomBytes(16)というのは実際には何の実装なんでしょうか?」
Cake「基本的にはPHP標準のrandom_bytesです。ここですね」
金「Laravelと同じですね」
Cake「Laravelの話はききたくない」
金「今日はわざわざありがとうございました」
Cake「市川さんによろしく」
Drupal
金「こんにちは」
Dpl「こんにちは」
金「早速ですが、CSRFトークンはこんな感じですね。」
<input type="hidden" name="form_token" value="dQGBMO3QEJ5DjKakM1wSFlId9VLQ5HVBR9Ak0YqsVo0" />
Dpl「はい、ここらへんを見ていただければ」
/**
* Generates a token based on $value, the user session, and the private key.
*
* The generated token is based on the session of the current user. Normally,
* anonymous users do not have a session, so the generated token will be
* different on every page request. To generate a token for users without a
* session, manually start a session prior to calling this function.
*
* @param string $value
* (optional) An additional value to base the token on.
*
* @return string
* A 43-character URL-safe token for validation, based on the token seed,
* the hash salt provided by Settings::getHashSalt(), and the
* 'drupal_private_key' configuration variable.
*
* @see \Drupal\Core\Site\Settings::getHashSalt()
* @see \Symfony\Component\HttpFoundation\Session\SessionInterface::start()
*/
public function get($value = '') {
$seed = $this->sessionMetadata->getCsrfTokenSeed();
if (empty($seed)) {
$seed = Crypt::randomBytesBase64();
$this->sessionMetadata->setCsrfTokenSeed($seed);
}
return $this->computeToken($seed, $value);
}
金「たまに値にアンダーバーが入りますよね?」
Dpl「それはこのへんの置換処理によるものですね。」
public static function hmacBase64($data, $key) {
// $data and $key being strings here is necessary to avoid empty string
// results of the hash function if they are not scalar values. As this
// function is used in security-critical contexts like token validation it
// is important that it never returns an empty string.
if (!is_scalar($data) || !is_scalar($key)) {
throw new \InvalidArgumentException('Both parameters passed to \Drupal\Component\Utility\Crypt::hmacBase64 must be scalar values.');
}
$hmac = base64_encode(hash_hmac('sha256', $data, $key, TRUE));
// Modify the hmac so it's safe to use in URLs.
return str_replace(['+', '/', '='], ['-', '_', ''], $hmac);
}
金「Crypt::randomBytesBase64の中で呼ばれるstatic::randomBytesというのは、実装はやはりPHP標準のrandom_bytesですか?」
Dpl「はい、そのとおりです」
金「今日はありがとうございました。Drupalさんはマニュアルからハイパーリンクでソースを辿りやすくていいですね。」
Dpl「ありがとうございます。ではまた機会があればよろしくお願いします。」
##Joomla
金「こんにちは。」
Jml「こんにちは」
金「最近忙しそうですね」
Jml「立て続けに脆弱性が出ちゃって…面目ないです」
金「あのjform[id]のやつですか?」
Jml「今日はその話はしたくないです。CSRFトークンの話をしましょう」
金「Joomlaの場合CSRFトークンはこんな感じですね」
<input type="hidden" name="bbbb8070c831e37bbd16514ebc4911e0" value="1" />
金「これは今日お越しいただいたゲストの中でもかなりキャラが立ってるほうですね」
Jml「そうでしょうね」
金「CSRFトークンの生成はこのへんですね。呼び出し元がこちらです。」
/**
* Generate a random password
*
* @param integer $length Length of the password to generate
*
* @return string Random Password
*
* @since 11.1
*/
public static function genRandomPassword($length = 8)
{
$salt = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
$base = strlen($salt);
$makepass = '';
/*
* Start with a cryptographic strength random string, then convert it to
* a string with the numeric base of the salt.
* Shift the base conversion on each character so the character
* distribution is even, and randomize the start shift so it's not
* predictable.
*/
$random = JCrypt::genRandomBytes($length + 1);
$shift = ord($random[0]);
for ($i = 1; $i <= $length; ++$i)
{
$makepass .= $salt[($shift + ord($random[$i])) % $base];
$shift += ord($random[$i]);
}
return $makepass;
}
Jml「上記のJCrypt::genRandomBytesでは、最終的にはPHP標準関数のrandom_bytesを使用しています」
金「PHPのフレームワークではごく普通の実装ですね」
金「…値が1ですよね」
Jml「はい、値は1です」
金「やはり、1にはこだわっているんですよね?2じゃだめなんですか?」
Jml「いい質問ですね」
金「…(ゴクリ)」
Jml「実はご期待に添えず残念なんですが、これ、値が2でも大丈夫です」
金「本当ですか?これはJoomla界を揺るがす大スキャンダルなのでは?」
Jml「このへんのコードを見てもらえばわかるように、PHPでブーリアン値に変換した結果がfalseでなければOKです」
/**
* Checks for a form token in the request.
*
* Use in conjunction with JHtml::_('form.token') or JSession::getFormToken.
*
* @param string $method The request method in which to look for the token key.
*
* @return boolean True if found and valid, false otherwise.
*
* @since 12.1
*/
public static function checkToken($method = 'post')
{
$token = self::getFormToken();
$app = JFactory::getApplication();
if (!$app->input->$method->get($token, '', 'alnum'))
{
if (JFactory::getSession()->isNew())
{
// Redirect to login screen.
$app->enqueueMessage(JText::_('JLIB_ENVIRONMENT_SESSION_EXPIRED'), 'warning');
$app->redirect(JRoute::_('index.php'));
return true;
}
return false;
}
return true;
}
金「なるほど。1や2だとOKですが、0だとダメそうですね」
Jml「0はfalseになっちゃうんでダメです」
金「今日は一番気になっている部分を直接訊くことができてすっきりしました。ありがとうございました。」
Jml「それでは失礼します」
Spring
金「最後のゲストのSpringさんにお越しいただきました」
Spring「こんにちは。はじめまして」
金「実はつい先日までのVAddyはSpringさんのCSRFトークンを適切に扱えず、お客様がサポートに連絡してくださったので対応できたんですよ」
Spring「そうなんですね。特にそれほど変わったことはやっていないつもりですが」
金「おっしゃるとおりですね。CSRFトークンはこんな感じですね。」
<input type="hidden" name="_csrf" value="2a0e43b3-f6c2-4d74-8e61-62765c71e1e2"/>
金「名前も非常に明快に_csrfですし。当時のVAddyは値にハイフンを含んでいるケースをうまく認識できていませんでした。」
Spring「なるほど。でもCSRFトークンの値がハイフンを含むケースって、それほど珍しくないですよね?」
金「はい、今回の調査でも明らかになりましたが、そのとおりです」
金「Springの場合、CSRF関係のクラスだけで大量にありますね。本当にこんなに必要なんですか?」
Spring「Javaの悪い癖です」
金「実際にはどこでトークンを生成しているのでしょうか?」
Spring「ここです」
private String createNewToken() {
return UUID.randomUUID().toString();
}
金「なるほどUUIDをそのまま使っているんですね。非常にシンプルですね。クラスは12個もあるのに」
Spring「まぁクラスじゃなくてインターフェースもあるんですけどね」
金「今日はありがとうございました」
Spring「ありがとうございました」