CakePHPのSecurityComponentの脆弱性で任意のPHPコードが実行できる仕組み
もはや懐かしい感じのあるCakePHPのセキュリティトークンから任意のPHPを実行できる脆弱性ですが、なぜこれで任意のPHPコードが実行可能になってしまうのか心配で夜も眠れない方の為に、実行される仕組みを解説してみようと思います。詳細とアドバイザリ、PoCは以下のものを参照しています。
CakePHPのSecurityComponentが提供するCSRF対策のトークンは単一の識別子ではなく、':' でトークンと$lockedという謎の値が繋がれたものになっています。$lockedには配列をシリアライズしたものをROT13で変換したものが入っています。この$lockedに任意の値を入れることで任意のPHPコードが実行できるというのが今回の脆弱性です。
PoCでは$lockedに以下の値を指定するようになっています。
B:3:"Ncc":4:{f:7:"__pnpur";v:1;f:5:"__znc";n:2:{f:4:"Pber";n:1:{f:6:"Ebhgre";f:42:"../gzc/pnpur/crefvfgrag/pnxr_pber_svyr_znc";}f:3:"Sbb";f:24:"";}f:7:"__cnguf";n:0:{}f:9:"__bowrpgf";n:0:{}}
これは以下のようにして生成したオブジェクトをROT13で変換してシリアライズしたものです。__mapプロパティのFooに入っているものがサーバ内で実行されるPHPコードになります。キーはFooでなくても構いません。
$obj = new App(); $obj->__cache = 1; $obj->__map = array("Core" => array("Router" => "../tmp/cache/persistent/cake_core_file_map"), "Foo" => "<? phpinfo(); exit(); ?>"); $obj->__paths = array(); $obj->__objects = array();
ちなみにROT13というのはアルファベットa〜zおよびA〜Zをアルファベット順に13文字ずらしたものに置き換えるという変換処理で、もう一度同じ変換をかけるだけで元に戻るのが特徴です。暗号というよりは内容を隠す程度のものですが、PHPのシリアライズフォーマットは記号が多いので、見ての通りあまり隠れていません。これならばBASE64エンコードの方がまだましといえます。
話を戻しますと、クライアントから送られてきたセキュリティトークンは、SecurityComponentクラスの_validatePost()メソッドの以下の処理でトークンと$lockedに分解されてデシリアライズされます。
cakephp/security.php at a0a84d1a8d854365c557b630a92ed584c39db8ba · cakephp/cakephp · GitHub
$locked = null; $check = $controller->data; $token = urldecode($check['_Token']['fields']); if (strpos($token, ':')) { list($token, $locked) = explode(':', $token, 2); } unset($check['_Token']); $lockedFields = array(); $fields = Set::flatten($check); $fieldList = array_keys($fields); $locked = unserialize(str_rot13($locked));
トークンから切り出した$lockedはなんのチェックも無しにそのままunserialize()に渡されます。これが今回の脆弱性の直接の原因で、任意の値をデシリアライズできると、PHPでは以下の3つの脆弱性が発生します。
- 異常にネストした配列をデシリアライズさせるなどによるDoS攻撃を受ける
- 設計者が意図していない型や値を持ったデータを使用させて誤動作させられる
- オブジェクトをデシリアライズした時に付随して実行される処理を利用して誤動作させられる
この脆弱性では3つ目が利用されていて、オブジェクトをデシリアライズすると、そのオブジェクトが破棄されるときにデストラクタが呼び出されるということを利用しています。$lockedがデシリアライズされた結果としてAppクラスのインスタンスが生成されますから、Appクラスのデストラクタが実行されることになります。そのAppクラスのデストラクタは以下のようになっています。
cakephp/configure.php at a0a84d1a8d854365c557b630a92ed584c39db8ba · cakephp/cakephp · GitHub
function __destruct() { if ($this->__cache) { $core = App::core('cake'); unset($this->__paths[rtrim($core[0], DS)]); Cache::write('dir_map', array_filter($this->__paths), '_cake_core_'); Cache::write('file_map', array_filter($this->__map), '_cake_core_'); Cache::write('object_map', $this->__objects, '_cake_core_'); } }
Appクラスはクラスローダの機能を持っていて、クラス名とファイルパスの対応をキャッシュするようになっています。このキャッシュが__mapプロパティで、モジュール名、クラス名がキーとしたネストされたハッシュになっていて、値としてファイルパスが入っています。デストラクタではキャッシュの内容をCache::write()を使って ../tmp/cache/persistent/cake_core_file_map というファイルに保存しています。インスタンス生成時にファイルを読み込んでキャッシュを復元します。
さて、$lockedから生成されたAppクラスのインスタンスのデストラクタ実行されると、このキャッシュファイルの保存処理が実行され、キャッシュファイルは以下の内容に書き換えられます。フォーマットは、1行目にキャッシュの有効期限の日付文字列、2行目以降にデータをシリアライズしたものになっています。
1294140712
a:2:{s:4:"Core";a:1:{s:6:"Router";s:42:"../tmp/cache/persistent/cake_core_file_map";}s:3:"Foo";s:24:"<? phpinfo(); exit(); ?>";}
書き換えた結果、CoreモジュールのRouterクラスのファイルパスが今書き換えたキャッシュファイルのパスになるというのがポイントです。キャッシュファイルの中身を見ると分かるように、$lockedで指定したPHPコードがそのまま含まれています。このキャッシュを利用してCoreモジュールのRouterクラスのロードが行われると、その中に含まれるPHPコードが実行されるというわけです。実行させるPHPコードを入れるキーがFooでなくてもよいのはこのためで、どんな形であろうとキャッシュファイルにPHPコードが含まれて入れさえすれば良いのです。
実際にPHPコードが実行されるのは次のリクエストの時になります。
まず、Appクラスはbootstrap.phpでrequireされます。理由は分かりませんが、Appクラスはconfigure.phpでConfigureクラスと共に定義されています。
cakephp/bootstrap.php at a0a84d1a8d854365c557b630a92ed584c39db8ba · cakephp/cakephp · GitHub
require CORE_PATH . 'cake' . DS . 'config' . DS . 'paths.php'; require LIBS . 'object.php'; require LIBS . 'inflector.php'; require LIBS . 'configure.php'; require LIBS . 'set.php'; require LIBS . 'cache.php'; Configure::getInstance(); require CAKE . 'dispatcher.php';
PHPコードを実行するきっかけとなるCoreモジュールのRouterクラスはどこでロードされるかというと、bootstrap.phpに続いて実行されるdispatcher.phpの冒頭でApp::import()を使ってロードされます。
cakephp/dispatcher.php at a0a84d1a8d854365c557b630a92ed584c39db8ba · cakephp/cakephp · GitHub
App::import('Core', 'Router'); App::import('Controller', 'Controller', false);
このRouterクラスをロードした時点でPHPコードが実行され攻撃が成功します。リクエストの内容がなんであってもこのクラスはロードされるので、2回目のリクエストはどんなものでも構いません。PoCが同じリクエストを2回送っているのは、その方が簡単だからというのと、キャッシュの書き換えを確実に行うという面があるのだと思います。
PoCのリクエストを処理している時には、正規のAppクラスのインスタンスと、$lockedから生成されたAppクラスのインスタンスの2つが同時に存在していることになります。正規のインスタンスのデストラクタが$lockedのものよりも後で実行された場合、せっかく書き換えたキャッシュが正規の内容で上書きされてしまいます。キャッシュに変更が無かった場合はファイルを書き換えないようになっているので、同じリクエストを投げれば2回目以降は書き換えられないことが期待できます。これによって常に正規のインスタンスに勝てるようにしているという訳です。
ちなみに、もし<?と?>の外側に余計なものがあった場合にロードが失敗したならば、あるいはPHPのシリアライズのフォーマットがもっとバイナリっぽくて文字列がそのまま含まれない形式であったならば、この攻撃は成功しません。PHPのこれらの特徴があればこその攻撃方法だと言えます。
Opera mini for iPhoneではオレオレ証明書が警告されない
通常、オレオレ証明書が使われているサイトにアクセスするとブラウザは警告を表示します。iPhone版のSafariでも警告が表示されます。たとえばオレオレ証明書が使われているサイトにアクセスすると、以下のように警告が表示されます。
が、Opera miniでアクセスした場合は何も警告が表示されません。ちなみに表示が崩れているように見えるのは、Opera miniの専用サーバが元のコンテンツを携帯デバイス用の軽量コンテンツに変換して返している為です。
Opera miniではhttpsなサイトにアクセスした時には、画面上部のサイトタイトルの右側に鍵マークが表示されるようになっています。よく見るとオレオレ証明書の場合はこの鍵マークが表示されていないことが分かります。したがってURLがhttpsであるにも関わらず鍵マークが表示されていない場合はオレオレ証明書だと判断することができます。
ですが、現状のOpera miniにはhttpsなサイトを表示した直後の状態の時には証明書が有効であっても鍵マークが表示されないというバグがあります。1回Opera miniを終了してもう一度起動すると鍵マークが表示されるようになります。たとえばhttps://www.verisign.com/にアクセスしても以下のように鍵マークが表示されませんが、アプリを起動し直すと表示されるようになります。このバグのせいで、そもそも鍵マークが表示されることを知らない人もいるのではないでしょうか。
サイトを表示した直後 | |
---|---|
アプリを起動し直した後 |
以上のことから現状のOpera miniでアクセスしたサイトがオレオレ証明書を使用していないことを確認するには以下の手順が必要になります。
- アドレスバーのURLがhttpsであることを確認する
- アプリを終了してもう一度起動する
- 鍵マークが表示されていることを確認する
そもそもこの鍵マークにどの程度の意味があるのか謎です。Opera miniは表示の高速化の為に専用サーバがコンテンツを変換して返すようになっています。変換する為には一度平文に戻さなければならない為、SSL通信はOpera miniの専用サーバで終わります。専用サーバからOpera miniまでの通信がどうなっているのか分かりませんが*1、少なくとも通常のSSL通信とは異なる状態になっていることは確かです。たとえ鍵マークが表示されていたとしてもOpera miinの専用サーバは通信の内容を知りえる状態になっているということを認識しておく必要があります。
4月22日追記
この件を4月21日にIPAに報告しました。
*1:暗号化されていると思いたい
「マルチスレッドプログラミングは難しい」のまとめ
「http://tabesugi.net/video/multithread.html」の動画を見るのが面倒くさい人の為にコードと問題点をまとめてみました。コードは最終系のソースコードのProducer()とConsumer()を置き換えることで実行できるようになっています。Producer()のコードが省略されている場合は前ステップのコードを使用してください。コードの詳しい説明は動画本体の説明を見てください。
キューのみ (4分頃〜)
private static void Producer() { Console.WriteLine("producer"); for (int i = 0; i < N; i++) { int x = i; queue.Enqueue(x); Console.WriteLine("sent:" + x); } } private static void Consumer() { for (int i =0; i < N; i++) { int x = queue.Dequeue(); Console.WriteLine("received:"+x) } }
Queue<T>がキューが空の場合にDequeue()すると例外を投げる仕様のため、Producerがキューに入れる前にConsumerがキューから取り出そうとする状況が発生すると異常終了します。では、もし例外を投げる仕様ではなくnullを返す仕様だったりしたら大丈夫かというと、Queue
ということでこの方式で改善すべき点は以下の2点です。
- Enqueue()とDequeue()が同時に動かないようにする
- キューが空の場合にComsumerの処理を待ってもらうようにする
前者を解決する方法を排他制御といい、複数のスレッドがひとつのデータを操作する際に、同時にひとつのスレッドしか操作していないことを保証する為の制御です。後者はスレッドの待ち合わせで、ある状態になるまで待機したり、スレッドがある状態を変更したときに、それに対して待機しているスレッドを起こしたりする処理です。
話は後者の方を中心に進んで行きます。
キュー + spin lock (10分頃〜)
private static void Producer() { Console.WriteLine("producer"); for (int i = 0; i < N; i++) { int x = i; queue.Enqueue(x); Console.WriteLine("sent:" + x); Thread.Sleep(100); // 処理を遅くする為のウェイト } } private static void Consumer() { for (int i =0; i < N; i++) { while (queue.Count == 0) { Console.WriteLine("empty queue."); // Producerが遅いとこれがモリモリ出る } int x = queue.Dequeue(); Console.WriteLine("received:"+x) } }
スピンロックというかビジーウェイトの欠点は待つ為にプロセッサが働いているという点ですが、動画中ではConsumer()の待ちループで何もしていないので無駄具合が分かりません。上記コードのようにキューが空の場合にメッセージを出力するようにすると無駄具合が良く分かります。
無駄を無くす為にはどうすれば良いかというと、キューが空の場合はConsumerの実行を止めてもらって、キューが空でなくなった場合に再び動き出すようにします。
ちなみにここでキューの排他制御の話が出てきますが、ややこしくなるということで以降の説明からは省かれます。
キュー + ウェイト×1 (17分頃〜)
private static void Producer() { Console.WriteLine("producer"); for (int i = 0; i < N; i++) { int x = i; queue.Enqueue(x); trigger.Set(); Console.WriteLine("sent:" + x); } } private static void Consumer() { for (int i =0; i < N; i++) { Console.WriteLine("empty queue."); triiger.WaitOne(); int x = queue.Dequeue(); Console.WriteLine("received:"+x) } }
.NET Frameworkの待ち合わせ処理用のクラスであるAutoResetEventを使って、Produserがキューに入れるまでConsumerが待つようにしています。
AutoResetEventは、WaitOne()で待機しいるスレッドがある時にSet()を呼ぶと、待機しているスレッドのうちひとつが動き出したら自動的にReset()されるというものです。スレッドが動き出す前にもう一度Set()したり、待機しているスレッドが無い場合はSet()を呼んでも何も起りません。ConsumerがWaitOne()で待機している間または解除されて動いている隙にProducerがSet()を呼んで沈黙すると、キューに何かが入っているにも関わらずConsumerは止まったままになってしまうという訳です。この現象はProducerが最後の1個をEnqueue()した後に起ります。
このコードの問題は、Consumerのループの先頭では必ずキューが空になっているという前提のコードなのに、実際は空では無い場合があるというところにあります。したがって、必ず空になるようにするか、空でなくても大丈夫なようにする必要があります。
キュー + ウェイト + 条件判定 (26分頃〜)
for (int i =0; i < N; i++) { if (queue.Count == 0) { Console.WriteLine("empty queue."); triiger.WaitOne(); } int x = queue.Dequeue(); Console.WriteLine("received:"+x) Thread.Sleep(1000); }
後者の解決方法を取って、キューが空では無い場合はWaitOne()で待たないようにしたのがこのコードです。
このコードではif文を通った後は必ずキューが空では無いという前提がありますが、実はその前提は成り立ちません。マルチスレッドプログラミングでは、排他制御していない部分には必ず他のスレッドに割り込まれるという性質があります。もしConsumerが複数いた場合、WaitOne()からDequeue()のまで間に他のスレッドのConsumerが先にDequeue()してしまうかもしれません。されてしまったらDequeue()で例外が出て死ぬというわけです。
この問題を解決するにはキューの中味チェックからDequeue()までの間に他のConsumerに割り込まれないように排他制御する必要があります。
ちなみにこのコードにはもうひとつ問題があって、if文で分岐してWaitOne()するまでの間にProducerがSet()した場合に、キューが空では無いにも関わらずConsumerが止まったままになります。
キュー + ウェイト×2 (ぎっこんばったん方式) (34分頃〜)
private const int N = 10; private static Queue<int> queue; // shared private static AutoResetEvent trigger1; // shared private static AutoResetEvent trigger2; // shared private static void Main(string[] args) { queue = new Queue<int>(); trigger1 = new AutoResetEvent(false); trigger2 = new AutoResetEvent(true); new Thread(new ThreadStart(Producer)).Start(); new Thread(new ThreadStart(Producer)).Start(); new Thread(new ThreadStart(Consumer)).Start(); new Thread(new ThreadStart(Consumer)).Start(); } for (int i =0; i < N; i++) { int x = i; trigger2.WaitOne(); queue.Enqueue(x); trigger1.Set(); Console.WriteLine("sent:"+x) } for (int i =0; i < N; i++) { trigger1.WaitOne(); int x = queue.Dequeue(); trigger2.Set(); Console.WriteLine("received:"+x) }
動画内ではキューでは無いと説明していますが、もはやマルチスレッドでも無くなっています。ProducerはConsumerを起こしてから止まり、ConsumerはProducerを起こしてから止まるので同時には動かなくなっています。そして前述の通りAutoResetEventはSet()でひとつのスレッドしか動き出さない為、結果的にConsumerが同時に1つしか動きません。同時にひとつしか動かないのであれば排他制御は要らないというわけです。
キュー + Semaphore
※コードは最終形と同じなので省略
動画中で概ねうまく行っていると評価されている「キュー + ウェイト + 条件判定」には以下の2つの問題があります。
- Consumerのキューの空チェックからWaitOne()までの隙にProducerがSet()するとConsumerが止まったままになる
- ConsumerのWaitOne()〜Dequeue()までの間に他のスレッドでDequeue()されると例外が投げられる
セマフォを使えばこの問題を解決できるとして最終系のコードが実装されますが、それはセマフォと言うよりスレッドのスケジューラに近いものになっています。1回のEnqueue()でひとつのConsumerが動くようにスケジューリングすれば、止まったままのConsumerも無くなり、Consumer同士の排他制御も要らなくなるという訳です。ちなみに、肝心のスケジューリングが排他制御されていないため、複数のProducerが同時にMySemaphore#Release()すると誤動作します。
「具体例で説明するデザインパターン」はかなり間違ってる
具体例で説明するデザインパターンが半分以上、しかも全然違う説明をしているので、念のためツッコんでおきます。
Factory Method
間違い。サブクラス側で生成するオブジェクトを変えられるように、生成処理をメソッドに追い出すのがFactory Methodパターンです。たとえばツリー構造など、入れ物と要素が組になるコレクションクラスを作った場合、コレクションクラス内で要素を普通にnewしてしまうと、コレクションのサブクラスを作った際に要素のクラスが変えられないという問題が起ります。生成処理をメソッドに切り出しておけば、オーバーライドすれば変えられるので安心という訳です。
ドキュメントベースのアプリケーションフレームワークでは、ドキュメントオブジェクトを生成するためにFactory Methodパターンが使われているのが見られます。
Abstract Factory
間違い。DOMツリーの要素の様に、互いに関連するクラス群を生成するためのインターフェースを提供するものです。クラス名を直接指定して生成させないことで実装の実体を隠すことができ、柔軟な実装にすることができます。たとえばHTMLTableElementと思っているものは、実際はそのインターフェースを持っている別の何かかもしれません。
DOMのdocumentオブジェクトがまさにAbstruct Factoryです。具体例はむしろFactory Methodの方ものが近いでしょう。
Builder
間違い。Builderは、何かを生成する処理を、生成する手順を知っている人(Director)と、生成する具体的な手段を持つ人(Builder)に分離するというパターンです。
Visitorパターンの具体例にあるSAXパーサの例が近いものになるでしょう。SAXパーサがDirectorで、それに渡されているDefaultHandlerクラスの匿名クラスがBuilderにあたります。Builderを変えればDOMツリーも生成できます。
Prototype
Prototypeパターン自体の説明は間違っていませんが、JavaScriptのprototypeプロパティはこのパターンと関係ないものなので余計です。
Singleton
説明が誤った使い方の方のSingletonになっています。グローバルにひとつだけ存在し、グローバルにアクセスする手段があるためグローバル変数と同じように扱えますが、「インスタンスが1つしかないことを保証する」という方が本来の使い方です。
Iterator
具体例が間違っています。これは単にLinked Listを辿っているだけでIteratorではありません。jQuery の例もLinked Listを辿る処理をC言語風のfor文を使って書く際の定型句です。
Visitor
間違い。Visitorは、ツリーのような複数のオブジェクトを組み合わせて作られる構造から、その構造を走査して行う処理を分離するためのものです。処理の種類を増やすことは容易になりますが、その代わりに要素の種類を増やすのは面倒になります。Vistorパターンのポイントは、処理を行うオブジェクト(Vistor)を一度要素に渡して、その要素がVistorオブジェクトのメソッドを呼び出すところです。必要な処理は要素の種類ごとに異なるので、要素の方から自分にふさわしいメソッドを呼び出してもらうわけです。