Edited at

Phalcon1.3でCookieの暗号化を有効にしたら復号結果がおかしなことに…?

More than 3 years have passed since last update.

Phalconを採用しちゃうようなアグレッシブな方は、もうとっくにPhalcon 2を使っているかもしれませんが、Phalcon 1.3でCookieの暗号化を有効にしたら、サーバ側でクライアントからのCookie値を取得した時におかしな値が返ってきましたので、調査&対処したメモです。

動作を確認した環境は以下の通りです。


  • Windows 7 (32bit)

  • PHP 5.6.12 ビルトインWebサーバ

  • Phalcon 1.3.4

(追記:Windows版 Phalcon 2.0.8 でも確認しましたが、同様でした)


Phalconでのサーバ側とクライアント側の暗号化されたCookieのやり取りを検証する

Cookie値の暗号化に必要な最低限のクラスを使って検証しました。

Phalcon\Http\Request, Phalcon\Http\Response, Phalcon\Http\Response\Cookies, Phalcon\Crypt, そして Phalcon\Session\Adapter\Files です。(セッションのアダプタは何でもいいですが)

<?php

$di = new \Phalcon\DI();

$di->setShared('request', function() use ($di) {
$request = new \Phalcon\Http\Request();
$request->setDI($di);
return $request;
});

$di->setShared('response', function() use ($di) {
$response = new \Phalcon\Http\Response();
$response->setDI($di);
return $response;
});

$di->setShared('cookies', function() use ($di) {
$cookies = new \Phalcon\Http\Response\Cookies();
$cookies->setDI($di);
$cookies->useEncryption(true); // 暗号化を有効に
return $cookies;
});

$di->setShared('crypt', function() {
$crypt = new \Phalcon\Crypt();
$crypt->setKey('@hgiQnmI6mQz%/x?!:BfIsB8'); // 暗号鍵
return $crypt;
});

$di->setShared('session', function() {
$session = new \Phalcon\Session\Adapter\Files();
$session->start();
return $session;
});

$request = $di->get('request');
$response = $di->get('response');
$cookies = $di->get('cookies');

$cookieName = 'test';
$cookieValue = 'FooBarBaz';

if ($request->isGet()) {
$cookies->set($cookieName, $cookieValue);
$response->setCookies($cookies);
} elseif ($request->isPost()) {
if ($cookies->has($cookieName)) {
$cookie = $cookies->get($cookieName);
var_dump($cookie->getValue() === $cookieValue, (string)$cookie);
}
}

$response->setContent(<<<HTML
<html>
<form method="post">
<input type="submit" value="test" />
</form>
</html>
HTML

)->send();

Cookieの暗号化を有効にするには、Phalcon\Crypt もDIコンテナにセットした上で Phalcon\Http\Response\Cookies::setDI($di) を実行しておく必要があります。

こうすることで、サーバ側で Phalcon\Http\Response\Cookies::set() を呼んだ時に生成される Phalcon\Http\Cookie の値が自動的に暗号化されます。

(Cookieの値を管理しているのが Phalcon\Http\Cookie で、Phalcon\Http\Response\Cookies はそのコレクションクラスです)

そして、クライアントから送信されたCookieの値を Phalcon\Http\Cookie::getValue() を呼んで取得する時に、自動的に復号されます。

暗号化の内容は Phalcon\Crypt の設定によって決まりますが、ひとまずは公式ドキュメント Cookie 管理 — Phalcon 2.0.7 ドキュメント のサンプルコード通りに暗号鍵のみセットしました。

なお、Phalcon\Session\Adapter\Files をDIに "session"という名前でセットしているのは、そうしないと Phalcon\Http\Cookie::getValue() の際に以下のようなエラーが発生するからです。

Fatal error: Uncaught exception 'Phalcon\DI\Exception' with message 'Service 'session' was not found in the dependency injection container' in....

Phalcon\Http\Cookie が Phalcon\Di\InjectionAwareInterface を実装してますので、たぶん内部的に Phalcon\Session\BagInterface あたりに依存しているんでしょう。(サービスロケータってやつですね…)

さて、ビルトインWebサーバを起動してこのスクリプトをブラウザから呼びます。以下、Cookieのやり取りの部分を抜粋します。

$cookieName = 'test';

$cookieValue = 'FooBarBaz';

if ($request->isGet()) {
$cookies->set($cookieName, $cookieValue);
$response->setCookies($cookies);
} elseif ($request->isPost()) {
if ($cookies->has($cookieName)) {
$cookie = $cookies->get($cookieName);
var_dump($cookie->getValue() === $cookieValue, (string)$cookie);
}
}

まずサーバ側から以下のようなレスポンスヘッダが返され、フォームが表示されます。

Set-Cookie:test=D%2FZMGjJm17huZoB7LfxuKVWzSgpOkTvmAh%2FpB9VkfjOWMhepH1A2%2Fm%2B2BgJsVaUBwazQFs55jTTgvVpA28YNbw%3D%3D; path=/; httponly

そして、フォームを送信すると、クライアント側から以下のようなリクエストヘッダが送信されます。

Cookie:_ga=GA1.1.1309066711.1434331375; PHPSESSID=0nlncu6s5bg1r8qpknpt9hff222r12dp; test=D%2FZMGjJm17huZoB7LfxuKVWzSgpOkTvmAh%2FpB9VkfjOWMhepH1A2%2Fm%2B2BgJsVaUBwazQFs55jTTgvVpA28YNbw%3D%3D

'test' というキーでCookieにセットした FooBarBaz という値が暗号化され、やり取りされていることが分かります。

で、サーバ側では 'test' というキーがCookieにあれば、それを取り出して元の値との比較結果と、その文字列表現を var_dump() で表示しているのですが…。

bool(false) string(32) "FooBarBaz"

Cookieから取得した値には元の"FooBarBaz"9バイトに加えて謎の23バイトが付いてきて、値が変わっています。


Phalcon\Crypt のパディングモードを変更すると問題なし

調査したところ Phalcon\Crypt では初期設定でAES (Rijndael-256) というアルゴリズムのブロック暗号が CBCモード(Cipher Block Chaining) で使われ、デフォルトでは末尾がNULLバイトで埋められるようなのですが、どうやら値を返す際にそのNULLバイトが除去されていないようです。

<?php

$crypt = $di->get('crypt');
var_dump($crypt->getCipher()); // string(12) "rijndael-256"
var_dump($crypt->getMode()); // string(3) "cbc"
var_dump($crypt->getPadding()); // int(0)
print_r((new ReflectionClass($crypt))->getConstants()); // Array ( [PADDING_DEFAULT] => 0 [PADDING_ANSI_X_923] => 1 [PADDING_PKCS7] => 2 [PADDING_ISO_10126] => 3 [PADDING_ISO_IEC_7816_4] => 4 [PADDING_ZERO] => 5 [PADDING_SPACE] => 6 )

ということは…

var_dump(rtrim($cookie->getValue(), "\0") === $cookieValue, rtrim((string)$cookie, "\0")); // bool(true) string(9) "FooBarBaz"

こうなったわけですが、こんなアドホックな対処は嫌ですよね。パディングモードは他にも色々用意されているようなので、試しに "PADDING_ANSI_X_923" に変更してみました。

$di->setShared('crypt', function() {

$crypt = new \Phalcon\Crypt();
$crypt->setKey('@hgiQnmI6mQz%/x?!:BfIsB8');
$crypt->setPadding(\Phalcon\Crypt::PADDING_ANSI_X_923);
return $crypt;
});

ビルトインWebサーバを再起動して、フォームを送信したところ…

var_dump($cookie->getValue() === $cookieValue, $cookie->getValue());// bool(true) string(9) "FooBarBaz"

こちらは問題ありません。

PADDING_ANSI_X_923, PADDING_PKCS7, PADDING_ISO_10126, PADDING_ISO_IEC_7816_4, PADDING_ZERO, PADDING_SPACE と順に試しましたが、全て問題ありませんでした。何ということでしょう、デフォルトで利用される PADDING_DEFAULT だけがおかしいのです。

ソースは読んでいませんが、NULLバイトの除去が意図通りに行われていないのかもしれません。

パディングモードの処理内容については、.NET Frameworkの System.Security.Cryptography にある PaddingMode 列挙体の解説が簡潔で分かりやすいです。ANSIX923, ISO10126, PKCS7 について書かれています。

何かビビッときたのを選べばいいと思います。(適当)


Phalcon\Crypt で利用できるアルゴリズムとブロックモード

Phalcon\Crypt::getAvailableCiphers() で対応する暗号化アルゴリズムが、 Phalcon\Crypt::getAvailableModes() で対応するブロックモードが、それぞれ配列で返されます。

今回の環境では以下のような結果となりました。

Array

(
[0] => cast-128
[1] => gost
[2] => rijndael-128
[3] => twofish
[4] => cast-256
[5] => loki97
[6] => rijndael-192
[7] => saferplus
[8] => wake
[9] => blowfish-compat
[10] => des
[11] => rijndael-256
[12] => serpent
[13] => xtea
[14] => blowfish
[15] => enigma
[16] => rc2
[17] => tripledes
[18] => arcfour
)
Array
(
[0] => cbc
[1] => cfb
[2] => ctr
[3] => ecb
[4] => ncfb
[5] => nofb
[6] => ofb
[7] => stream
)

なお、各アルゴリズムのブロック長や鍵のサイズは Mcrypt関数を使って調べることができます。

$keySize = mcrypt_module_get_algo_key_size($algorithm);

$blockSize = mcrypt_module_get_algo_block_size($algorithm);

rijndael-256ではどちらも32バイトでした。


そもそも暗号化したくなるような値をCookieでやり取りするんじゃねえよ

はい。

HTTPSを使ってCookieのsecure属性を有効にしても、中間者攻撃によるCookieの改竄は防げないそうです。

改竄によってデータにクリティカルな影響を及ぼす類の要件ではないのですが、なるべく既存のコードを触らずに、新しく設けた入口で識別子を発行した後、出口まで達した時にその識別子をクライアントから送ってもらいたい、ということがありまして…。

いわゆるログイン機能が必要なわけではないので、無駄にセッションを使うのは避けたいということでCookieを使うことにしたんですが…PhalconのCookieがセッション必須だったとは…。ちなみに暗号化を無効にしてもそこは同じでした。


追記:この振る舞いは多分バグではなく仕様

どうやらこれをバグとして挙げた方がいたものの、却下されたようです。

https://github.com/phalcon/cphalcon/issues/749#issuecomment-20268763


While you may want to rtrim($result, "\0"), this is probably not the safest solution in case you need to deal with binary data which may have trailing zeros. The recommended solution is to store the lenght of the data somewhere.


Cryptクラス自体はバイナリデータにも使われるため、単純にNULLバイトを除去しては元のデータが失われるかも知れないということでしょうか。で、それを避けるためには元のデータ長を記憶しておく必要があると。

そして、こちらの対応が回答のようです。

https://github.com/phalcon/cphalcon/issues/864#issuecomment-21209271

PKCS7への対応がされた際、同時にその対処も行われたようですが、デフォルトの振る舞いはそのまま仕様として残されたという経緯でしょうか。

色んな意味で誰得な情報ですが、何かの参考になれば幸いです。