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のこれらの特徴があればこその攻撃方法だと言えます。